Public: anyone can read this; the crew can change it.

The terminal's issues list opens the repository's plan with `m`: working, ready now, waiting on what, and parked twice, as MCP `map` reads it #7481

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7472-the-terminal-s-issues-list-opens-the-rep → main 2 commits fork

  • Merged. scout/7472-the-terminal-s-issues-list-opens-the-rep was merged into main as 1c0139f.

Review

Sign in to review this change.
4 files changed +328 −0
README.md +2
@@ -54,6 +54,8 @@ A hold an agent placed does not stand forever: the tick releases one still stand
5454
5555 In the terminal, `T` on a shipped pull's screen takes it back, as the pull page's button does: a one-line confirm names the pull, Yes opens the pull that undoes it (`revertPull`, `src/actions/revert.ts`) and shows it, No changes nothing. The key appears only to a person who can write a local repository (never an agent, a reader, a guest or a mirror), and refusals read as the page's do (`takeBackWords`). `T` also opens an app preview's terminal on an open pull; a shipped pull has no live preview, so the two never meet.
5656
57 In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge.
58
5759 A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round.
5860
5961 Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`.
src/tui/screens/issues.ts +6
@@ -43,6 +43,7 @@ import {
4343 } from "./common.ts";
4444 import { issueScreen } from "./issue.ts";
4545 import { newPullScreen } from "./newpull.ts";
46 import { planScreen } from "./plan.ts";
4647 import { pullScreen } from "./pull.ts";
4748 import { verdictsForPulls, type Verdict } from "./verdicts.ts";
4849 import {
@@ -262,6 +263,7 @@ export function issuesScreen(
262263 ...(stops() > 0 ? [binding(["P"], "pillar")] : []),
263264 binding(["a"], mine ? "all authors" : "only mine"),
264265 ...(mayWrite() ? [binding(["c"], `new ${noun}`)] : []),
266 ...(kind === "issue" && repo !== undefined ? [binding(["m"], "plan")] : []),
265267 binding(["/"], "search"),
266268 binding(["r"], "refresh"),
267269 ];
@@ -410,6 +412,10 @@ export function issuesScreen(
410412 );
411413 return true;
412414 }
415 if (e.key === "m" && kind === "issue" && repo !== undefined) {
416 ctx.push(planScreen(f, ctx, repo));
417 return true;
418 }
413419 if (e.key === "enter") {
414420 openAt(state.index);
415421 return true;
src/tui/screens/plan.ts added +143
@@ -0,0 +1,143 @@
1 /**
2 * The repository's plan in one screen: who is working, what is ready now,
3 * what waits on what, and what the crew parked twice. The reads are MCP
4 * `map`'s own (`repoGraph` under the reader's `sealedScope`, `pillarView`,
5 * `mapHeldFor`, `listActiveRuns`), so a sealed issue the reader may not see
6 * is left out here as it is there.
7 */
8
9 import { repoGraph, readyNow } from "../../core/dependencies.ts";
10 import { pillarView } from "../../core/pillars.ts";
11 import { listActiveRuns } from "../../core/runs.ts";
12 import type { Repo } from "../../core/repos.ts";
13 import { sealedScope } from "../../core/sealed.ts";
14 import { mapHeldFor } from "../../crew/held.ts";
15 import { marksFor } from "../../web/pages/map.ts";
16 import type { Ctx, Screen } from "../app/app.ts";
17 import { binding } from "../app/keys.ts";
18 import type { Session } from "../session.ts";
19 import { ListState, drawList, hitList, live, runAsync, type ListRow } from "./common.ts";
20 import { issueScreen } from "./issue.ts";
21 import { local } from "./where.ts";
22
23 /** A line of the plan: a group's heading (no `number`) or one issue. */
24 export interface PlanLine {
25 readonly text: string;
26 readonly number?: number;
27 }
28
29 export function readPlan(f: Session, repo: Repo): PlanLine[] {
30 const scope = sealedScope(f.db, repo, f.user);
31 const graph = repoGraph(f.db, repo.id, { ...scope });
32 const pillars = pillarView(f.db, repo);
33 const marks = marksFor(f.db, repo.id, graph, listActiveRuns(f.db, [repo.id]));
34 const held = mapHeldFor(f.app, repo, graph, scope);
35 const byId = new Map(graph.issues.map((i) => [i.id, i]));
36 const open = (id: string): boolean => byId.get(id)?.state === "open";
37
38 const working = graph.issues.filter((i) => i.state === "open" && marks.crew.has(i.id));
39 const twice = new Set(marks.twice.map((t) => t.number));
40 // Parked twice is the planner's: it is listed once, under its own heading.
41 const ready = readyNow(graph).filter(
42 (r) => !marks.crew.has(r.issue.id) && !twice.has(r.issue.number),
43 );
44 const readyIds = new Set(ready.map((r) => r.issue.id));
45 const waits = new Map<string, number[]>();
46 for (const e of graph.edges) {
47 const blocker = byId.get(e.blockedById);
48 if (blocker === undefined || !open(e.blockedById)) continue;
49 waits.set(e.issueId, [...(waits.get(e.issueId) ?? []), blocker.number]);
50 }
51 const waiting = graph.issues.filter(
52 (i) => open(i.id) && !marks.crew.has(i.id) && !readyIds.has(i.id) && waits.has(i.id),
53 );
54 const hold = (n: number): string => {
55 const h = held.holds.get(n);
56 return h === undefined ? "" : ` (${h.detail})`;
57 };
58
59 const lines: PlanLine[] = [{ text: `Working ${working.length}` }];
60 for (const i of working)
61 lines.push({
62 text: `#${i.number} ${i.title} — ${i.assigneeName ?? "crew"}`,
63 number: i.number,
64 });
65 lines.push({ text: `Ready now ${ready.length}` });
66 for (const { issue: i } of ready)
67 lines.push({
68 text: `#${i.number} ${i.title} — ${pillars?.of.get(i.id) ?? "no pillar"}${hold(i.number)}`,
69 number: i.number,
70 });
71 lines.push({ text: `Waiting ${waiting.length}` });
72 for (const i of waiting)
73 lines.push({
74 text: `#${i.number} ${i.title} — waits on ${waits
75 .get(i.id)!
76 .slice()
77 .sort((a, b) => a - b)
78 .map((n) => `#${n}`)
79 .join(", ")}${hold(i.number)}`,
80 number: i.number,
81 });
82 lines.push({ text: `Parked twice ${marks.twice.length}` });
83 for (const t of marks.twice)
84 lines.push({ text: `#${t.number} ${t.title} — ${t.reasons.join("; ")}`, number: t.number });
85 return lines;
86 }
87
88 export function planScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
89 const state = new ListState();
90 let lines: PlanLine[] = [];
91 /** Headings are not rows to stand on: the cursor moves on to the next issue, `dir` first. */
92 const settle = (dir: 1 | -1): void => {
93 const at = (i: number): boolean => lines[i]?.number !== undefined;
94 for (const d of [dir, -dir]) {
95 for (let i = state.index; i >= 0 && i < lines.length; i += d)
96 if (at(i)) {
97 state.index = i;
98 return;
99 }
100 }
101 };
102 const reload = (): void => {
103 lines = readPlan(f, repo);
104 state.clamp(lines.length);
105 settle(1);
106 ctx.invalidate();
107 };
108 const openAt = (index: number): void => {
109 const number = lines[index]?.number;
110 if (number !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), number));
111 };
112 return {
113 title: "Plan",
114 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")],
115 load(signal) {
116 live(f, signal, { repoId: repo.id }, () => runAsync(ctx, async () => reload()));
117 reload();
118 return Promise.resolve();
119 },
120 render(area, s) {
121 const c = ctx.theme.c;
122 const rows: ListRow[] = lines.map((l) => ({ title: l.text }));
123 drawList(s, area, c, state, rows);
124 hitList(ctx, area, state, rows.length, { open: openAt });
125 },
126 key(e) {
127 const before = state.index;
128 if (state.key(e, lines.length)) {
129 settle(state.index >= before ? 1 : -1);
130 return true;
131 }
132 if (e.key === "enter") {
133 openAt(state.index);
134 return true;
135 }
136 if (e.key === "r") {
137 reload();
138 return true;
139 }
140 return false;
141 },
142 };
143 }
test/tui-plan.test.ts added +177
@@ -0,0 +1,177 @@
1 /** `m` on the issues list opens the plan: working, ready now, waiting, parked twice; Enter opens the issue. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { addDependency } from "../src/core/dependencies.ts";
11 import { addComment, createIssue, type Issue } from "../src/core/issues.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { HOT_PULLS, rememberPullFiles } from "../src/crew/hot_files.ts";
14 import { claimRun, createRun } from "../src/core/runs.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { issuesScreen } from "../src/tui/screens/issues.ts";
18 import { local } from "../src/tui/screens/where.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { settleApp } from "./helpers/tui.ts";
23
24 quietLogs();
25
26 let dir: string;
27 let forge: Forge;
28 let alice: User;
29 let repo: Repo;
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-tui-plan-"));
33 forge = createApp({ dataDir: dir, port: 0 });
34 const a = await seedAdmin(forge, {
35 username: "alice",
36 email: "[email protected]",
37 password: "a-long-passphrase",
38 });
39 if (!a.ok) throw new Error("signup failed");
40 alice = a.value;
41 const r = await createRepo(forge, alice, { name: "plan", visibility: "public" });
42 if (!r.ok) throw new Error("repo failed");
43 repo = r.value;
44 });
45 afterAll(async () => {
46 forge.close?.();
47 await rm(dir, { recursive: true, force: true });
48 });
49
50 const make = async (title: string): Promise<Issue> => {
51 const r = await openIssue(forge, alice, repo, { title, body: "x" });
52 if (!r.ok) throw new Error(`issue failed: ${r.error}`);
53 return r.value;
54 };
55
56 const open = async (user: User, where: Repo = repo, kind: "issue" | "pull" = "issue") => {
57 const { app, session } = createForgeApp(
58 forge,
59 { sshUsername: user.username, remoteAddress: "127.0.0.1", user },
60 { terminal: headlessTerminal(120, 30), exitHooks: false },
61 );
62 app.start();
63 await settleApp(app);
64 app.push(issuesScreen(session, app.ctx, local(session, where), kind));
65 await settleApp(app);
66 const press = async (key: string): Promise<void> => {
67 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
68 await settleApp(app);
69 };
70 return { app, press, text: () => app.screen.toText() };
71 };
72
73 test("m shows the plan in four groups, Enter opens an issue, a sealed issue stays out of a reader's", async () => {
74 const ready = await make("Ready thing");
75 const waiting = await make("Waiting thing");
76 const running = await make("Running thing");
77 addDependency(forge.db, waiting, ready, alice.id);
78 expect(
79 claimRun(
80 forge.db,
81 createRun(forge.db, { repoId: repo.id, kind: "agent", issueId: running.id, actorId: alice.id, reason: "assigned" }).id,
82 ),
83 ).toBeDefined();
84 const sealed = createIssue(forge.db, { repoId: repo.id, authorId: alice.id, title: "Secret report", sealed: true });
85 if (!sealed.ok) throw new Error(String(sealed.error));
86
87 const h = await open(alice);
88 expect(h.text()).toContain("m plan");
89 await h.press("m");
90 const t = h.text();
91 expect(t).toContain("Working 1");
92 expect(t).toContain("Ready now");
93 expect(t).toContain("Waiting 1");
94 expect(t).toContain("Parked twice 0");
95 expect(t).toContain(`#${running.number} Running thing`);
96 expect(t).toContain(`#${waiting.number} Waiting thing — waits on #${ready.number}`);
97 expect(t).toContain(`#${ready.number} Ready thing`);
98
99 // The cursor starts on the first issue row, not on the "Working" heading.
100 await h.press("enter");
101 expect(h.app.screen.toText()).toContain("Running thing");
102 expect(h.app.screen.toText()).not.toContain("Ready now");
103 h.app.stop();
104
105 const carol = await createUser(forge.db, {
106 origin: "seed",
107 trusted: true,
108 username: "carol",
109 email: "[email protected]",
110 password: "a-good-passphrase",
111 });
112 if (!carol.ok) throw new Error(String(carol.error));
113 const mine = await open(alice);
114 await mine.press("m");
115 expect(mine.text()).toContain("Secret report");
116 mine.app.stop();
117 const theirs = await open(carol.value);
118 await theirs.press("m");
119 expect(theirs.text()).toContain("Ready thing");
120 expect(theirs.text()).not.toContain("Secret report");
121 theirs.app.stop();
122 });
123
124 test("a parked-twice issue has its row with every reason; a held ready issue says what holds it; an empty plan shows four empty groups", async () => {
125 const r = await createRepo(forge, alice, { name: "held", visibility: "public" });
126 if (!r.ok) throw new Error("repo failed");
127 const where = r.value;
128 const add = (title: string, body = ""): Issue => {
129 const made = createIssue(forge.db, { repoId: where.id, authorId: alice.id, title, body });
130 if (!made.ok) throw new Error(String(made.error));
131 return made.value;
132 };
133
134 const empty = await open(alice, where);
135 await empty.press("m");
136 for (const heading of ["Working 0", "Ready now 0", "Waiting 0", "Parked twice 0"])
137 expect(empty.text()).toContain(heading);
138 expect(empty.text()).not.toContain("Reading…");
139 empty.app.stop();
140
141 const hot = add("Add a flag to src/hot.ts", "");
142 const stuck = add("Stuck thing");
143 for (const why of ["the tests would not build", "the diff kept conflicting"])
144 addComment(forge.db, { issueId: stuck.id, authorId: alice.id, body: `Parked by the crew: ${why}.` });
145 rememberPullFiles(
146 where.id,
147 Array.from({ length: HOT_PULLS }, (_, i) => ({ number: i + 1, files: ["src/hot.ts"] })),
148 );
149 try {
150 const h = await open(alice, where);
151 await h.press("m");
152 const lines = h.text().split("\n");
153 const row = (needle: string): string => lines.find((l) => l.includes(needle)) ?? "";
154 expect(h.text()).toContain("Parked twice 1");
155 expect(row(`#${stuck.number} Stuck thing`)).toContain("the tests would not build; the diff kept conflicting");
156 expect(h.text()).toContain("Ready now 1");
157 expect(row(`#${hot.number} Add a flag`)).toContain("src/hot.ts is in");
158 // Enter on the first row lands on an issue, never on a heading.
159 await h.press("enter");
160 expect(h.text()).toContain("Add a flag to src/hot.ts");
161 expect(h.text()).not.toContain("Parked twice");
162 h.app.stop();
163 } finally {
164 rememberPullFiles(where.id, []);
165 }
166 });
167
168 test("m is offered on the issues list and not on the pulls list", async () => {
169 const issues = await open(alice, repo, "issue");
170 expect(issues.text()).toContain("m plan");
171 issues.app.stop();
172 const pulls = await open(alice, repo, "pull");
173 expect(pulls.text()).not.toContain("m plan");
174 await pulls.press("m");
175 expect(pulls.text()).not.toContain("Parked twice");
176 pulls.app.stop();
177 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs