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

The asks list holds every pull waiting on a person: handed over, or untouched by a person for 24 h #4176

Merged opened this

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

Merged

scout/forge:scout/3256-the-asks-list-holds-every-pull-waiting-o → main 7 commits fork 2 threads · 2 unresolved

  • Merged. scout/3256-the-asks-list-holds-every-pull-waiting-o was merged into main as b165476.

Review

Sign in to review this change.
7 files changed +490 −10
README.md +2
@@ -821,6 +821,8 @@ and `forge sync` below is what acts on it.
821821
822822 **What an ask row waits for.** Every ask row carries one word, `waitingFor` (`src/core/waiting_for.ts`, computed once), and who it waits on, e.g. "ready to land · on you". The first that applies wins: `conflicts with main` (the last sweep found files stopping the pull, on its author), `ready to land` (a green change to look at and ship), `a review` (a review or a hand-over), `assigned to you` (the pull or issue is assigned to the viewer), otherwise `a decision`. It shows as a chip on each row of `/asks`, as `waitingFor: {word, on}` in `GET /api/v1/asks` and the MCP `asks` tool, and in the terminal's On you card. A grouped row shares one word (the weightiest of its items), and the terminal's card shows its row's word.
823823
824 **Waiting.** A pull waits on a person when the crew handed it over, or when no person has acted on it for 24 hours (`WAIT_LIMIT_MS`, `src/core/pull_wait.ts`): a person's opening, comment or review restarts the clock, the crew's own words do not. The row is "Waiting" with how long (`waiting 30 hours`); it is on `/asks`, `GET /api/v1/asks` (`kind: "waiting"`, `why`), MCP `asks`, and in the terminal's On you card (`waiting` before the title). A pull you hold never asks for being idle, and a crew pull still at work or younger than a day stays the crew's. It can be picked, shipped or closed with the other rows (`waiting:<id>`). A crew member can advise by ending a comment with one line, `Recommendation: merge|close|change — why`; the newest such line from a non-person is shown as "The crew advises: merge — why" on the page and the terminal row, and as `recommendation: {verdict, reason}` in the API, on handed-over rows too.
825
824826 **Muting a repository from /asks**: each ask row on `/asks` carries a "Mute owner/name" chip. A muted repository's asks leave `/asks`, `/api/v1/asks` and the phone's pushes (the same per-user mute the On you card uses, no new table); `/asks` says "N muted repositories still ask" with a "Listen again" button, and an app's own page still counts them. The home itself renders no asks, no for-you list and no count: the top bar carries a plain Asks link to `/asks`, and the terminal's dashboard keeps its On you card on purpose (a different door).
825827
826828 **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (`/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks on `/asks` (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card.
src/core/pull_wait.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * How long an open pull has waited on a person, and what the crew advised.
3 * A pull no person has touched for a day is a person's to decide, whatever
4 * its check says; the crew's advice is a line it wrote in the thread,
5 * `Recommendation: merge|close|change — why`, read here and nowhere else.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import type { Issue } from "./issues.ts";
10
11 /** A day, in ms: how long a pull may go without a person before it asks one. */
12 export const WAIT_LIMIT_MS = 24 * 3_600_000;
13
14 export type Verdict = "merge" | "close" | "change";
15
16 export interface Recommendation {
17 readonly verdict: Verdict;
18 readonly reason: string;
19 }
20
21 const CHUNK = 400;
22 const LINE = /^\s*recommendation:\s*(merge|close|change)\b[\s:—–-]*(.*)$/im;
23
24 /**
25 * When a person last acted on each pull, or its start if none has: the
26 * author's opening (when a person opened it), and any comment or review of
27 * a person. One query per chunk each, never a read per pull.
28 */
29 export function lastPersonAction(
30 db: Database,
31 pulls: readonly Issue[],
32 ): Map<string, string> {
33 const out = new Map(
34 pulls.map((p) => [p.id, p.createdAt] as const),
35 );
36 const ids = [...out.keys()];
37 for (let n = 0; n < ids.length; n += CHUNK) {
38 const batch = ids.slice(n, n + CHUNK);
39 const marks = batch.map((_, k) => `?${k + 1}`).join(",");
40 for (const table of ["comment", "review"] as const)
41 for (const row of db
42 .query<{ id: string; at: string }, string[]>(
43 `SELECT ${table}.issue_id AS id, MAX(${table}.created_at) AS at
44 FROM ${table} JOIN user ON user.id = ${table}.author_id
45 WHERE ${table}.issue_id IN (${marks}) AND user.kind = 'user' AND user.is_agent = 0
46 GROUP BY ${table}.issue_id`,
47 )
48 .all(...batch))
49 if (row.at > out.get(row.id)!) out.set(row.id, row.at);
50 }
51 return out;
52 }
53
54 /** The newest recommendation a crew member wrote on each pull that has one. */
55 export function recommendationsFor(
56 db: Database,
57 issueIds: readonly string[],
58 ): Map<string, Recommendation> {
59 const out = new Map<string, Recommendation>();
60 for (let n = 0; n < issueIds.length; n += CHUNK) {
61 const batch = issueIds.slice(n, n + CHUNK);
62 const marks = batch.map((_, k) => `?${k + 1}`).join(",");
63 for (const row of db
64 .query<{ id: string; body: string }, string[]>(
65 `SELECT comment.issue_id AS id, comment.body AS body
66 FROM comment JOIN user ON user.id = comment.author_id
67 WHERE comment.issue_id IN (${marks}) AND user.is_agent = 1
68 AND comment.body LIKE '%ecommendation:%'
69 ORDER BY comment.created_at, comment.id`,
70 )
71 .all(...batch)) {
72 const m = LINE.exec(row.body);
73 if (m) out.set(row.id, { verdict: m[1]!.toLowerCase() as Verdict, reason: m[2]!.trim().slice(0, 300) });
74 }
75 }
76 return out;
77 }
78
79 /** "waiting 30 hours" / "waiting 4 days", as a person says a wait. */
80 export function waitWords(ms: number): string {
81 const hours = Math.floor(ms / 3_600_000);
82 if (hours < 48) return `waiting ${hours} ${hours === 1 ? "hour" : "hours"}`;
83 const days = Math.floor(hours / 24);
84 return `waiting ${days} days`;
85 }
src/core/waiting_for.ts +1 −1
@@ -18,7 +18,7 @@ export interface WaitingFor {
1818
1919 export interface WaitFacts {
2020 /** The ask's kind (`DecisionKind`). */
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal";
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal";
2222 /** Files stopping the pull from merging, as the last sweep found it. */
2323 readonly conflicts: number;
2424 /** Whether the pull or issue the ask is about is assigned to the viewer. */
src/tui/screens/dashboard.ts +18 −5
@@ -131,6 +131,10 @@ interface OnYouItem {
131131 readonly hand?: true;
132132 /** A pull the crew handed to a person: closed, marked and opened like any row. */
133133 readonly over?: true;
134 /** For a handed-over row: the crew's advice, when it wrote one. */
135 readonly overAdvice?: string;
136 /** A pull no person has touched for a day, with the crew's advice when it wrote one. */
137 readonly late?: { readonly advice: string };
134138 /** What it waits for, and on whom: the word the web's asks row shows. */
135139 readonly waiting: WaitingFor;
136140 }
@@ -319,6 +323,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
319323 rows: ReturnType<typeof askLinesFor>,
320324 waitingOf: (id: string) => WaitingFor,
321325 ): OnYouItem[] => {
326 const advice = (id: string, kind: "waiting" | "handover" = "waiting"): string => {
327 const r = rows.find((row) => row.items.includes(`${kind}:${id}`))?.recommendation;
328 return r === undefined ? "" : ` · crew advises: ${r.verdict}${r.reason === "" ? "" : ` — ${r.reason}`}`;
329 };
322330 if (f.user === undefined) return [];
323331 const idsOf = (kind: string, prefix: string): string[] =>
324332 rows
@@ -326,11 +334,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
326334 .flatMap((row) => row.items)
327335 .filter((id) => id.startsWith(prefix))
328336 .map((id) => id.slice(prefix.length));
329 const wanted: { id: string; flag: "hand" | "over" }[] = [
337 const wanted: { id: string; flag: "hand" | "over" | "late" }[] = [
330338 ...(f.app.config.decider === undefined
331339 ? []
332340 : idsOf("parked", "parked:").map((id) => ({ id, flag: "hand" as const }))),
333341 ...idsOf("handover", "handover:").map((id) => ({ id, flag: "over" as const })),
342 ...idsOf("waiting", "waiting:").map((id) => ({ id, flag: "late" as const })),
334343 ];
335344 const out: OnYouItem[] = [];
336345 const seen = new Set<string>();
@@ -340,7 +349,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
340349 const issue = getIssueById(f.db, id);
341350 const repo = issue === undefined ? undefined : getRepoById(f.db, issue.repoId);
342351 if (issue !== undefined && repo !== undefined)
343 out.push({ issue, repo, waiting: waitingOf(id), ...(flag === "hand" ? { hand: true as const } : { over: true as const }) });
352 out.push({ issue, repo, waiting: waitingOf(id), ...(flag === "hand"
353 ? { hand: true as const }
354 : flag === "over"
355 ? { over: true as const, overAdvice: advice(id, "handover") }
356 : { late: { advice: advice(id) } }) });
344357 }
345358 return out;
346359 };
@@ -409,7 +422,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
409422
410423 /** One verb for the set, through the same action the web and API use. */
411424 const clear = (action: "close" | "mute" | "ship", reason?: string): void => {
412 const items = targets().map((t) => `${t.over === true ? "handover" : "issue"}:${t.issue.id}`);
425 const items = targets().map((t) => `${t.over === true ? "handover" : t.late !== undefined ? "waiting" : "issue"}:${t.issue.id}`);
413426 if (items.length === 0) return;
414427 runAsync(ctx, async () => {
415428 const done = await applyAsksBulk(f.app, f.user, action, items, reason);
@@ -1009,8 +1022,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10091022 ? PULL_MARK
10101023 : ISSUE_MARK;
10111024 return {
outdated

The advice is appended only when item.late is set. Handed-over rows (item.over) carry a recommendation in askLinesFor too, but it is dropped here, which contradicts the README. Compute the advice for over rows as well and cover it in test/asks-waiting.test.ts.

1012 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
1013 meta: `${item.waiting.word} · on ${item.waiting.on}`,
1025 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : item.late !== undefined ? "waiting " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
1026 meta: `${item.waiting.word} · on ${item.waiting.on}${item.late?.advice ?? item.overAdvice ?? ""}`,
10141027 };
10151028 }),
10161029 ...waves.map(
src/web/studio/asks.ts +95 −3
@@ -15,6 +15,7 @@ import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
1515 import { crewForRepos } from "../../core/agents.ts";
1616 import { appsForRepos } from "../../core/apps.ts";
1717 import { latestDecision, latestDecisionsFor, stopStands } from "../../core/decisions.ts";
18 import { lastPersonAction, recommendationsFor, waitWords, WAIT_LIMIT_MS, type Recommendation } from "../../core/pull_wait.ts";
1819 import { getHold, holdsFor } from "../../core/holds.ts";
1920 import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts";
2021 import { chipsOf, type Chips } from "../../core/feed_chips.ts";
@@ -106,7 +107,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
106107 );
107108 }
108109
109 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal";
110 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal";
110111
111112 /** Dollars as a person reads them: cents when small, whole when not. */
112113 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
@@ -137,6 +138,8 @@ export interface Decision {
137138 readonly consequence: Consequence;
138139 /** What it waits for, and on whom (`core/waiting_for.ts`). */
139140 readonly waiting: WaitingFor;
141 readonly why?: string | undefined;
142 readonly recommendation?: Recommendation | undefined;
140143 }
141144
142145 /** What an ask asks, the answers it takes, and what happens if nobody gives one. */
@@ -158,6 +161,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = {
158161 options: ["Ship", "Say what is off"],
159162 ifNothing: "it waits; an agent's change never ships unreviewed",
160163 },
164 waiting: {
165 decision: "No one has looked at this in a day: merge it, close it, or say what to change?",
166 options: ["Ship", "Say what to change", "Close it"],
167 ifNothing: "it keeps waiting for you",
168 },
161169 stopped: {
162170 decision: "The check fails and the crew stopped: what should it try?",
163171 options: ["Tell them what to try", "Close it"],
@@ -213,6 +221,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = {
213221 declined: { score: 70, why: "the planner has stopped for this app" },
214222 stopped: { score: 50, why: "red, and the crew stopped" },
215223 review: { score: 45, why: "an agent's change waits for a review" },
224 waiting: { score: 42, why: "no person has acted on it for a day" },
216225 handover: { score: 55, why: "three reviews without an approval: handed to a person" },
217226 parked: { score: 40, why: "parked by the crew" },
218227 look: { score: 30, why: "green, waiting" },
@@ -279,8 +288,16 @@ interface AskCandidate {
279288 readonly decline?: { readonly runId: string; readonly words: string };
280289 /** For "handover": the reviews that asked for changes. */
281290 readonly handover?: Handover;
291 /** For "handover" and "waiting": one sentence on why a person must decide. */
292 readonly why?: string;
293 /** For "handover" and "waiting": what the crew advised, when it wrote one. */
294 readonly recommendation?: Recommendation;
282295 }
283296
297 /** How a handed-over pull says why: the rounds, and the hand-over. */
298 const THREE_ROUNDS = "three reviews asked for changes";
299 const HANDOVER_WHY = `${THREE_ROUNDS} with no approval between; the crew handed it to a person`;
300
284301 /**
285302 * Open pulls the viewer may land, sorted into what asks them: stopped (red
286303 * past the crew's own limit since a person last spoke — the crew's own
@@ -352,6 +369,7 @@ function askCandidatesFor(
352369 new Date(),
353370 handedOver,
354371 );
372 const first = out.length;
355373 for (const issue of pulls) {
356374 const repo = byId.get(issue.repoId)!;
357375 const pull = joined.get(issue.id);
@@ -381,6 +399,7 @@ function askCandidatesFor(
381399 who,
382400 authorIsAgent: issue.authorIsAgent,
383401 handover: over,
402 why: HANDOVER_WHY,
384403 });
385404 continue;
386405 }
@@ -463,6 +482,38 @@ function askCandidatesFor(
463482 });
464483 }
465484 }
485 // Whatever else a pull is, one no person has touched for a day is theirs
486 // to decide, whatever its check says. The crew's own pull still at work
487 // never reaches here; nor does one younger than a day.
488 const asked = new Set(out.slice(first).map((a) => a.pull?.id));
489 const idle = pulls.filter(
490 (p) => joined.has(p.id) && !asked.has(p.id) && held.get(p.id)?.heldBy !== user.id && !working.has(p.id),
491 );
492 const acted = lastPersonAction(db, idle);
493 const nowMs = Date.now();
494 const stale = idle.filter((p) => nowMs - Date.parse(acted.get(p.id)!) > WAIT_LIMIT_MS);
495 const advice = recommendationsFor(db, [...stale.map((p) => p.id), ...out.slice(first).filter((a) => a.kind === "handover").map((a) => a.pull!.id)]);
496 for (const issue of stale) {
497 const since = acted.get(issue.id)!;
498 const rounds = handedOver.has(issue.id) ? `, ${THREE_ROUNDS}` : "";
499 const rec = advice.get(issue.id);
500 out.push({
501 kind: "waiting",
502 id: `waiting:${issue.id}`,
503 since,
504 repo: byId.get(issue.repoId)!,
505 pull: joined.get(issue.id)!,
506 who: issue.authorName,
507 authorIsAgent: issue.authorIsAgent,
508 why: `${waitWords(nowMs - Date.parse(since))}${rounds}`,
509 ...(rec === undefined ? {} : { recommendation: rec }),
510 });
511 }
512 for (let i = first; i < out.length; i++) {
513 const a = out[i]!;
514 const rec = a.kind === "handover" ? advice.get(a.pull!.id) : undefined;
515 if (rec !== undefined) out[i] = { ...a, recommendation: rec };
516 }
466517 const crew = crewForRepos(
467518 db,
468519 repos.map((r) => r.id),
@@ -644,7 +695,7 @@ function consequencesOf(
644695 /** The row an ask collapses into: one kind in one repository. */
645696 const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string =>
646697 // Work assigned to you is each its own row: they share no decision.
647 a.kind === "assigned" || a.kind === "proposal" ? a.id : `${a.kind}:${a.repo.id}`;
698 a.kind === "assigned" || a.kind === "waiting" || a.kind === "proposal" ? a.id : `${a.kind}:${a.repo.id}`;
648699
649700 /**
650701 * The asks the viewer has not set aside: a muted repository's asks are all
@@ -818,7 +869,7 @@ export function endOf(
818869 raisedAt: string,
819870 ): Exclude<AskEnd, "dismissed"> {
820871 const [kind, what] = askId.split(":") as [DecisionKind, string];
821 if (kind === "look" || kind === "review" || kind === "stopped" || kind === "handover") {
872 if (kind === "look" || kind === "review" || kind === "stopped" || kind === "handover" || kind === "waiting") {
822873 const pull = getIssueById(db, what);
823874 // Shipped or closed: someone decided. Held by the one it asked: they
824875 // answered "not yet". Still open otherwise: the cause went away.
@@ -904,6 +955,7 @@ function lineOf(c: AskCandidate): string {
904955 return `proposes "${c.proposal?.title ?? ""}" and waits for your nod`;
905956 if (c.kind === "declined")
906957 return "the planner declined the direction and waits for you";
958 if (c.kind === "waiting") return `"${c.pull?.title ?? ""}" is ${c.why ?? "waiting"} for a person`;
907959 if (c.kind === "handover")
908960 return `"${c.pull?.title ?? ""}" had three reviews asking for changes and waits for you`;
909961 const title = c.pull?.title ?? "";
@@ -931,6 +983,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> =
931983 stopped: (n) => `the crew stopped on ${n} changes`,
932984 parked: (n) => `the crew parked ${n} issues after three failed runs`,
933985 handover: (n) => `${n} changes had three reviews asking for changes and wait for you`,
986 waiting: (n) => `${n} changes have waited a day for a person`,
934987 declined: (n) => `the planner declined ${n} times and waits for you`,
935988 proposal: (n) => `${n} waves wait for your nod`,
936989 paused: (n) => `${n} pauses at the ceiling`,
@@ -974,6 +1027,10 @@ export function askLinesFor(
9741027 consequence: Consequence;
9751028 /** What the row waits for (the weightiest ask's), and on whom. */
9761029 waitingFor: WaitingFor;
1030 /** For a row of one handed-over or long-waiting pull: why a person must decide. */
1031 why?: string;
1032 /** The crew's advice on it, when it wrote one. */
1033 recommendation?: Recommendation;
9771034 }[] {
9781035 const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true });
9791036 return askRows(waitingOn(db, user, repos, chips)).map((row) => {
@@ -989,6 +1046,8 @@ export function askLinesFor(
9891046 waitingFor: row.items[0]!.waiting,
9901047 };
9911048 const only = row.items.length === 1 ? row.items[0]! : undefined;
1049 if (only?.why !== undefined) Object.assign(about, { why: only.why });
1050 if (only?.recommendation !== undefined) Object.assign(about, { recommendation: only.recommendation });
9921051 if (only === undefined)
9931052 return {
9941053 ...about,
@@ -1113,6 +1172,20 @@ export function decisionsFor(
11131172 who: c.who,
11141173 says: `The reviewer asked for changes three times with no approval between, so the crew stopped. ${handoverLines(c.handover).join(" ")} Say what to try (or approve it on its page), or close it.`,
11151174 short: `"${c.pull.title}" had three reviews asking for changes`,
1175 why: c.why,
1176 recommendation: c.recommendation,
1177 };
1178 if (c.kind === "waiting" && c.pull)
1179 return {
1180 kind: "waiting",
1181 id: `waiting:${c.pull.id}`,
1182 repo: c.repo,
1183 pull: c.pull,
1184 who: c.who,
1185 says: `"${c.pull.title}" is ${c.why ?? "waiting"}, and no person has acted on it. Ship it, say what to change, or close it.`,
1186 short: c.why ?? "waiting for a person",
1187 why: c.why,
1188 recommendation: c.recommendation,
11161189 };
11171190 if (c.kind === "stopped" && c.pull) {
11181191 const tries = c.tries ?? 0;
@@ -1209,6 +1282,13 @@ export function decisionForm(d: Decision, back?: string): Html | null {
12091282 return html`<form ${own} action="${base}/pulls/${pull.number}/ship">
12101283 <input type="hidden" name="pull" value="${pull.number}" />${where}
12111284 </form>`;
1285 if (d.kind === "waiting")
1286 return html`<form ${own} action="${base}/pulls/${pull.number}/ship">
1287 <input type="hidden" name="pull" value="${pull.number}" />${where}
1288 </form>
1289 <form id="${askFormId(d)}-close" method="post" hidden action="${base}/pulls/${pull.number}/state">
1290 <input type="hidden" name="state" value="closed" />${where}
1291 </form>`;
12121292 return null;
12131293 }
12141294
@@ -1251,6 +1331,17 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
12511331 }),
12521332 ],
12531333 };
1334 if (d.kind === "waiting" && pull)
1335 return {
1336 primary: btn("Ship", { size: "sm", form: askFormId(d), variant: "primary" }),
1337 others: [
1338 btn("Say what to change", {
1339 href: `${changeHref(base, pull.number)}#comment-body`,
1340 size: "sm",
1341 }),
1342 btn("Close", { size: "sm", form: `${askFormId(d)}-close` }),
1343 ],
1344 };
12541345 if (d.kind === "assigned" && (d.pull ?? d.issue))
12551346 return {
12561347 primary: btn("Open", {
@@ -1352,6 +1443,7 @@ export function askItemsOf(issue: Issue): string[] {
13521443 `ask:review:${issue.id}`,
13531444 `ask:stopped:${issue.id}`,
13541445 `ask:handover:${issue.id}`,
1446 `ask:waiting:${issue.id}`,
13551447 `ask:assigned:${issue.id}`,
13561448 ]
13571449 : [`ask:parked:${issue.id}`, `ask:assigned:${issue.id}`];
src/web/studio/asks_page.ts +11 −1
@@ -69,10 +69,12 @@ const ASK_KIND_WORDS: Readonly<Record<string, string>> = {
6969 stopped: "Stopped",
7070 parked: "Parked",
7171 assigned: "Assigned",
72 waiting: "Waiting",
73 handover: "Handed over",
7274 };
7375
7476 /** The kinds a bulk action takes: a ceiling and a planner's decline are decided where they live. */
75 const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "handover", "parked", "assigned"]);
77 const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "handover", "waiting", "parked", "assigned"]);
7678
7779 /** A row's box for the bulk form, which sits apart (`form="asks-bulk"`): forms do not nest. */
7880 const pick = (d: Decision, picked: ReadonlySet<string>): Html | null =>
@@ -172,6 +174,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = {
172174 review: "Needs review",
173175 stopped: "Stopped",
174176 handover: "Handed over",
177 waiting: "Waiting",
175178 parked: "Parked",
176179 declined: "Declined",
177180 proposal: "Proposed",
@@ -197,6 +200,12 @@ function acts(ctx: Ctx, d: Decision): Html {
197200 </div>`;
198201 }
199202
203 /** What the crew advised on a pull waiting for a person, in one plain line; nothing when it wrote none. */
204 const adviceLine = (d: Decision): Html | null =>
205 d.recommendation === undefined
206 ? null
207 : html`<p class="small muted">The crew advises: ${d.recommendation.verdict}${d.recommendation.reason === "" ? "" : html` — ${d.recommendation.reason}`}</p>`;

Uses the .small and .muted utilities, so it fits the design system. If the advice becomes a recurring part of rows, give it a named component rather than an inline paragraph.

208
200209 /** A wave proposal's why, its issue titles and the Redirect box; nothing for any other ask. */
201210 function proposalBody(ctx: Ctx, d: Decision): Html | null {
202211 if (!d.proposal) return null;
@@ -223,6 +232,7 @@ function decisionCard(ctx: Ctx, d: Decision, picked: ReadonlySet<string>): Html
223232 : html`<a href="${base}/settings/autonomy">Paused</a>`;
224233 return card(
225234 html`${clause(d, d.kind, d.consequence)}
235 ${adviceLine(d)}
226236 ${proposalBody(ctx, d)}
227237 ${acts(ctx, d)}`,
228238 {
test/asks-waiting.test.ts added +278
@@ -0,0 +1,278 @@
1 /**
2 * The asks list holds every pull waiting on a person (#3256): one handed
3 * over, or untouched by a person for a day whatever its check says. A crew
4 * pull still young stays the crew's.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
13 import { addComment } from "../src/core/issues.ts";
14 import { setHold } from "../src/core/holds.ts";
15 import { createRepo, type Repo } from "../src/core/repos.ts";
16 import { submitReview } from "../src/core/reviews.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { id } from "../src/kernel/id.ts";
21 import { askLinesFor } from "../src/web/studio/asks.ts";
22 import { createForgeApp } from "../src/tui/forge.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { settleApp } from "./helpers/tui.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 const ZERO = "0".repeat(40);
30 const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
31
32 interface Row {
33 id: string;
34 kind: string;
35 why?: string;
36 recommendation?: { verdict: string; reason: string };
37 }
38
39 describe("asks that wait on a person", () => {
40 let dataDir = "";
41 let app: App;
42 let servers: Servers;
43 let owner: User;
44 let builder: User;
45 let reviewer: User;
46 let repo: Repo;
47 let token = "";
48 let seq = 0;
49
50 const user = async (username: string, agent: boolean) => {
51 const made = await createUser(app.db, {
52 username,
53 email: `${username}@forge.test`,
54 ...(agent
55 ? { agent: true, origin: "agent" as const }
56 : { password: "a-good-passphrase", origin: "seed" as const, trusted: true, adminIfFirst: true }),
57 });
58 if (!made.ok) throw new Error(`seed ${username}: ${made.error}`);
59 if (agent) createAgentRow(app.db, { userId: made.value.id, brief: `${username}.` });
60 return made.value;
61 };
62
63 const pull = (author: User, title: string, age: number) => {
64 const issueId = id();
65 const number = app.db
66 .query<{ issue_seq: number }, [string]>(
67 "UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq",
68 )
69 .get(repo.id)!.issue_seq;
70 app.db
71 .query(
72 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at)
73 VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`,
74 )
75 .run(issueId, repo.id, number, title, author.id, hoursAgo(age));
76 app.db
77 .query(
78 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
79 VALUES (?1, 'main', ?2, ?3, ?3, ?4)`,
80 )
81 .run(issueId, `b/${++seq}`, ZERO, repo.id);
82 return issueId;
83 };
84
85 const rows = async (): Promise<Row[]> => {
86 const res = await fetch(`${servers.url}/api/v1/asks`, {
87 headers: { authorization: `Bearer ${token}` },
88 });
89 return ((await res.json()) as { rows: Row[] }).rows;
90 };
91
92 let handed = "";
93 let old = "";
94 let young = "";
95
96 beforeAll(async () => {
97 dataDir = await mkdtemp(join(tmpdir(), "forge-asks-waiting-"));
98 app = createApp({ dataDir, port: 0 });
99 servers = await serve(app);
100 owner = await user("alice", false);
101 builder = await user("builder", true);
102 reviewer = await user("reviewer", true);
103 const made = await createRepo(app.db, app.reposRoot, {
104 ownerId: owner.id,
105 name: "wait",
106 visibility: "public",
107 description: "wait",
108 });
109 if (!made.ok) throw new Error(`seed repo: ${made.error}`);
110 repo = made.value;
111 for (const agent of [builder, reviewer]) addCrew(app.db, repo.id, agent.id, owner.id);
112 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
113 token = createToken(app.db, owner.id, "ro", "ro").token;
114
115 handed = pull(builder, "Handed over", 3);
116 for (let r = 0; r < 3; r++)
117 submitReview(app.db, {
118 issueId: handed,
119 authorId: reviewer.id,
120 state: "changes_requested",
121 body: `Round ${r}.`,
122 commitOid: ZERO,
123 });
124 old = pull(owner, "Thirty hours", 30);
125 young = pull(builder, "Two hours", 2);
126 }, 30_000);
127
128 afterAll(async () => {
129 await servers.stop();
130 await rm(dataDir, { recursive: true, force: true });
131 });
132
133 test("lists the handed-over pull and the 30 h pull, each with its why, and not the young crew pull", async () => {
134 const got = await rows();
135 expect(got.map((r) => r.id).sort()).toEqual([`handover:${handed}`, `waiting:${old}`].sort());
136 const byKind = new Map(got.map((r) => [r.kind, r]));
137 expect(byKind.get("handover")!.why).toContain("three reviews asked for changes");
138 expect(byKind.get("waiting")!.why).toBe("waiting 30 hours");
139 expect(got.some((r) => r.id.endsWith(young))).toBe(false);
140 });
141
142 test("a handed-over row carries the crew's recommendation too", async () => {
143 addComment(app.db, {
144 issueId: handed,
145 authorId: reviewer.id,
146 body: "Recommendation: close — three rounds and no progress.",
147 });
148 const row = (await rows()).find((r) => r.id === `handover:${handed}`)!;
149 expect(row.recommendation).toEqual({ verdict: "close", reason: "three rounds and no progress." });
150 });
151
152 test("a pull the viewer holds never asks again for being idle", async () => {
153 const held = pull(builder, "Held aside", 50);
154 expect((await rows()).some((r) => r.id === `waiting:${held}`)).toBe(true);
155 setHold(app.db, held, owner.id, "later");
156 expect((await rows()).some((r) => r.id === `waiting:${held}`)).toBe(false);
157 });
158
159 test("/asks shows the Waiting row, its advice, and a pick box to ship or close it with the rest", async () => {
160 const adv = pull(builder, "Advised idle", 40);
161 addComment(app.db, { issueId: adv, authorId: reviewer.id, body: "Recommendation: change — rename the flag <b>." });
162 const signed = await fetch(`${servers.url}/signin`, {
163 method: "POST",
164 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
165 redirect: "manual",
166 });
167 const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
168 const page = await (
169 await fetch(`${servers.url}/asks`, { headers: { cookie: `${cookie}; forge_mode=studio` } })
170 ).text();
171 expect(page).toContain("Waiting");
172 expect(page).toContain("Advised idle");
173 expect(page).toContain("The crew advises: change");
174 expect(page).toContain("rename the flag &lt;b&gt;.");
175 expect(page).not.toContain("flag <b>");
176 // The row can be acted on where it stands: ship and close forms, a word, no Settings dead end.
177 const row = page.slice(page.indexOf("Advised idle") - 1500, page.indexOf("Advised idle") + 2500);
178 expect(page).toMatch(/<form id="ask-waiting:[^"]+" method="post" hidden action="[^"]*\/pulls\/\d+\/ship">/);
179 expect(page).toMatch(/<form id="ask-waiting:[^"]+-close" method="post" hidden action="[^"]*\/pulls\/\d+\/state">/);
180 expect(page).toContain("Say what to change");
181 expect(row).not.toContain("/settings/autonomy");
182 expect(page).toContain(">Handed over<");
183 expect(page).toMatch(/class="ask-pick"[^>]*value="waiting:[^"]+"[^>]*data-kind="waiting"/);
184 });
185
186 test("the MCP asks tool carries the waiting kind, its why and the recommendation", async () => {
187 const res = await fetch(`${servers.url}/mcp`, {
188 method: "POST",
189 headers: {
190 "content-type": "application/json",
191 accept: "application/json, text/event-stream",
192 authorization: `Bearer ${token}`,
193 },
194 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asks", arguments: {} } }),
195 });
196 const body = (await res.json()) as { result: { content: { text: string }[] } };
197 const got = JSON.parse(body.result.content[0]!.text) as { rows: Row[] };
198 const waiting = got.rows.filter((r) => r.kind === "waiting");
199 expect(waiting.length).toBeGreaterThan(0);
200 expect(waiting.every((r) => r.why?.startsWith("waiting "))).toBe(true);
201 expect(waiting.some((r) => r.recommendation?.verdict === "change")).toBe(true);
202 });
203
204 test("the terminal lists the waiting pull with its advice, for its viewer only", async () => {
205 const lines = askLinesFor(app.db, owner);
206 expect(lines.some((r) => r.kind === "waiting")).toBe(true);
207 const sees = async (user: User) => {
208 const terminal = headlessTerminal(240, 40);
209 const { app: ui } = createForgeApp(
210 app,
211 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user },
212 { terminal, exitHooks: false },
213 );
214 ui.start();
215 await settleApp(ui);
216 for (const key of ["tab", "j"]) {
217 ui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
218 await settleApp(ui);
219 }
220 const text = ui.screen.toText();
221 ui.stop();
222 return text;
223 };
224 const mine = await sees(owner);
225 expect(mine).toContain("waiting ");
226 expect(mine).toContain("crew advises: change");
227 expect(await sees(builder)).not.toContain("crew advises");
228 });
229
230 test("the terminal's handed-over row says what the crew advised", async () => {
231 const terminal = headlessTerminal(240, 60);
232 const { app: ui } = createForgeApp(
233 app,
234 { sshUsername: owner.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: owner },
235 { terminal, exitHooks: false },
236 );
237 ui.start();
238 await settleApp(ui);
239 const text = ui.screen.toText();
240 ui.stop();
241 expect(text).toContain("handed over");
242 expect(text).toContain("crew advises: close — three rounds and no progress.");
243 });
244
245 test("a person's word restarts the clock; the crew's recommendation rides along", async () => {
246 addComment(app.db, {
247 issueId: old,
248 authorId: reviewer.id,
249 body: "Recommendation: merge — the diff is small and green.",
250 });
251 const advised = (await rows()).find((r) => r.id === `waiting:${old}`)!;
252 expect(advised.recommendation).toEqual({ verdict: "merge", reason: "the diff is small and green." });
253
254 const said = addComment(app.db, { issueId: old, authorId: owner.id, body: "Looking." });
255 if (!said.ok) throw new Error(said.error);
256 expect((await rows()).some((r) => r.id === `waiting:${old}`)).toBe(false);
257 });
258
259 test("bulk close and bulk ship take a waiting:<id> item through the same checks", async () => {
260 const rw = createToken(app.db, owner.id, "rw", "rw").token;
261 const post = async (action: string, items: string[]) => {
262 const res = await fetch(`${servers.url}/api/v1/asks/bulk`, {
263 method: "POST",
264 headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" },
265 body: JSON.stringify({ action, items, preview: false }),
266 });
267 return (await res.json()) as { rows: { outcome: string; reason?: string }[]; skipped: unknown[] };
268 };
269 const a = pull(builder, "Bulk close me", 60);
270 const closed = await post("close", [`waiting:${a}`]);
271 expect(closed.rows.map((r) => r.outcome)).toEqual(["ok"]);
272 expect((await rows()).some((r) => r.id === `waiting:${a}`)).toBe(false);
273 const b = pull(builder, "Bulk ship me", 60);
274 const shipped = await post("ship", [`waiting:${b}`]);
275 expect(shipped.skipped).toEqual([]);
276 expect(shipped.rows).toHaveLength(1);
277 });
278 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs