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

The Later gate's one-time pass proposes keep and merge as well as close, reading each issue's body #8829

Merged opened this

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

Merged

scout/forge:scout/8813-the-later-gate-s-one-time-pass-proposes → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/8813-the-later-gate-s-one-time-pass-proposes was merged into main as 898da13.

Review

Sign in to review this change.
6 files changed +270 −54
docs/features/later-gate.md +9 −3
@@ -5,8 +5,14 @@ The Later gate (`Later gate: …`, read by `core/pillars.ts`) holds work back fr
55 - **Home** says, per repository you write, `N issues are held behind the Later gate in <repo>, set by <who> <when>` (`web/studio/held.ts`), and the line opens `/:owner/:name/held`.
66 - **A held issue** says so at its top, with **Lift** (`web/pages/gate.ts`). Lift is `liftHold` (`actions/gate.ts`): the issue stops waiting for the gate, and its `Later` label goes with it. The MCP `lift` tool is the same action.
77 - **The label and the gate are one thing.** Giving an issue `Later` puts it behind the gate; taking `Later` off lifts it (`followLabel`, called from `setIssueLabels`). The other direction, an edge changing the label, was already `actions/pillars.ts`.
8 - **The pass over the lot** (`/:owner/:name/held`, MCP `held`, the terminal's held screen) reads titles only, with no model and no run (`core/gate_triage.ts`). Unchosen ideas are dropped, not tracked (Shape Up): every held issue is a candidate to close, grouped by the word its titles share most. Only one the crew found done on main starts ticked; **unticked is keep**. An issue that open work waits for says so on its row, because closing it parks that work (`dropped` holds dependents). One button closes the ticked (`applyGateReview`). The form carries a digest of the candidates shown (`reviewDigest`, their sorted numbers); if the gate holds other issues when it is applied, it refuses and closes nothing. An agent may read the proposal and never applies it.
8 - **The pass over the lot** (`/:owner/:name/held`, MCP `held`, the terminal's held screen) reads titles and bodies, with no model and no run (`core/gate_triage.ts`). Unchosen ideas are dropped, not tracked (Shape Up): every held issue is a candidate to close, grouped by the word its titles share most, unless it is proposed keep or merge (below). Only done and merge start ticked; **unticked stays behind the gate**. An issue that open work waits for says so on its row, because closing it parks that work (`dropped` holds dependents). One button applies the ticked (`applyGateReview`). The form carries a digest of the candidates shown (`reviewDigest`, their sorted numbers and verdicts); if the gate holds other issues when it is applied, it refuses and closes nothing. An agent may read the proposal and never applies it.
99 - **Closed ones stay findable.** The apply writes the `issue.gate_reviewed` event after each close, and a comment note beside it. The event, not the note, is the marker (`closedByReview`): it is the review's only while it is newer than the issue's last close and reopen, and a comment cannot forge it. Each is listed under "Closed by the review" on the held page and shows **Revive** at its top. Revive (the same `lift` action) lets it out from behind the gate and reopens it as one outcome: the hold is checked first, and a reopen that fails puts the hold back.
10 - **The terminal** has the same page: `h` on a repository's issues, or "Held by the Later gate" in the palette (`tui/screens/held.ts`). `space` ticks, `A` closes the ticked, `l` lifts or revives. It reads and acts through the same functions, so a reader is refused in the same words and a private repository is not found; the web hears each write as the close, reopen and unblock events it already follows.
10 - **The terminal** has the same page: `h` on a repository's issues, or "Held by the Later gate" in the palette (`tui/screens/held.ts`). `space` ticks (a keep row shows `[-]` and has no tick), `A` applies the ticked (close or merge), `l` lifts or revives. It reads and acts through the same functions, so a reader is refused in the same words and a private repository is not found; the web hears each write as the close, reopen and unblock events it already follows.
1111
12 Not done: snoozing an issue until a date or until new activity; merging duplicates; a per-issue "keep" verdict beyond leaving the box unticked.
12 Not done: snoozing an issue until a date or until new activity.
13
14 ## The pass proposes keep and merge too (#8813)
15
16 `core/gate_triage.ts` reads titles and bodies in one pass of word counting: no model call, no cost. Close is the default. An issue that serves a pillar, or that a pillar's open work or a `Bet:` issue names as `#N` (or names it), is proposed **keep**, with the anchor named. A held issue whose title or body is nearly the same as an older held one is proposed **merge**: on apply, its body is appended to the survivor (never replaced), and the duplicate is closed with a note pointing at the survivor. Done and merge start ticked; keep has nothing to tick. The digest covers each verdict, so the apply refuses if the gate changed.
17
18 A merge whose survivor is ticked in the same apply is skipped, so the words never land on an issue that closes in the same breath; the duplicate stays open. A bet is found by its `Bet` title among the first 64 such open issues outside the gate; more than that are not read, which is a heuristic until bets (#8713) are a thing of their own. In the terminal, `A` applies and the toast says "Closed or merged N."; a keep row is `[-]` and `space` does nothing on it.
src/actions/gate_review.ts +78 −16
@@ -1,8 +1,10 @@
11 /**
22 * The one-time pass over what the Later gate holds (#8700): the candidates
33 * (`core/gate_triage.ts`) read for a repository, and applied on one approval.
4 * Applying closes what the person ticked; what they left unticked stays
5 * behind the gate. A closed issue keeps a "Gate review:" note and the
4 * Applying closes what the person ticked and merges what they ticked as a
5 * duplicate (the duplicate's words are appended to the survivor, never
6 * replacing them, and its note points at the survivor); what they left
7 * unticked stays behind the gate, as does a proposed keep. A closed issue keeps a "Gate review:" note and the
68 * `issue.gate_reviewed` event only this path writes, which is how it is found
79 * again and revived (`liftHold`). The approval carries a digest of the
810 * candidates the person read; if the gate holds other issues now, the apply
@@ -14,17 +16,17 @@ import { createHash } from "node:crypto";
1416 import type { App } from "../app.ts";
1517 import { alreadyDone } from "../core/done_on_main.ts";
1618 import { heldIssues, REVIEW_NOTE } from "../core/gate_hold.ts";
17 import { startsTicked, triage, type Theme, type Triaged } from "../core/gate_triage.ts";
18 import { getIssue } from "../core/issues.ts";
19 import { pillarsOf } from "../core/pillars.ts";
19 import { startsTicked, triage, type Anchor, type Theme, type Triaged } from "../core/gate_triage.ts";
20 import { getIssue, getIssuesByIds } from "../core/issues.ts";
21 import { pillarsOf, servedBy } from "../core/pillars.ts";
2022 import { canWrite, type Repo } from "../core/repos.ts";
2123 import type { User } from "../core/users.ts";
2224 import { err, ok, type Result } from "../kernel/result.ts";
23 import { addComment, setIssueState } from "./issues.ts";
25 import { addComment, editIssue, setIssueState } from "./issues.ts";
2426
2527 export interface GateReview {
2628 readonly themes: readonly Theme[];
27 /** Every held issue as a candidate to close. */
29 /** Every held issue with what is proposed for it: close, keep or merge. */
2830 readonly changes: readonly Triaged[];
2931 /** The numbers that start ticked: what the crew found done on main. */
3032 readonly ticked: readonly number[];
@@ -33,12 +35,47 @@ export interface GateReview {
3335 readonly held: number;
3436 }
3537
36 /** A short name for a set of issue numbers, whatever their order. */
37 export function reviewDigest(numbers: readonly number[]): string {
38 const sorted = [...numbers].sort((a, b) => a - b).join(",");
38 /** A short name for the candidates and what is proposed for each, whatever their order. */
39 export function reviewDigest(changes: readonly Triaged[]): string {
40 const sorted = changes
41 .map((c) => `${c.number}:${c.verdict.kind}:${c.verdict.kind === "merge" ? c.verdict.into : ""}`)
42 .sort()
43 .join(",");
3944 return createHash("sha256").update(sorted).digest("hex").slice(0, 16);
4045 }
4146
47 /** A bet is open work titled `Bet:` outside the gate (#8713). */
48 const BET = /^bet\b/i;
49
50 /**
51 * The open work held issues can join, and which held issues already serve a
52 * pillar: two reads, bodies included for the anchors only.
53 */
54 function anchorsFor(app: App, repo: Repo, heldIds: ReadonlySet<string>) {
55 const set = pillarsOf(app.db, repo);
56 const serves = servedBy(app.db, set);
57 const ids = [...serves.entries()]
58 .filter(([id, name]) => name !== "Later" && !heldIds.has(id))
59 .map(([id]) => id);
60 const found = getIssuesByIds(app.db, ids);
61 const anchors: Anchor[] = [];
62 for (const [id, name] of serves) {
63 const issue = found.get(id);
64 if (issue === undefined || issue.state !== "open" || issue.kind !== "issue") continue;
65 anchors.push({ number: issue.number, label: name, title: issue.title, body: issue.body });
66 }
67 for (const p of set.pillars) anchors.push({ number: p.umbrella.number, label: p.name, title: p.umbrella.title, body: p.umbrella.body });
68 const bets = app.db
69 .query<{ id: string }, [string]>(
70 `SELECT id FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND state = 'open' AND sealed = 0 AND title LIKE 'bet%' LIMIT 64`,
71 )
72 .all(repo.id)
73 .filter((r) => !heldIds.has(r.id));
74 for (const issue of getIssuesByIds(app.db, bets.map((r) => r.id)).values())
75 if (BET.test(issue.title)) anchors.push({ number: issue.number, label: "Bet", title: issue.title, body: issue.body });
76 return { anchors, serves };
77 }
78
4279 /** How many open issues wait for each of these, in one read. */
4380 function waitingOn(app: App, ids: readonly string[]): Map<string, number> {
4481 const out = new Map<string, number>();
@@ -55,7 +92,7 @@ function waitingOn(app: App, ids: readonly string[]): Map<string, number> {
5592 return out;
5693 }
5794
58 /** What the gate holds, triaged by title: one read. */
95 /** What the gate holds, triaged by title and body: one pass, no model. */
5996 export function reviewGate(app: App, repo: Repo): GateReview | undefined {
6097 const set = pillarsOf(app.db, repo);
6198 if (set.gate === undefined) return undefined;
@@ -63,16 +100,19 @@ export function reviewGate(app: App, repo: Repo): GateReview | undefined {
63100 const ids = held.map((i) => i.id);
64101 const done = alreadyDone(app.db, ids);
65102 const waiting = waitingOn(app, ids);
103 const { anchors, serves } = anchorsFor(app, repo, new Set(ids));
66104 const themes = triage(
67 held.map((i) => ({ number: i.number, title: i.title, waiting: waiting.get(i.id) ?? 0 })),
105 held.map((i) => ({ number: i.number, title: i.title, body: i.body, waiting: waiting.get(i.id) ?? 0 })),
outdated

byNumber/serves juggling is convoluted; i.number is available directly.

68106 new Set(held.filter((i) => done.has(i.id)).map((i) => i.number)),
107 anchors,
108 new Map(held.flatMap((i) => (serves.get(i.id) !== undefined && serves.get(i.id) !== "Later" ? [[i.number, serves.get(i.id)!] as const] : []))),
69109 );
70110 const changes = themes.flatMap((t) => t.items);
71111 return {
72112 themes,
73113 changes,
74114 ticked: changes.filter(startsTicked).map((c) => c.number),
75 digest: reviewDigest(changes.map((c) => c.number)),
115 digest: reviewDigest(changes),
76116 held: held.length,
77117 };
78118 }
@@ -83,7 +123,7 @@ export type ApplyError = "not_found" | "forbidden" | "stale";
83123 * Apply the approval the person gave: `approved` are the issue numbers they
84124 * ticked and `digest` names the candidates they read. If the gate holds other
85125 * issues now, or an approved one is not a candidate, the whole is refused, so
86 * a person never approves what they did not read. Returns how many closed.
126 * a person never approves what they did not read. Returns how many were closed or merged.
87127 */
88128 export function applyGateReview(
89129 app: App,
@@ -99,10 +139,32 @@ export function applyGateReview(
99139 const proposed = new Map(review.changes.map((c) => [c.number, c.verdict]));
100140 if (!approved.every((n) => proposed.has(n))) return err("stale");
101141 let closed = 0;
102 for (const number of new Set(approved)) {
142 const chosen = new Set(approved);
143 // Merges first, so a survivor takes its words before anything closes it.
144 const order = [...new Set(approved)].sort((a, b) => Number(proposed.get(b)!.kind === "merge") - Number(proposed.get(a)!.kind === "merge"));
145 for (const number of order) {
103146 const issue = getIssue(app.db, repo.id, number);
104147 const verdict = proposed.get(number)!;
105 if (issue === undefined || issue.state !== "open") continue;
148 if (issue === undefined || issue.state !== "open" || verdict.kind === "keep") continue;
149 if (verdict.kind === "merge") {
150 const survivor = getIssue(app.db, repo.id, verdict.into);
151 if (survivor === undefined || survivor.state !== "open") continue;
152 // A survivor closed in this same apply would take the words with it: the duplicate waits.
153 if (chosen.has(survivor.number) && proposed.get(survivor.number)!.kind !== "keep") continue;
outdated

Survivor may itself be ticked to close in this batch; its merged words are then lost on a closed issue. Skip/refuse that merge.

154 // Append, never replace; if the words do not fit, nothing is closed.
155 const merged = `${survivor.body}\n\n---\nMerged from #${issue.number}: ${issue.title}\n\n${issue.body}`;
156 if (!editIssue(app, actor, repo, survivor, { body: merged }).ok) continue;
157 if (!setIssueState(app, actor, repo, issue, "closed", { reason: "dropped", system: true }).ok) continue;
158 app.bus.append({
159 kind: "issue.gate_reviewed",
160 repoId: repo.id,
161 actorId: actor.id,
162 payload: { number: issue.number },
163 });
164 addComment(app, actor, repo, issue, `${REVIEW_NOTE}merged into #${survivor.number}: its words are appended there. Revive it from the held page to bring it back.`);
165 closed++;
166 continue;
167 }
106168 if (!setIssueState(app, actor, repo, issue, "closed", { reason: verdict.reason, system: true }).ok) continue;
107169 // The marker follows the close, never the other way: a failed close leaves none.
108170 app.bus.append({
src/core/gate_triage.ts +94 −14
@@ -1,27 +1,43 @@
11 /**
2 * A one-time look at what the Later gate holds (#8700), from titles alone:
3 * no model, no run, nothing stored. Unchosen ideas are dropped, not tracked
4 * (Shape Up): every held issue is a candidate to close, and only one the
5 * crew found done on main starts ticked; the rest are the person's to tick,
6 * and one that open work waits for is flagged, for closing it parks that
7 * work. Unticked is keep. A closed one stays findable and one tap revives
8 * it. The lot is grouped by theme, the word its titles share most. Nothing
9 * here changes an issue (`actions/gate_review.ts` applies the approval).
2 * A one-time look at what the Later gate holds (#8700, #8813), from titles
3 * and bodies: one pass of plain word counting, no model, no run, nothing
4 * stored. Unchosen ideas are dropped, not tracked (Shape Up), so close is
5 * the default. Two things change that, each named in the verdict:
6 * keep the issue joins a bet or a pillar's open work: the work serves a
7 * pillar, or names it as `#N` (or it names the work), so it stays
8 * behind the gate;
9 * merge another held issue says the same thing (the older survives):
10 * the duplicate's body is appended to it on apply, never replaced.
11 * A found-done issue closes first. Only done and merge start ticked (a
12 * merge loses no words); the rest are the person's to tick, and one that
13 * open work waits for is flagged, for closing it parks that work. A closed
14 * one stays findable and one tap revives it. The lot is grouped by theme,
15 * the word its titles share most. Nothing here changes an issue
16 * (`actions/gate_review.ts` applies the approval).
1017 */
1118
1219 export interface Held {
1320 readonly number: number;
1421 readonly title: string;
22 readonly body?: string;
1523 /** Open issues that wait for this one: closing it parks them. */
1624 readonly waiting: number;
1725 }
1826
19 export interface Verdict {
20 readonly kind: "close";
21 readonly why: string;
22 readonly reason: "done" | "dropped";
27 /** Open work outside the gate that held issues can join: a pillar's, or a bet. */
28 export interface Anchor {
29 readonly number: number;
30 /** `P0 Trust`, or `Bet`. */
31 readonly label: string;
32 readonly title: string;
33 readonly body: string;
2334 }
2435
36 export type Verdict =
37 | { readonly kind: "close"; readonly why: string; readonly reason: "done" | "dropped" }
38 | { readonly kind: "keep"; readonly why: string; readonly reason: "kept"; readonly anchor: number }
39 | { readonly kind: "merge"; readonly why: string; readonly reason: "merged"; readonly into: number };
40
2541 export interface Triaged {
2642 readonly number: number;
2743 readonly title: string;
@@ -48,7 +64,60 @@ export function words(text: string): string[] {
4864 }
4965
5066 /** Whether a candidate starts ticked: only one the crew found done on main. */
51 export const startsTicked = (t: Triaged): boolean => t.verdict.reason === "done";
67 export const startsTicked = (t: Triaged): boolean =>
68 t.verdict.reason === "done" || t.verdict.kind === "merge";
69
70 /** Words two issues share, over all they use: 1 is the same set. */
71 function overlap(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
72 let shared = 0;
73 for (const w of a) if (b.has(w)) shared++;
74 const union = a.size + b.size - shared;
75 return union === 0 ? 0 : shared / union;
76 }
77
78 /** Titles this alike, or title and body this alike, are one issue said twice. */
79 export const DUPLICATE_TITLE = 0.7;
80 export const DUPLICATE_TEXT = 0.6;
81 /** Fewer words than this say too little to call a duplicate. */
82 const DUPLICATE_WORDS = 3;
83
84 const mentions = (text: string, n: number): boolean =>
85 new RegExp(`(?<![\\w#])#${n}(?!\\w)`).test(text);
86
87 /** The held issue each duplicate folds into: the oldest of its group, by number. */
88 export function duplicates(held: readonly Held[]): Map<number, number> {
89 const title = new Map(held.map((h) => [h.number, new Set(words(h.title))]));
90 const text = new Map(held.map((h) => [h.number, new Set(words(`${h.title} ${h.body ?? ""}`))]));
91 const parent = new Map(held.map((h) => [h.number, h.number]));
92 const root = (n: number): number => (parent.get(n) === n ? n : root(parent.get(n)!));
93 const sorted = [...held].sort((a, b) => a.number - b.number);
94 for (let i = 0; i < sorted.length; i++)
95 for (let j = i + 1; j < sorted.length; j++) {
96 const a = sorted[i]!.number;
97 const b = sorted[j]!.number;
98 const ta = title.get(a)!;
99 const tb = title.get(b)!;
100 const xa = text.get(a)!;
101 const xb = text.get(b)!;
102 const alike =
103 (ta.size >= DUPLICATE_WORDS && tb.size >= DUPLICATE_WORDS && overlap(ta, tb) >= DUPLICATE_TITLE) ||
104 (xa.size >= 2 * DUPLICATE_WORDS && xb.size >= 2 * DUPLICATE_WORDS && overlap(xa, xb) >= DUPLICATE_TEXT);
105 if (!alike) continue;
106 const ra = root(a);
107 const rb = root(b);
108 if (ra !== rb) parent.set(Math.max(ra, rb), Math.min(ra, rb));
109 }
110 const out = new Map<number, number>();
111 for (const h of held) if (root(h.number) !== h.number) out.set(h.number, root(h.number));
112 return out;
113 }
114
115 /** The anchor a held issue joins: one that names it, or that it names. */
116 function anchorOf(h: Held, anchors: readonly Anchor[]): Anchor | undefined {
117 return anchors.find(
118 (a) => mentions(`${a.title}\n${a.body}`, h.number) || mentions(`${h.title}\n${h.body ?? ""}`, a.number),
119 );
120 }
52121
53122 /** A theme needs this many issues; a word on fewer is not a theme. */
54123 export const THEME_MIN = 3;
@@ -60,7 +129,10 @@ export const OTHER = "Other";
60129 export function triage(
61130 held: readonly Held[],
62131 done: ReadonlySet<number> = new Set(),
132 anchors: readonly Anchor[] = [],
133 serves: ReadonlyMap<number, string> = new Map(),
63134 ): Theme[] {
135 const folds = duplicates(held.filter((h) => !done.has(h.number)));
64136 const sets = new Map(held.map((h) => [h.number, new Set(words(h.title))]));
65137 // A theme is a word many titles share: each issue goes under the commonest
66138 // of its words that is common enough, ties to the shorter, then alphabetical.
@@ -74,9 +146,17 @@ export function triage(
74146 const groups = new Map<string, Triaged[]>(chosen.map((w) => [w, []]));
75147 const other: Triaged[] = [];
76148 for (const h of held) {
149 const joined = anchorOf(h, anchors);
150 const pillar = serves.get(h.number);
77151 const verdict: Verdict = done.has(h.number)
78152 ? { kind: "close", why: "the crew found it done on main", reason: "done" }
79 : { kind: "close", why: "not chosen: an idea that matters comes back", reason: "dropped" };
153 : pillar !== undefined
154 ? { kind: "keep", why: `serves ${pillar}: stays behind the gate`, reason: "kept", anchor: 0 }
155 : joined !== undefined
156 ? { kind: "keep", why: `joins ${joined.label} #${joined.number}: stays behind the gate`, reason: "kept", anchor: joined.number }
157 : folds.has(h.number)
158 ? { kind: "merge", why: `says the same as #${folds.get(h.number)}: merge into it, words appended`, reason: "merged", into: folds.get(h.number)! }
159 : { kind: "close", why: "not chosen: an idea that matters comes back", reason: "dropped" };
80160 const row = { number: h.number, title: h.title, waiting: h.waiting, verdict };
81161 const theme = chosen.find((w) => sets.get(h.number)!.has(w));
82162 if (theme === undefined) other.push(row);
src/tui/screens/held.ts +11 −8
@@ -7,7 +7,7 @@
77 * a change to what the gate holds resets the ticks, so what is approved is
88 * always what is shown.
99 *
10 * space tick or untick a candidate A close the ticked
10 * space tick or untick a candidate A apply the ticked (close or merge)
1111 * l lift (or revive, on a closed) enter open the issue
1212 */
1313
@@ -34,6 +34,8 @@ export interface HeldLine {
3434 readonly why: string;
3535 readonly waiting: number;
3636 readonly closed: boolean;
37 /** Proposed to stay behind the gate: nothing to tick. */
38 readonly keep: boolean;
3739 }
3840
3941 export interface HeldRead {
@@ -53,17 +55,18 @@ export function readHeld(f: Session, repo: Repo): HeldRead | undefined {
5355 why: i.verdict.why,
5456 waiting: i.waiting,
5557 closed: false,
58 keep: i.verdict.kind === "keep",
5659 })),
5760 );
5861 for (const i of reviewedClosed(f.db, repo, pillarsOf(f.db, repo)))
59 lines.push({ number: i.number, title: i.title, theme: "Closed by the review", why: "revive with l", waiting: 0, closed: true });
62 lines.push({ number: i.number, title: i.title, theme: "Closed by the review", why: "revive with l", waiting: 0, closed: true, keep: false });
6063 return { review, lines };
6164 }
6265
6366 /** One row per line; a candidate shows its tick, a flagged one says closing parks work. */
6467 export function heldRows(lines: readonly HeldLine[], ticked: ReadonlySet<number>): ListRow[] {
6568 return lines.map((l) => ({
66 title: `${l.closed ? " " : ticked.has(l.number) ? "[x]" : "[ ]"} #${l.number} ${l.title}`,
69 title: `${l.closed ? " " : l.keep ? "[-]" : ticked.has(l.number) ? "[x]" : "[ ]"} #${l.number} ${l.title}`,
6770 meta: metaOf(
6871 l.theme,
6972 l.why,
@@ -99,14 +102,14 @@ export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
99102 if (f.user === undefined) return void ctx.toast("Sign in first.", "danger");
100103 const user = f.user;
101104 const digest = read.review.digest;
102 const numbers = [...ticked];
105 const numbers = [...ticked].filter((n) => !lines().some((l) => l.number === n && l.keep));
103106 runAsync(ctx, async () => {
104107 const done = applyGateReview(f.app, user, repo, numbers, digest);
105 if (done.ok) ctx.toast(`Closed ${done.value}.`, "success");
108 if (done.ok) ctx.toast(`Closed or merged ${done.value}.`, "success");
106109 else
107110 ctx.toast(
108111 done.error === "stale"
109 ? "What the gate holds changed since you read it. Nothing was closed."
112 ? "What the gate holds changed since you read it. Nothing was changed."
110113 : done.error === "forbidden"
111114 ? THREAD_SAYS.planForbidden
112115 : "There is no Later gate here.",
@@ -121,7 +124,7 @@ export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
121124 };
122125 const toggle = (index: number): void => {
123126 const line = lines()[index];
124 if (line === undefined || line.closed) return;
127 if (line === undefined || line.closed || line.keep) return;
125128 if (!ticked.delete(line.number)) ticked.add(line.number);
126129 ctx.invalidate();
127130 };
@@ -130,7 +133,7 @@ export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
130133 keys: [
131134 binding(["j", "k"], "move"),
132135 binding(["space"], "tick"),
133 binding(["A"], "close the ticked"),
136 binding(["A"], "apply the ticked"),
134137 binding(["l"], "lift / revive"),
135138 binding(["enter"], "open"),
136139 binding(["esc", "q"], "close"),
src/web/pages/gate.ts +14 −12
@@ -80,10 +80,12 @@ export async function liftHoldSubmit(ctx: Ctx): Promise<Response> {
8080
8181 // ---- the list, and the one pass over it ----
8282
83 /** One row of the review: ticked to close, and Lift to let it go instead. */
83 /** One row of the review: ticked to close or merge, a keep with nothing to tick, and Lift to let it go instead. */
8484 function reviewRow(base: string, mayLift: boolean, item: Triaged): Html {
8585 return html`<li class="bar">
86 <input type="checkbox" form="apply" name="n" value="${item.number}" ${startsTicked(item) ? "checked" : ""} aria-label="Close #${item.number}" />
86 ${item.verdict.kind === "keep"
87 ? html`<span class="small">Keep</span>`
outdated

aria-label on a span is not announced reliably; drop it, the visible word 'Keep' suffices.

88 : html`<input type="checkbox" form="apply" name="n" value="${item.number}" ${startsTicked(item) ? "checked" : ""} aria-label="${item.verdict.kind === "merge" ? "Merge" : "Close"} #${item.number}" />`}
8789 <a href="${base}/issues/${item.number}">${item.title}</a>
8890 <span class="muted small">#${item.number} · ${item.verdict.why}</span>
8991 ${item.waiting > 0
@@ -115,6 +117,8 @@ export function heldPage(ctx: Ctx): Response {
115117 user !== undefined &&
116118 (canWrite(ctx.app.db, repo, user) || isCrew(ctx.app.db, repo.id, user.id));
117119 const mayApply = user !== undefined && user.kind !== "agent" && canWrite(ctx.app.db, repo, user);
120 const counts = { close: 0, keep: 0, merge: 0 };
121 for (const c of review.changes) counts[c.verdict.kind]++;
118122 const closed = reviewedClosed(ctx.app.db, repo, set);
119123 const body = html`${repoHeader(ctx, repo, "issues", { compact: true })}
120124 <div class="page-head mt">
@@ -126,18 +130,16 @@ export function heldPage(ctx: Ctx): Response {
126130 them until it is lifted.
127131 </p>
128132 </div>
129 <p>
130 Unchosen ideas are dropped, not tracked. Tick the ones to close; only
131 those the crew found done on main start ticked. An unticked issue
132 stays behind the gate, and Lift lets one go. An issue that open work
133 waits for says so: closing it parks that work. A closed issue stays
134 findable here and one tap revives it. Nothing changes until you
135 approve.
133 <p class="muted small">${counts.close} to close · ${counts.keep} to keep · ${counts.merge} to merge</p>
134 <p class="small">
135 Close is the default. A keep joins a bet or a pillar's work; a merge
136 folds a duplicate into the older issue, words appended. Nothing changes
137 until you approve.
136138 </p>
137139 ${mayApply && review.changes.length > 0
138140 ? html`<form id="apply" method="post" action="${base}/held/apply" class="bar">
139141 <input type="hidden" name="digest" value="${review.digest}" />
140 ${btn("Close the ticked", { type: "submit", variant: "primary" })}
142 ${btn("Apply the ticked", { type: "submit", variant: "primary" })}
141143 </form>`
142144 : null}
143145 <div class="stack mt">
@@ -195,10 +197,10 @@ export async function heldApply(ctx: Ctx): Promise<Response> {
195197 tone: "danger",
196198 message:
197199 done.error === "stale"
198 ? "What the gate holds changed since you read it. Nothing was closed; read it again."
200 ? "What the gate holds changed since you read it. Nothing was changed; read it again."
199201 : done.error === "forbidden"
200202 ? THREAD_SAYS.planForbidden
201203 : "There is no Later gate here.",
202204 });
203 return flashTo(ctx, dest, { message: `Closed ${done.value}.` });
205 return flashTo(ctx, dest, { message: `Closed or merged ${done.value}.` });
204206 }
test/later-gate.test.ts +64 −1
@@ -14,6 +14,7 @@ import { liftHold } from "../src/actions/gate.ts";
1414 import { addComment, openIssue, setIssueLabels, setIssueState } from "../src/actions/issues.ts";
1515 import { createLabel, createRepo } from "../src/actions/repos.ts";
1616 import { closedByReview, gateHold, gateHolding, reviewedClosed } from "../src/core/gate_hold.ts";
17 import { heldRows } from "../src/tui/screens/held.ts";
1718 import { startsTicked, triage } from "../src/core/gate_triage.ts";
1819 import { pillarsOf } from "../src/core/pillars.ts";
1920 import { createToken } from "../src/core/tokens.ts";
@@ -167,13 +168,75 @@ describe("the Later gate in plain sight", () => {
167168 expect(triage([]).length).toBe(0);
168169 });
169170
171 test("triage proposes merge for a duplicate pair and keep for what a bet names", () => {
172 const themes = triage(
173 [
174 { number: 10, title: "Export the archive as CSV", body: "Let people download rows.", waiting: 0 },
175 { number: 11, title: "Export archive as CSV file", body: "Download rows please.", waiting: 0 },
176 { number: 12, title: "Dark mode toggle", body: "", waiting: 0 },
177 { number: 13, title: "Paint the footer blue", body: "", waiting: 0 },
178 ],
179 new Set(),
180 [{ number: 90, label: "Bet", title: "Bet: theming", body: "Ships #12 this cycle." }],
181 );
182 const v = new Map(themes.flatMap((t) => t.items).map((r) => [r.number, r.verdict]));
183 expect(v.get(11)).toMatchObject({ kind: "merge", into: 10 });
184 expect(v.get(10)).toMatchObject({ kind: "close", reason: "dropped" });
185 expect(v.get(12)).toMatchObject({ kind: "keep", anchor: 90 });
186 expect(v.get(12)!.why).toContain("Bet #90");
187 expect(v.get(13)).toMatchObject({ kind: "close", reason: "dropped" });
188 });
189
190 test("a merge appends the duplicate's words, points at the survivor, and a changed gate refuses", () => {
191 const a = openIssue(app, alice, fresh(), { title: "Frobnicate the widget pipeline", body: "Original words about frobnicating." });
192 const b = openIssue(app, alice, fresh(), { title: "Frobnicate widget pipeline", body: "Extra words nobody may lose." });
193 if (!a.ok || !b.ok) throw new Error("open");
194 for (const i of [a.value, b.value]) expect(linkIssue(app, alice, fresh(), current(i), gate).ok).toBe(true);
195 const read = reviewGate(app, fresh())!;
196 expect(read.changes.find((c) => c.number === b.value.number)!.verdict).toMatchObject({ kind: "merge", into: a.value.number });
197 expect(read.ticked).toContain(b.value.number);
198 // The gate changes after reading: refused, nothing merged.
199 const late = held("Arrived after the read");
200 expect(applyGateReview(app, alice, fresh(), [b.value.number], read.digest)).toEqual({ ok: false, error: "stale" });
201 expect(current(b.value).state).toBe("open");
202 expect(current(late).state).toBe("open");
203 const again = reviewGate(app, fresh())!;
204 expect(applyGateReview(app, alice, fresh(), [b.value.number], again.digest)).toEqual({ ok: true, value: 1 });
205 const survivor = current(a.value);
206 expect(survivor.state).toBe("open");
207 expect(survivor.body).toContain("Original words about frobnicating.");
208 expect(survivor.body).toContain("Extra words nobody may lose.");
209 expect(survivor.body).toContain(`Merged from #${b.value.number}`);
210 expect(current(b.value).state).toBe("closed");
211 expect(app.db.query("SELECT 1 FROM comment WHERE issue_id = ?1 AND body LIKE ?2").get(b.value.id, `Gate review: merged into #${a.value.number}%`)).not.toBeNull();
212 });
213
214 test("a merge is skipped when its survivor closes in the same apply", () => {
215 const a = openIssue(app, alice, fresh(), { title: "Reticulate the spline index", body: "Survivor words." });
216 const b = openIssue(app, alice, fresh(), { title: "Reticulate spline index", body: "Duplicate words." });
217 if (!a.ok || !b.ok) throw new Error("open");
218 for (const i of [a.value, b.value]) expect(linkIssue(app, alice, fresh(), current(i), gate).ok).toBe(true);
219 const read = reviewGate(app, fresh())!;
220 expect(applyGateReview(app, alice, fresh(), [a.value.number, b.value.number], read.digest)).toEqual({ ok: true, value: 1 });
221 expect(current(a.value).state).toBe("closed");
222 expect(current(a.value).body).not.toContain("Duplicate words.");
223 expect(current(b.value).state).toBe("open");
224 });
225
226 test("the terminal draws a keep row with no tick", () => {
227 const line = { number: 5, title: "Kept", theme: "t", why: "joins Bet #1", waiting: 0, closed: false, keep: true };
228 const rows = heldRows([line, { ...line, number: 6, keep: false }], new Set([5, 6]));
229 expect(rows[0]!.title).toStartWith("[-]");
230 expect(rows[1]!.title).toStartWith("[x]");
231 });
232
170233 test("one approval closes what is ticked, it stays findable, and one tap revives it", async () => {
171234 const a = open("Idea nobody chose");
172235 const b = open("Idea to keep behind the gate");
173236 for (const i of [a, b]) linkIssue(app, alice, fresh(), current(i), gate);
174237 const review = reviewGate(app, fresh())!;
175238 expect(review.changes.map((c) => c.number)).toContain(a.number);
176 expect(await get("/alice/engine/held")).toContain("Close the ticked");
239 expect(await get("/alice/engine/held")).toContain("Apply the ticked");
177240 // A proposal that is not the one shown is refused whole.
178241 expect(applyGateReview(app, alice, fresh(), [999999], review.digest)).toEqual({ ok: false, error: "stale" });
179242 expect(current(a).state).toBe("open");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs