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

The Later gate is in plain sight: what it holds, who set it and when, and one action to lift any of it #8711

Merged opened this in answer to

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

Merged

mason/forge:mason/8700-the-later-gate-is-in-plain-sight-what-it → main 6 commits fork

  • Merged. mason/8700-the-later-gate-is-in-plain-sight-what-it was merged into main as 0d89a7e.

Review

Sign in to review this change.
25 files changed +1577 −3
docs/features/later-gate.md added +12
@@ -0,0 +1,12 @@
1 # The Later gate in plain sight (#8700)
2
3 The Later gate (`Later gate: …`, read by `core/pillars.ts`) holds work back from the crew. A person is told so where they look:
4
5 - **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`.
6 - **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.
7 - **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.
9 - **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.
11
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.
src/actions/gate.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * The Later gate, in plain sight (#8700): lifting what it holds, and keeping
3 * the `Later` label and the gate one thing. The gate is an edge (the issue
4 * waits for the gate issue) and the label is that edge made visible
5 * (`actions/pillars.ts` writes the label when the edge changes); this is the
6 * other direction, so a person who sets or removes the label by hand changes
7 * the edge too, and lifting by either road reads the same.
8 */
9
10 import type { App } from "../app.ts";
11 import { closedByReview, gateHolding, gateOf } from "../core/gate_hold.ts";
12 import type { Issue, Label } from "../core/issues.ts";
13 import { isStructural, LATER, pillarsOf } from "../core/pillars.ts";
14 import type { Repo } from "../core/repos.ts";
15 import type { User } from "../core/users.ts";
16 import { err, ok, type Result } from "../kernel/result.ts";
17 import { isMirror } from "../origins/write-through.ts";
18 import type { DependencyError } from "../core/dependencies.ts";
19 import type { AccessError } from "./access.ts";
20 import { linkIssue, unlinkIssue } from "./dependencies.ts";
21 import { setIssueState } from "./issues.ts";
22
23 /** Whether a label set carries the `Later` label. */
24 export const hasLater = (labels: readonly Pick<Label, "name">[]): boolean =>
25 labels.some((l) => l.name.toLowerCase() === LATER.toLowerCase());
26
27 /**
28 * Let the issue go from behind the gate: it waits for the gate no more, and
29 * its `Later` label goes with it. One the gate review closed is reopened as
30 * part of it: an idea that matters comes back with one tap. The hold is
31 * checked first, and a reopen that fails puts the hold back, so the answer
32 * is the whole outcome. One action, the same for a person and an agent;
33 * whoever may link may lift.
34 */
35 export function liftHold(
36 app: App,
37 actor: User | undefined,
38 repo: Repo,
39 issue: Issue,
40 ): Result<Issue, AccessError | "not_held"> {
41 const revive = closedByReview(app.db, issue);
42 const gate = revive ? gateOf(app.db, repo, issue) : gateHolding(app.db, repo, issue);
43 if (gate === undefined) return err("not_held");
44 const lifted = unlinkIssue(app, actor, repo, issue, gate);
45 if (!lifted.ok) return lifted;
46 if (!revive) return ok(issue);
47 const reopened = setIssueState(app, actor, repo, issue, "open");
48 if (!reopened.ok) {
49 linkIssue(app, actor, repo, issue, gate);
50 return err(reopened.error === "unauthorized" || reopened.error === "not_found" ? reopened.error : "forbidden");
51 }
52 return ok(reopened.value);
53 }
54
55 /**
56 * Labels were just replaced by hand. Giving an issue `Later` puts it behind
57 * the gate; taking `Later` off lifts it. Nothing happens where the
58 * repository has no gate, for the issue is the plan's own furniture, or for
59 * a mirror (its labels are its origin's). A link or unlink that fails is
60 * returned, so the caller never leaves the label and the gate apart.
61 */
62 export function followLabel(
63 app: App,
64 actor: User,
65 repo: Repo,
66 issue: Issue,
67 before: readonly Pick<Label, "name">[],
68 after: readonly Pick<Label, "name">[],
69 ): Result<void, AccessError | DependencyError> {
70 const was = hasLater(before);
71 const is = hasLater(after);
72 if (was === is || issue.kind !== "issue" || isMirror(app, repo)) return ok(undefined);
73 const set = pillarsOf(app.db, repo);
74 if (set.gate === undefined || isStructural(set, issue.id)) return ok(undefined);
75 const holding = gateHolding(app.db, repo, issue) !== undefined;
76 if (is && !holding) return linkIssue(app, actor, repo, issue, set.gate);
77 if (!is && holding) return unlinkIssue(app, actor, repo, issue, set.gate);
78 return ok(undefined);
79 }
src/actions/gate_review.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * The one-time pass over what the Later gate holds (#8700): the candidates
3 * (`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
6 * `issue.gate_reviewed` event only this path writes, which is how it is found
7 * again and revived (`liftHold`). The approval carries a digest of the
8 * candidates the person read; if the gate holds other issues now, the apply
9 * refuses. A person approves — an agent may read the proposal and never
10 * apply it.
11 */
12
13 import { createHash } from "node:crypto";
14 import type { App } from "../app.ts";
15 import { alreadyDone } from "../core/done_on_main.ts";
16 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";
20 import { canWrite, type Repo } from "../core/repos.ts";
21 import type { User } from "../core/users.ts";
22 import { err, ok, type Result } from "../kernel/result.ts";
23 import { addComment, setIssueState } from "./issues.ts";
24
25 export interface GateReview {
26 readonly themes: readonly Theme[];
27 /** Every held issue as a candidate to close. */
28 readonly changes: readonly Triaged[];
29 /** The numbers that start ticked: what the crew found done on main. */
30 readonly ticked: readonly number[];
31 /** Names the candidates shown; the apply must carry it back. */
32 readonly digest: string;
33 readonly held: number;
34 }
35
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(",");
39 return createHash("sha256").update(sorted).digest("hex").slice(0, 16);
40 }
41
42 /** How many open issues wait for each of these, in one read. */
43 function waitingOn(app: App, ids: readonly string[]): Map<string, number> {
44 const out = new Map<string, number>();
45 if (ids.length === 0) return out;
46 const rows = app.db
47 .query<{ blocked_by_id: string; n: number }, string[]>(
48 `SELECT d.blocked_by_id, COUNT(*) AS n FROM issue_dependency d
49 JOIN issue ON issue.id = d.issue_id
50 WHERE issue.state = 'open' AND d.blocked_by_id IN (${ids.map((_, i) => `?${i + 1}`).join(",")})
51 GROUP BY d.blocked_by_id`,
52 )
53 .all(...ids);
54 for (const r of rows) out.set(r.blocked_by_id, r.n);
55 return out;
56 }
57
58 /** What the gate holds, triaged by title: one read. */
59 export function reviewGate(app: App, repo: Repo): GateReview | undefined {
60 const set = pillarsOf(app.db, repo);
61 if (set.gate === undefined) return undefined;
62 const held = heldIssues(app.db, repo, set);
63 const ids = held.map((i) => i.id);
64 const done = alreadyDone(app.db, ids);
65 const waiting = waitingOn(app, ids);
66 const themes = triage(
67 held.map((i) => ({ number: i.number, title: i.title, waiting: waiting.get(i.id) ?? 0 })),
68 new Set(held.filter((i) => done.has(i.id)).map((i) => i.number)),
69 );
70 const changes = themes.flatMap((t) => t.items);
71 return {
72 themes,
73 changes,
74 ticked: changes.filter(startsTicked).map((c) => c.number),
75 digest: reviewDigest(changes.map((c) => c.number)),
76 held: held.length,
77 };
78 }
79
80 export type ApplyError = "not_found" | "forbidden" | "stale";
81
82 /**
83 * Apply the approval the person gave: `approved` are the issue numbers they
84 * ticked and `digest` names the candidates they read. If the gate holds other
85 * 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.
87 */
88 export function applyGateReview(
89 app: App,
90 actor: User,
91 repo: Repo,
92 approved: readonly number[],
93 digest: string | undefined,
94 ): Result<number, ApplyError> {
95 if (actor.kind === "agent" || !canWrite(app.db, repo, actor)) return err("forbidden");
96 const review = reviewGate(app, repo);
97 if (review === undefined) return err("not_found");
98 if (digest !== review.digest) return err("stale");
99 const proposed = new Map(review.changes.map((c) => [c.number, c.verdict]));
100 if (!approved.every((n) => proposed.has(n))) return err("stale");
101 let closed = 0;
102 for (const number of new Set(approved)) {
103 const issue = getIssue(app.db, repo.id, number);
104 const verdict = proposed.get(number)!;
105 if (issue === undefined || issue.state !== "open") continue;
106 if (!setIssueState(app, actor, repo, issue, "closed", { reason: verdict.reason }).ok) continue;
107 // The marker follows the close, never the other way: a failed close leaves none.
108 app.bus.append({
109 kind: "issue.gate_reviewed",
110 repoId: repo.id,
111 actorId: actor.id,
112 payload: { number: issue.number },
113 });
114 addComment(app, actor, repo, issue, `${REVIEW_NOTE}closed: ${verdict.why}. Revive it from the held page to bring it back.`);
115 closed++;
116 }
117 return ok(closed);
118 }
src/actions/issues.ts +9
@@ -68,6 +68,7 @@ import {
6868 } from "../origins/import.ts";
6969 import { err, ok, type Result } from "../kernel/result.ts";
7070 import { clearFirst } from "../core/first.ts";
71 import { followLabel } from "./gate.ts";
7172 import { inheritPillar } from "./pillars.ts";
7273 import {
7374 requireParticipant,
@@ -705,6 +706,7 @@ export function setIssueLabels(
705706 if (!label) return err("unknown_label");
706707 if (!resolved.some((l) => l.id === label.id)) resolved.push(label);
707708 }
709 const before = labelsFor(app.db, issue.id);
708710 writeLabels(
709711 app.db,
710712 issue.id,
@@ -716,6 +718,13 @@ export function setIssueLabels(
716718 actorId: writer.value.id,
717719 payload: { number: issue.number, labels: resolved.map((l) => l.name) },
718720 });
721 // `Later` is the Later gate made visible: changing the label changes the gate.
722 const followed = followLabel(app, writer.value, repo, issue, before, resolved);
723 if (!followed.ok) {
724 // The label and the gate move together or not at all.
725 writeLabels(app.db, issue.id, before.map((l) => l.id));
726 return err("forbidden");
727 }
719728 return ok(labelsFor(app.db, issue.id));
720729 }
721730
src/core/event_audience.ts +1
@@ -38,6 +38,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
3838 "issue.labeled",
3939 "issue.first",
4040 "issue.closed",
41 "issue.gate_reviewed",
4142 "issue.commented",
4243 "usage.delta",
4344 "issue.raised",
src/core/gate_hold.ts added +159
@@ -0,0 +1,159 @@
1 /**
2 * What the Later gate holds (#8700): the open issues that wait behind it, who
3 * set the gate and when, read from the same edges `core/pillars.ts` reads.
4 * Nothing is stored. The gate is the plan's own issue, so "who set it" is its
5 * author and "when" is the day it was opened.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { getIssue, type Issue } from "./issues.ts";
10 import { isStructural, pillarsOf, type Pillars } from "./pillars.ts";
11 import type { Repo } from "./repos.ts";
12
13 export interface GateHold {
14 readonly gate: Issue;
15 /** Open issues waiting behind the gate, not counting the plan's furniture. */
16 readonly held: number;
17 /** Who opened the gate. */
18 readonly by: string;
19 /** When it was opened, an ISO instant. */
20 readonly since: string;
21 }
22
23 /** The open issues waiting behind the gate, newest first, the plan's furniture left out. */
24 export function heldIssues(
25 db: Database,
26 repo: Repo,
27 set: Pillars,
28 limit = 500,
29 ): Issue[] {
30 if (set.gate === undefined) return [];
31 const rows = db
32 .query<{ number: number }, [string, string, number]>(
33 `SELECT issue.number FROM issue_dependency d
34 JOIN issue ON issue.id = d.issue_id
35 WHERE d.blocked_by_id = ?1 AND issue.repo_id = ?2
36 AND issue.kind = 'issue' AND issue.state = 'open' AND issue.sealed = 0
37 ORDER BY issue.number DESC LIMIT ?3`,
38 )
39 .all(set.gate.id, repo.id, limit + 8);
40 const out: Issue[] = [];
41 for (const row of rows) {
42 const issue = getIssue(db, repo.id, row.number);
43 if (issue !== undefined && !isStructural(set, issue.id)) out.push(issue);
44 if (out.length === limit) break;
45 }
46 return out;
47 }
48
49 /** How many open issues wait behind the gate: one read. */
50 export function countHeld(db: Database, repo: Repo, set: Pillars): number {
51 if (set.gate === undefined) return 0;
52 const structural = [
53 ...set.pillars.map((p) => p.umbrella.id),
54 ...(set.later === undefined ? [] : [set.later.id]),
55 ];
56 const row = db
57 .query<{ n: number }, string[]>(
58 `SELECT COUNT(*) AS n FROM issue_dependency d
59 JOIN issue ON issue.id = d.issue_id
60 WHERE d.blocked_by_id = ?1 AND issue.repo_id = ?2
61 AND issue.kind = 'issue' AND issue.state = 'open' AND issue.sealed = 0
62 ${structural.length === 0 ? "" : `AND issue.id NOT IN (${structural.map((_, i) => `?${i + 3}`).join(",")})`}`,
63 )
64 .get(set.gate.id, repo.id, ...structural);
65 return row?.n ?? 0;
66 }
67
68 /** The repository's gate hold, or nothing where there is no gate or it holds nothing. */
69 export function gateHold(db: Database, repo: Repo): GateHold | undefined {
70 const set = pillarsOf(db, repo);
71 if (set.gate === undefined) return undefined;
72 const held = countHeld(db, repo, set);
73 if (held === 0) return undefined;
74 return {
75 gate: set.gate,
76 held,
77 by: set.gate.authorName,
78 since: set.gate.createdAt,
79 };
80 }
81
82 /** The gate this issue waits behind, open or closed, if it does. */
83 export function gateOf(
84 db: Database,
85 repo: Repo,
86 issue: Issue,
87 ): Issue | undefined {
88 if (issue.kind !== "issue") return undefined;
89 const set = pillarsOf(db, repo);
90 if (set.gate === undefined || isStructural(set, issue.id)) return undefined;
91 const row = db
92 .query<{ one: number }, [string, string]>(
93 "SELECT 1 AS one FROM issue_dependency WHERE issue_id = ?1 AND blocked_by_id = ?2",
94 )
95 .get(issue.id, set.gate.id);
96 return row === null ? undefined : set.gate;
97 }
98
99 /** The gate this open issue waits behind, if it does. */
100 export function gateHolding(
101 db: Database,
102 repo: Repo,
103 issue: Issue,
104 ): Issue | undefined {
105 return issue.state === "open" ? gateOf(db, repo, issue) : undefined;
106 }
107
108 /** The note a gate review leaves on an issue it closed (`actions/gate_review.ts`). */
109 export const REVIEW_NOTE = "Gate review: ";
110
111 /**
112 * The event only the review's apply path writes, right after the close. An
113 * issue is "closed by the review" while it is the latest of this, a close and
114 * a reopen on that issue: a comment cannot forge it, and a reopen followed by
115 * a manual close does not read as the review's.
116 */
117 export const GATE_REVIEWED = "issue.gate_reviewed"; // also named in `actions/gate_review.ts` and `core/event_audience.ts`
118
119 /** The newest event of a kind on `issue` (a row of `issue`), by rowid; each reads the event index by equality. */
120 const newest = (kind: string): string =>
121 `COALESCE((SELECT MAX(e.rowid) FROM event e
122 WHERE e.repo_id = issue.repo_id AND e.kind = '${kind}'
123 AND json_extract(e.payload, '$.number') = issue.number), 0)`;
124
125 /** The SQL that says whether the review closed `issue`: its event is newer than the issue's last close and reopen. */
126 const BY_REVIEW = `(${newest(GATE_REVIEWED)} > ${newest("issue.closed")} AND ${newest(GATE_REVIEWED)} > ${newest("issue.reopened")})`;
127
128 /** Whether the gate review closed this issue, so it can be found and revived. */
129 export function closedByReview(db: Database, issue: Issue): boolean {
130 if (issue.state !== "closed") return false;
131 return (
132 db
133 .query<{ one: number }, [string]>(
134 `SELECT 1 AS one FROM issue WHERE id = ?1 AND ${BY_REVIEW}`,
135 )
136 .get(issue.id) !== null
137 );
138 }
139
140 /** The issues the gate review closed that are still behind the gate, newest first. */
141 export function reviewedClosed(
142 db: Database,
143 repo: Repo,
144 set: Pillars,
145 limit = 300,
146 ): Issue[] {
147 if (set.gate === undefined) return [];
148 const rows = db
149 .query<{ number: number }, [string, string, number]>(
150 `SELECT issue.number FROM issue_dependency d
151 JOIN issue ON issue.id = d.issue_id
152 WHERE d.blocked_by_id = ?1 AND issue.repo_id = ?2
153 AND issue.kind = 'issue' AND issue.state = 'closed' AND issue.sealed = 0
154 AND ${BY_REVIEW}
155 ORDER BY issue.number DESC LIMIT ?3`,
156 )
157 .all(set.gate.id, repo.id, limit);
158 return rows.flatMap((r) => getIssue(db, repo.id, r.number) ?? []);
159 }
src/core/gate_triage.ts added +89
@@ -0,0 +1,89 @@
1 /**
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).
10 */
11
12 export interface Held {
13 readonly number: number;
14 readonly title: string;
15 /** Open issues that wait for this one: closing it parks them. */
16 readonly waiting: number;
17 }
18
19 export interface Verdict {
20 readonly kind: "close";
21 readonly why: string;
22 readonly reason: "done" | "dropped";
23 }
24
25 export interface Triaged {
26 readonly number: number;
27 readonly title: string;
28 readonly waiting: number;
29 readonly verdict: Verdict;
30 }
31
32 export interface Theme {
33 readonly name: string;
34 readonly items: readonly Triaged[];
35 }
36
37 const STOP = new Set(
38 "a an and are as at be by for from has have how in is it its of on or that the this to was we with when what who which not no can do does your you our new add make use used using into out up per via issue issues".split(
39 " ",
40 ),
41 );
42
43 /** Lowercase words of three letters or more, the stop words left out. */
44 export function words(text: string): string[] {
45 return (text.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []).filter(
46 (w) => !STOP.has(w),
47 );
48 }
49
50 /** Whether a candidate starts ticked: only one the crew found done on main. */
51 export const startsTicked = (t: Triaged): boolean => t.verdict.reason === "done";
52
53 /** A theme needs this many issues; a word on fewer is not a theme. */
54 export const THEME_MIN = 3;
55 /** Themes shown; the rest go under `OTHER`. */
56 export const THEMES_MAX = 8;
57 export const OTHER = "Other";
58
59 /** The candidate close for each held issue, under the theme its title belongs to. */
60 export function triage(
61 held: readonly Held[],
62 done: ReadonlySet<number> = new Set(),
63 ): Theme[] {
64 const sets = new Map(held.map((h) => [h.number, new Set(words(h.title))]));
65 // A theme is a word many titles share: each issue goes under the commonest
66 // of its words that is common enough, ties to the shorter, then alphabetical.
67 const freq = new Map<string, number>();
68 for (const s of sets.values()) for (const w of s) freq.set(w, (freq.get(w) ?? 0) + 1);
69 const chosen = [...freq]
70 .filter(([, n]) => n >= THEME_MIN)
71 .sort((a, b) => b[1] - a[1] || a[0].length - b[0].length || a[0].localeCompare(b[0]))
72 .slice(0, THEMES_MAX)
73 .map(([w]) => w);
74 const groups = new Map<string, Triaged[]>(chosen.map((w) => [w, []]));
75 const other: Triaged[] = [];
76 for (const h of held) {
77 const verdict: Verdict = done.has(h.number)
78 ? { 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" };
80 const row = { number: h.number, title: h.title, waiting: h.waiting, verdict };
81 const theme = chosen.find((w) => sets.get(h.number)!.has(w));
82 if (theme === undefined) other.push(row);
83 else groups.get(theme)!.push(row);
84 }
85 const out = [...groups]
86 .filter(([, items]) => items.length > 0)
87 .map(([name, items]) => ({ name, items }));
88 return other.length === 0 ? out : [...out, { name: OTHER, items: other }];
89 }
src/crew/conversation_confirm.ts +6
@@ -57,6 +57,12 @@ export const ASKING: Readonly<Record<string, Asking>> = {
5757 model_grant: { does: "give an account a monthly model grant", undo: () => "`model_grant` with `usdPerMonth: 0` takes it back" },
5858 model_ceiling: { does: "cap an account's model spend", undo: () => "`model_ceiling` with both `null` clears it" },
5959 limits: { does: "change one of the forge's limits", undo: () => "`limits` with `value: \"\"` restores the default" },
60 // Closing the Later gate's unchosen ideas in one go; reading its proposal asks nothing.
61 held: {
62 does: "close the issues the Later gate review proposed to close",
63 asks: (a) => a["apply"] !== undefined,
64 undo: () => "`lift` on each issue revives it",
65 },
6066 // Secrets and credentials.
6167 secrets: { does: "set or remove a sealed secret", asks: not("action", "list") },
6268 app_env: { does: "set or unset an app's variable", asks: not("action", "list") },
src/http/mcp/tools/held.ts added +50
@@ -0,0 +1,50 @@
1 import { applyGateReview, reviewGate } from "../../../actions/gate_review.ts";
2 import { THREAD_SAYS } from "../../../actions/thread_words.ts";
3 import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "held",
7 doc: "`{ app, apply? }`: what the Later gate holds, grouped by theme, with each as a candidate to close, from titles alone; `apply: [numbers], digest` (a person's token; the numbers to close, and the `digest` the read gave) does it, refused if what the gate holds has moved; only `done` ones are in `approve`; `lift` revives a closed one",
8 description: "What the Later gate holds, and one proposed pass over it.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 app: APP_ARG,
13 apply: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 1000 },
14 digest: { type: "string", maxLength: 64 },
15 },
16 required: ["app"],
17 },
18 writes: true,
19 run(ctx, user, args) {
20 const repo = planRepo(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const apply = args["apply"];
23 if (apply !== undefined) {
24 if (!Array.isArray(apply) || !apply.every((n) => Number.isInteger(n) && n > 0))
25 return bad("invalid_apply", "apply is the list of issue numbers the proposal names");
26 const done = applyGateReview(ctx.app, user, repo, apply as number[], typeof args["digest"] === "string" ? args["digest"] : undefined);
27 if (!done.ok)
28 return bad(
29 done.error,
30 done.error === "stale"
31 ? "what the gate holds changed since you read it; read it again"
32 : done.error === "forbidden"
33 ? THREAD_SAYS.planForbidden
34 : "there is no Later gate here",
35 );
36 return good({ says: `Closed ${done.value}.` });
37 }
38 const review = reviewGate(ctx.app, repo);
39 if (review === undefined) return bad("not_found", "there is no Later gate here");
40 return good({
41 says: `${review.held} held; ${review.ticked.length} found done on main.`,
42 themes: review.themes.map((t) => ({
43 theme: t.name,
44 issues: t.items.map((i) => ({ number: i.number, title: i.title, waiting: i.waiting, ...i.verdict })),
45 })),
46 approve: review.ticked,
47 digest: review.digest,
48 });
49 },
50 };
src/http/mcp/tools/index.ts +2
@@ -65,6 +65,7 @@ export { tool as forges } from "./forges.ts";
6565 export { tool as forks } from "./forks.ts";
6666 export { tool as front_page } from "./front_page.ts";
6767 export { tool as health } from "./health.ts";
68 export { tool as held } from "./held.ts";
6869 export { tool as here } from "./here.ts";
6970 export { tool as hold } from "./hold.ts";
7071 export { tool as hooks } from "./hooks.ts";
@@ -74,6 +75,7 @@ export { tool as issue_teams } from "./issue_teams.ts";
7475 export { tool as issues } from "./issues.ts";
7576 export { tool as labels } from "./labels.ts";
7677 export { tool as land_past_gate } from "./land_past_gate.ts";
78 export { tool as lift } from "./lift.ts";
7779 export { tool as limb } from "./limb.ts";
7880 export { tool as limits } from "./limits.ts";
7981 export { tool as make_it_yours } from "./make_it_yours.ts";
src/http/mcp/tools/lift.ts added +31
@@ -0,0 +1,31 @@
1 import { liftHold } from "../../../actions/gate.ts";
2 import { THREAD_SAYS } from "../../../actions/thread_words.ts";
3 import { good, bad, type Tool, APP_ARG, planRepo, issueArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "lift",
7 doc: "`{ app, issue }`: let an issue out from behind the Later gate, as the page's Lift does; its `Later` label goes with it, and the answer carries the issue's URL",
8 description: "Lift an issue out from behind the Later gate.",
9 inputSchema: {
10 type: "object",
11 properties: { app: APP_ARG, issue: { type: "integer", minimum: 1 } },
12 required: ["app", "issue"],
13 },
14 writes: true,
15 run(ctx, user, args) {
16 const repo = planRepo(ctx, user, args);
17 if (isAnswer(repo)) return repo;
18 const issue = issueArg(ctx, repo, args, "issue");
19 if ("ok" in issue) return issue;
20 const lifted = liftHold(ctx.app, user, repo, issue);
21 if (!lifted.ok)
22 return bad(
23 lifted.error,
24 lifted.error === "not_held" ? `#${issue.number} is not behind the Later gate.` : THREAD_SAYS.planForbidden,
25 );
26 return good({
27 says: `#${issue.number} is lifted from behind the Later gate.`,
28 url: `${ctx.url.origin}/${repo.ownerName}/${repo.name}/issues/${issue.number}`,
29 });
30 },
31 };
src/http/server.ts +4
@@ -266,6 +266,7 @@ import { appNowFragment } from "../web/studio/now.ts";
266266 import { appStateFragment } from "../web/studio/previews.ts";
267267 import { changesList, tellsList } from "../web/studio/lists.ts";
268268 import { dependencySubmit } from "../web/pages/thread.ts";
269 import { heldApply, heldPage, liftHoldSubmit } from "../web/pages/gate.ts";
269270 import {
270271 assigneeSubmit,
271272 labelsSubmit,
@@ -1020,6 +1021,9 @@ export function buildRouter(): Router<Handler> {
10201021 "/:owner/:name/issues/:number/dependencies",
10211022 dependencySubmit,
10221023 );
1024 router.add("POST", "/:owner/:name/issues/:number/lift", liftHoldSubmit);
1025 router.add("GET", "/:owner/:name/held", heldPage);
1026 router.add("POST", "/:owner/:name/held/apply", heldApply);
10231027 router.add("GET", "/:owner/:name/map", mapPage);
10241028 router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit);
10251029 router.add("GET", "/:owner/:name/milestones", milestonesPage);
src/tui/forge.ts +7
@@ -73,6 +73,7 @@ import { keeperScreen } from "./screens/keeper.ts";
7373 import { releasesScreen } from "./screens/releases.ts";
7474 import { mayRead } from "../release/gate.ts";
7575 import { fileHoldsPrompt } from "./screens/fileholds.ts";
76 import { heldScreen } from "./screens/held.ts";
7677 import { stalledScreen } from "./screens/stalled.ts";
7778 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
7879 import { limbsScreen } from "./screens/limbs.ts";
@@ -109,6 +110,12 @@ const REPO_VERBS: readonly {
109110 title: "Issues in a repository",
110111 open: (f, ctx, repo) => issuesScreen(f, ctx, local(f, repo), "issue"),
111112 },
113 {
114 // What the Later gate holds, as /held shows it: reading is the repository's own.
115 id: "held",
116 title: "Held by the Later gate",
117 open: (f, ctx, repo) => heldScreen(f, ctx, repo),
118 },
112119 {
113120 id: "pulls",
114121 title: "Pull requests in a repository",
src/tui/screens/held.ts added +161
@@ -0,0 +1,161 @@
1 /**
2 * What the Later gate holds, from the terminal (#8700): the held page's twin.
3 * The same reads (`reviewGate`, `reviewedClosed`) and the same actions
4 * (`liftHold`, `applyGateReview`) the page and the MCP tools use, so who may
5 * see and who may change is theirs: a repository the viewer cannot read is
6 * not found, and only a writer who is not an agent applies. The list is live;
7 * a change to what the gate holds resets the ticks, so what is approved is
8 * always what is shown.
9 *
10 * space tick or untick a candidate A close the ticked
11 * l lift (or revive, on a closed) enter open the issue
12 */
13
14 import { liftHold } from "../../actions/gate.ts";
15 import { applyGateReview, reviewGate, type GateReview } from "../../actions/gate_review.ts";
16 import { THREAD_SAYS } from "../../actions/thread_words.ts";
17 import { reviewedClosed } from "../../core/gate_hold.ts";
18 import { getIssue } from "../../core/issues.ts";
19 import { pillarsOf } from "../../core/pillars.ts";
20 import type { Repo } from "../../core/repos.ts";
21 import type { Ctx, Screen } from "../app/app.ts";
22 import { binding } from "../app/keys.ts";
23 import type { Session } from "../session.ts";
24 import { ListState, drawList, hitList, live, metaOf, runAsync, type ListRow } from "./common.ts";
25 import { issueScreen } from "./issue.ts";
26 import { local } from "./where.ts";
27 import { emptyState } from "../ui/widgets.ts";
28
29 /** One line of the screen: a candidate, or an issue the review closed. */
30 export interface HeldLine {
31 readonly number: number;
32 readonly title: string;
33 readonly theme: string;
34 readonly why: string;
35 readonly waiting: number;
36 readonly closed: boolean;
37 }
38
39 export interface HeldRead {
40 readonly review: GateReview;
41 readonly lines: readonly HeldLine[];
42 }
43
44 /** The gate's candidates and what it closed, or nothing where the repository has no gate. */
45 export function readHeld(f: Session, repo: Repo): HeldRead | undefined {
46 const review = reviewGate(f.app, repo);
47 if (review === undefined) return undefined;
48 const lines: HeldLine[] = review.themes.flatMap((t) =>
49 t.items.map((i) => ({
50 number: i.number,
51 title: i.title,
52 theme: t.name,
53 why: i.verdict.why,
54 waiting: i.waiting,
55 closed: false,
56 })),
57 );
58 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 });
60 return { review, lines };
61 }
62
63 /** One row per line; a candidate shows its tick, a flagged one says closing parks work. */
64 export function heldRows(lines: readonly HeldLine[], ticked: ReadonlySet<number>): ListRow[] {
65 return lines.map((l) => ({
66 title: `${l.closed ? " " : ticked.has(l.number) ? "[x]" : "[ ]"} #${l.number} ${l.title}`,
67 meta: metaOf(
68 l.theme,
69 l.why,
70 l.waiting > 0 ? `${l.waiting} open waits for it: closing parks it` : "",
71 ),
72 }));
73 }
74
75 export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
76 const state = new ListState();
77 let read: HeldRead | undefined;
78 let ticked = new Set<number>();
79 const lines = (): readonly HeldLine[] => read?.lines ?? [];
80 const reload = (): void => {
81 const next = readHeld(f, repo);
82 if (next?.review.digest !== read?.review.digest) ticked = new Set(next?.review.ticked ?? []);
83 read = next;
84 state.clamp(lines().length);
85 ctx.invalidate();
86 };
87 const lift = (index: number): void => {
88 const line = lines()[index];
89 if (line === undefined) return;
90 const issue = getIssue(f.db, repo.id, line.number);
91 if (issue === undefined) return;
92 const done = liftHold(f.app, f.user, repo, issue);
93 if (done.ok) ctx.toast(`#${line.number} is lifted from behind the Later gate.`, "success");
94 else ctx.toast(done.error === "not_held" ? `#${line.number} is not behind the Later gate.` : THREAD_SAYS.planForbidden, "danger");
95 reload();
96 };
97 const apply = (): void => {
98 if (read === undefined) return;
99 if (f.user === undefined) return void ctx.toast("Sign in first.", "danger");
100 const user = f.user;
101 const digest = read.review.digest;
102 const numbers = [...ticked];
103 runAsync(ctx, async () => {
104 const done = applyGateReview(f.app, user, repo, numbers, digest);
105 if (done.ok) ctx.toast(`Closed ${done.value}.`, "success");
106 else
107 ctx.toast(
108 done.error === "stale"
109 ? "What the gate holds changed since you read it. Nothing was closed."
110 : done.error === "forbidden"
111 ? THREAD_SAYS.planForbidden
112 : "There is no Later gate here.",
113 "danger",
114 );
115 reload();
116 });
117 };
118 const openAt = (index: number): void => {
119 const line = lines()[index];
120 if (line !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), line.number));
121 };
122 const toggle = (index: number): void => {
123 const line = lines()[index];
124 if (line === undefined || line.closed) return;
125 if (!ticked.delete(line.number)) ticked.add(line.number);
126 ctx.invalidate();
127 };
128 return {
129 title: `Held · ${repo.ownerName}/${repo.name}`,
130 keys: [
131 binding(["j", "k"], "move"),
132 binding(["space"], "tick"),
133 binding(["A"], "close the ticked"),
134 binding(["l"], "lift / revive"),
135 binding(["enter"], "open"),
136 binding(["esc", "q"], "close"),
137 ],
138 load(signal) {
139 live(f, signal, { repoId: repo.id }, () => runAsync(ctx, async () => reload()));
140 reload();
141 return Promise.resolve();
142 },
143 render(area, s) {
144 if (read === undefined) {
145 emptyState(s, area, ctx.theme.c, { title: "No Later gate", body: "This repository holds nothing back." });
146 return;
147 }
148 drawList(s, area, ctx.theme.c, state, heldRows(lines(), ticked));
149 hitList(ctx, area, state, lines().length, { open: openAt });
150 },
151 key(e) {
152 if (e.key === "esc" || e.key === "q") ctx.pop();
153 else if (e.key === " " || e.key === "space") toggle(state.index);
154 else if (e.key === "A") apply();
155 else if (e.key === "l") lift(state.index);
156 else if (e.key === "enter") openAt(state.index);
157 else state.key(e, lines().length);
158 return true;
159 },
160 };
161 }
src/tui/screens/issues.ts +6 −1
@@ -44,6 +44,7 @@ import {
4444 } from "./common.ts";
4545 import { issueScreen } from "./issue.ts";
4646 import { newPullScreen } from "./newpull.ts";
47 import { heldScreen } from "./held.ts";
4748 import { planScreen } from "./plan.ts";
4849 import { pullScreen } from "./pull.ts";
4950 import { verdictsForPulls, type Verdict } from "./verdicts.ts";
@@ -272,7 +273,7 @@ export function issuesScreen(
272273 ...(stops() > 0 ? [binding(["P"], "pillar")] : []),
273274 binding(["a"], mine ? "all authors" : "only mine"),
274275 ...(mayWrite() ? [binding(["c"], `new ${noun}`)] : []),
275 ...(kind === "issue" && repo !== undefined ? [binding(["m"], "plan")] : []),
276 ...(kind === "issue" && repo !== undefined ? [binding(["m"], "plan"), binding(["h"], "held")] : []),
276277 binding(["/"], "search"),
277278 binding(["r"], "refresh"),
278279 ];
@@ -426,6 +427,10 @@ export function issuesScreen(
426427 ctx.push(planScreen(f, ctx, repo));
427428 return true;
428429 }
430 if (e.key === "h" && kind === "issue" && repo !== undefined) {
431 ctx.push(heldScreen(f, ctx, repo));
432 return true;
433 }
429434 if (e.key === "enter") {
430435 openAt(state.index);
431436 return true;
src/web/pages/gate.ts added +204
@@ -0,0 +1,204 @@
1 /**
2 * The Later gate in plain sight (#8700): the banner at the top of a held
3 * issue, with Lift, and the route behind it. Reading is `core/gate_hold.ts`,
4 * acting is `actions/gate.ts`.
5 */
6
7 import { applyGateReview, reviewGate } from "../../actions/gate_review.ts";
8 import { liftHold } from "../../actions/gate.ts";
9 import { startsTicked, type Triaged } from "../../core/gate_triage.ts";
10 import { pillarsOf } from "../../core/pillars.ts";
11 import { THREAD_SAYS } from "../../actions/thread_words.ts";
12 import { isCrew } from "../../core/agents.ts";
13 import { closedByReview, gateHolding, reviewedClosed } from "../../core/gate_hold.ts";
14 import type { Issue } from "../../core/issues.ts";
15 import { canWrite, type Repo } from "../../core/repos.ts";
16 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
19 import { readForm } from "../form.ts";
20 import { html, type Html } from "../html.ts";
21 import { flashTo, page } from "../layout.ts";
22 import { btn, disclosure, time } from "../ui.ts";
23 import { threadContext } from "./thread.ts";
24
25 /** What the banner says about the hold, and the one button that ends it. */
26 export function heldBanner(ctx: Ctx, repo: Repo, issue: Issue): Html | null {
27 const user = ctx.auth.user;
28 const base0 = repoBase(repo);
29 if (closedByReview(ctx.app.db, issue))
30 return html`<div class="bar" id="held" role="note">
31 <span>Closed by the <a href="${base0}/held">Later gate review</a>: not chosen. An idea that matters comes back.</span>
32 ${user !== undefined && canWrite(ctx.app.db, repo, user)
33 ? html`<form method="post" action="${base0}/issues/${issue.number}/lift" class="inline">
34 ${btn("Revive", { type: "submit", variant: "primary", size: "sm" })}
35 </form>`
36 : null}
37 </div>`;
38 const gate = gateHolding(ctx.app.db, repo, issue);
39 if (gate === undefined) return null;
40 const mayLift =
41 user !== undefined &&
42 (canWrite(ctx.app.db, repo, user) || isCrew(ctx.app.db, repo.id, user.id));
43 const base = repoBase(repo);
44 return html`<div class="bar" id="held" role="note">
45 <span
46 >Held by the <a href="${base}/held">Later gate</a>, set
47 by ${gate.authorName} ${time(gate.createdAt)}. The crew picks nothing
48 here until it is lifted.</span
49 >
50 ${mayLift
51 ? html`<form method="post" action="${base}/issues/${issue.number}/lift" class="inline">
52 ${btn("Lift", { type: "submit", variant: "primary", size: "sm" })}
53 </form>`
54 : null}
55 </div>`;
56 }
57
58 // POST /:owner/:name/issues/:number/lift
59 export async function liftHoldSubmit(ctx: Ctx): Promise<Response> {
60 const found = threadContext(ctx, "issue");
61 if (found instanceof Response) return found;
62 if (!ctx.auth.user) return signInFirst(ctx);
63 const form = await readForm(ctx, "rw");
64 if (form instanceof Response) return form;
65 const repo = loadRepo(ctx) ?? found.repo;
66 const dest = `${repoBase(repo)}/issues/${found.issue.number}`;
67 const lifted = liftHold(ctx.app, ctx.auth.user, repo, found.issue);
68 if (!lifted.ok)
69 return flashTo(ctx, dest, {
70 tone: "danger",
71 message:
72 lifted.error === "not_held"
73 ? `#${found.issue.number} is not behind the Later gate.`
74 : THREAD_SAYS.planForbidden,
75 });
76 return flashTo(ctx, dest, {
77 message: `#${found.issue.number} is lifted from behind the Later gate.`,
78 });
79 }
80
81 // ---- the list, and the one pass over it ----
82
83 /** One row of the review: ticked to close, and Lift to let it go instead. */
84 function reviewRow(base: string, mayLift: boolean, item: Triaged): Html {
85 return html`<li class="bar">
86 <input type="checkbox" form="apply" name="n" value="${item.number}" ${startsTicked(item) ? "checked" : ""} aria-label="Close #${item.number}" />
87 <a href="${base}/issues/${item.number}">${item.title}</a>
88 <span class="muted small">#${item.number} · ${item.verdict.why}</span>
89 ${item.waiting > 0
90 ? html`<span class="small" role="note">${item.waiting} open ${item.waiting === 1 ? "issue waits" : "issues wait"} for it: closing parks ${item.waiting === 1 ? "it" : "them"}</span>`
91 : null}
92 ${mayLift
93 ? html`<form method="post" action="${base}/issues/${item.number}/lift" class="inline">
94 ${btn("Lift", { type: "submit", size: "sm", variant: "ghost" })}
95 </form>`
96 : null}
97 </li>`;
98 }
99
100 // GET /:owner/:name/held — what the gate holds, grouped by theme, with one pass proposed
101 export function heldPage(ctx: Ctx): Response {
102 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
103 const repo = loadRepo(ctx);
104 const review = repo === undefined ? undefined : reviewGate(ctx.app, repo);
105 if (repo === undefined || review === undefined) {
106 notePhase(ctx.request, "render", 0);
107 return notFoundPage(ctx);
108 }
109 const rendering = performance.now();
110 const set = pillarsOf(ctx.app.db, repo);
111 const gate = set.gate!;
112 const base = repoBase(repo);
113 const user = ctx.auth.user;
114 const mayLift =
115 user !== undefined &&
116 (canWrite(ctx.app.db, repo, user) || isCrew(ctx.app.db, repo.id, user.id));
117 const mayApply = user !== undefined && user.kind !== "agent" && canWrite(ctx.app.db, repo, user);
118 const closed = reviewedClosed(ctx.app.db, repo, set);
119 const body = html`${repoHeader(ctx, repo, "issues", { compact: true })}
120 <div class="page-head mt">
121 <h1>Held by the Later gate</h1>
122 <p class="muted">
123 ${review.held} open ${review.held === 1 ? "issue waits" : "issues wait"}
124 behind <a href="${base}/issues/${gate.number}">the gate</a>, set by
125 ${gate.authorName} ${time(gate.createdAt)}. The crew picks none of
126 them until it is lifted.
127 </p>
128 </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.
136 </p>
137 ${mayApply && review.changes.length > 0
138 ? html`<form id="apply" method="post" action="${base}/held/apply" class="bar">
139 <input type="hidden" name="digest" value="${review.digest}" />
140 ${btn("Close the ticked", { type: "submit", variant: "primary" })}
141 </form>`
142 : null}
143 <div class="stack mt">
144 ${review.themes.map((t) =>
145 disclosure(
146 html`${t.name} <span class="muted small">${t.items.length}</span>`,
147 html`<ul class="stack stack-tight">${t.items.map((i) => reviewRow(base, mayLift, i))}</ul>`,
148 ),
149 )}
150 ${closed.length === 0
151 ? null
152 : disclosure(
153 html`Closed by the review <span class="muted small">${closed.length}</span>`,
154 html`<ul class="stack stack-tight">
155 ${closed.map(
156 (i) => html`<li class="bar">
157 <a href="${base}/issues/${i.number}">${i.title}</a>
158 <span class="muted small">#${i.number}</span>
159 ${mayLift
160 ? html`<form method="post" action="${base}/issues/${i.number}/lift" class="inline">
161 ${btn("Revive", { type: "submit", size: "sm", variant: "ghost" })}
162 </form>`
163 : null}
164 </li>`,
165 )}
166 </ul>`,
167 )}
168 </div>`;
169 const response = page({
170 ctx,
171 title: `Held · ${repo.name}`,
172 crumbs: crumbsFor(repo),
173 palette: repoPalette(ctx, repo),
174 body,
175 });
176 notePhase(ctx.request, "render", performance.now() - rendering);
177 return response;
178 }
179
180 // POST /:owner/:name/held/apply { approved } — the proposal the person read, done
181 export async function heldApply(ctx: Ctx): Promise<Response> {
182 const repo = loadRepo(ctx);
183 if (!repo) return notFoundPage(ctx);
184 if (!ctx.auth.user) return signInFirst(ctx);
185 const form = await readForm(ctx, "rw");
186 if (form instanceof Response) return form;
187 const dest = `${repoBase(repo)}/held`;
188 const approved = [...form.getAll("n")]
189 .map((n) => Number(n))
190 .filter((n) => Number.isInteger(n) && n > 0)
191 .slice(0, 1000);
192 const done = applyGateReview(ctx.app, ctx.auth.user, repo, approved, form.get("digest")?.toString());
193 if (!done.ok)
194 return flashTo(ctx, dest, {
195 tone: "danger",
196 message:
197 done.error === "stale"
198 ? "What the gate holds changed since you read it. Nothing was closed; read it again."
199 : done.error === "forbidden"
200 ? THREAD_SAYS.planForbidden
201 : "There is no Later gate here.",
202 });
203 return flashTo(ctx, dest, { message: `Closed ${done.value}.` });
204 }
src/web/studio/held.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * Home says what is being held back for a person (#8700): one line per
3 * repository whose Later gate holds open issues — how many, who set the
4 * gate, when — and a link that opens the list.
5 */
6
7 import { gateHold } from "../../core/gate_hold.ts";
8 import { reposWithUmbrellas } from "../../core/pillars.ts";
9 import { canWrite, listReposOwnedBy, type Repo } from "../../core/repos.ts";
10 import type { User } from "../../core/users.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { repoBase } from "../chrome.ts";
13 import { html, type Html } from "../html.ts";
14 import { time } from "../ui.ts";
15 import { writableAppsOf } from "./asks.ts";
16
17 /** The repositories whose gate holds work back from this person, with the hold. */
18 function holds(ctx: Ctx, user: User) {
19 const db = ctx.app.db;
20 const own = listReposOwnedBy(db, user.id);
21 const byId = new Map<string, Repo>(own.map((r) => [r.id, r]));
22 for (const r of writableAppsOf(ctx, user).repos) byId.set(r.id, r);
23 const withUmbrellas = reposWithUmbrellas(db, [...byId.keys()]);
24 const out = [];
25 for (const id of withUmbrellas) {
26 const repo = byId.get(id)!;
27 if (!canWrite(db, repo, user)) continue;
28 const hold = gateHold(db, repo);
29 if (hold !== undefined) out.push({ repo, hold });
30 }
31 return out;
32 }
33
34 /** One line for each gate that holds work back; nothing where none does. */
35 export function heldLine(ctx: Ctx, user: User): Html | null {
36 const found = holds(ctx, user);
37 if (found.length === 0) return null;
38 return html`<section class="stack stack-tight" id="held-line" aria-label="Held back">
39 ${found.map(
40 ({ repo, hold }) =>
41 html`<p class="small">
42 <a href="${repoBase(repo)}/held"
43 >${hold.held} ${hold.held === 1 ? "issue is" : "issues are"} held
44 behind the Later gate</a
45 >
46 in ${repo.name}, set by ${hold.by} ${time(hold.since)}. Nothing there
47 starts until you lift it.
48 </p>`,
49 )}
50 </section>`;
51 }
src/web/studio/home.ts +3 −1
@@ -52,6 +52,7 @@ import { writableAppsOf } from "./asks.ts";
5252 import { needsYouSection } from "./asks_page.ts";
5353 import { flowCards } from "./flow_card.ts";
5454 import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed.ts";
55 import { heldLine } from "./held.ts";
5556 import { appChips, movingRegion, tellBox } from "./living.ts";
5657 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
5758 import { kpiRow, waitingLine } from "./kpi_row.ts";
@@ -299,7 +300,7 @@ export async function studioHome(
299300 ) {
300301 const apps = await clientApps(ctx, user);
301302 const lean = html`<h1 class="sr-only">Home</h1>
302 ${firstSteps} ${address} ${crewKeyNote(ctx)} ${apps}
303 ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps}
303304 <div class="stack">
304305 ${composer(ctx, user)}
305306 <p class="small muted">
@@ -327,6 +328,7 @@ export async function studioHome(
327328 const body = html`<h1 class="sr-only">Home</h1>
328329 ${tellBox(ctx, user)}
329330 ${ask} ${address}
331 ${timePhase(ctx.request, "held", () => heldLine(ctx, user))}
330332 <section class="stack" aria-label="The forge">${kpis}</section>
331333 ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))}
332334 ${appChips(ctx, user)}
src/web/studio/tell.ts +2
@@ -19,6 +19,7 @@ import { getAppByRepo } from "../../core/apps.ts";
1919 import { LATER_TITLED } from "../../core/pillars.ts";
2020 import { deferredOf, linkedIssueIds } from "../../core/dependencies.ts";
2121 import { hasPicture } from "../../core/note_pictures.ts";
22 import { heldBanner } from "../pages/gate.ts";
2223 import {
2324 getIssuesByNumbers,
2425 labelsFor,
@@ -564,6 +565,7 @@ async function studioTell(
564565 ownTitle: true,
565566 })}
566567 ${head}
568 ${heldBanner(ctx, repo, issue)}
567569 <div class="split">
568570 <div>
569571 ${talk.node} ${doneLine}
test/conversation-confirm.test.ts +1 −1
@@ -123,7 +123,7 @@ describe("the one list of what asks first", () => {
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide",
124124 "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126 "follow_mode", "front_page", "hold", "issue_teams", "labels", "limb", "make_it_yours", "map", "merge", "new_app",
126 "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app",
127127 "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
test/hot-reads-indexed.test.ts +12
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { listEvents } from "../src/core/events.ts";
8 import { closedByReview, reviewedClosed } from "../src/core/gate_hold.ts";
89 import { askToLiveByPillar } from "../src/core/pillars.ts";
910
1011 let root = "";
@@ -63,3 +64,14 @@ test("the window of merged pulls walks the merged ones, not the repository's iss
6364 expect(lines.join("\n")).toContain("pull_merged");
6465 expect(lines.join("\n")).not.toContain("issue_repo_updated");
6566 });
67
68 test("whether the Later gate review closed an issue reads the event index, not the table", () => {
69 const one = plans(() => void closedByReview(app.db, { id: "i", state: "closed" } as never));
70 expect(one.join("\n")).toContain("SEARCH e USING");
71 noScan(one.filter((l) => !/^SCAN issue|SEARCH issue/.test(l)));
72 const many = plans(
73 () => void reviewedClosed(app.db, { id: "r" } as never, { gate: { id: "g" } } as never),
74 );
75 expect(many.join("\n")).toContain("SEARCH e USING");
76 noScan(many);
77 });
test/later-gate.test.ts added +394
@@ -0,0 +1,394 @@
1 /**
2 * The Later gate in plain sight (#8700): home says what it holds, who set it
3 * and when; a held issue says so at its top with Lift; the `Later` label and
4 * the gate are one thing; and one pass over the lot proposes what to close.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { linkIssue, unlinkIssue } from "../src/actions/dependencies.ts";
12 import { applyGateReview, reviewGate } from "../src/actions/gate_review.ts";
13 import { liftHold } from "../src/actions/gate.ts";
14 import { addComment, openIssue, setIssueLabels, setIssueState } from "../src/actions/issues.ts";
15 import { createLabel, createRepo } from "../src/actions/repos.ts";
16 import { closedByReview, gateHold, gateHolding, reviewedClosed } from "../src/core/gate_hold.ts";
17 import { startsTicked, triage } from "../src/core/gate_triage.ts";
18 import { pillarsOf } from "../src/core/pillars.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { getIssueById, labelsFor, type Issue } from "../src/core/issues.ts";
21 import { getRepoById, type Repo } from "../src/core/repos.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24
25 describe("the Later gate in plain sight", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let alice: User;
30 let repo: Repo;
31 let cookie = "";
32 let gate: Issue;
33
34 const fresh = () => getRepoById(app.db, repo.id)!;
35 const current = (i: Issue) => getIssueById(app.db, i.id)!;
36 const open = (title: string): Issue => {
37 const made = openIssue(app, alice, fresh(), { title });
38 if (!made.ok) throw new Error(String(made.error));
39 return made.value;
40 };
41 const names = (i: Issue) => labelsFor(app.db, i.id).map((l) => l.name);
42 const holds = (i: Issue) => gateHolding(app.db, fresh(), current(i)) !== undefined;
43 const labels = (i: Issue, to: string[]) => {
44 const set = setIssueLabels(app, alice, fresh(), current(i), to);
45 if (!set.ok) throw new Error(String(set.error));
46 };
47 const get = async (path: string) =>
48 (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
49 const post = (path: string, body: Record<string, string> = {}) =>
50 fetch(`${servers.url}${path}`, {
51 method: "POST",
52 headers: { cookie, origin: servers.url },
53 body: new URLSearchParams(body),
54 redirect: "manual",
55 });
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-later-gate-"));
59 app = createApp({ dataDir, port: 0 });
60 servers = await serve(app);
61 const made = await createUser(app.db, {
62 origin: "seed",
63 trusted: true,
64 username: "alice",
65 email: "[email protected]",
66 password: "a-good-passphrase",
67 adminIfFirst: true,
68 });
69 if (!made.ok) throw new Error(made.error);
70 alice = getUserByName(app.db, "alice")!;
71 const created = await createRepo(app, alice, { name: "engine", visibility: "public" });
72 if (!created.ok) throw new Error(String(created.error));
73 repo = created.value;
74 const signin = await fetch(`${servers.url}/signin`, {
75 method: "POST",
76 headers: { origin: servers.url },
77 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
78 redirect: "manual",
79 });
80 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
81 open("P0 Trust: be trusted");
82 gate = open("Later gate: nothing behind this starts until the owner opens it");
83 });
84 afterAll(async () => {
85 await servers.stop();
86 await rm(dataDir, { recursive: true, force: true });
87 });
88
89 test("a gate that holds nothing says nothing on home", async () => {
90 expect(gateHold(app.db, fresh())).toBeUndefined();
91 expect(await get("/")).not.toContain("held-line");
92 });
93
94 test("a new gate shows up on home: how many, who set it", async () => {
95 const a = open("Later thing one");
96 const b = open("Later thing two");
97 expect(linkIssue(app, alice, fresh(), current(a), gate).ok).toBe(true);
98 expect(linkIssue(app, alice, fresh(), current(b), gate).ok).toBe(true);
99 const hold = gateHold(app.db, fresh())!;
100 expect(hold.held).toBe(2);
101 expect(hold.by).toBe("alice");
102 const home = await get("/");
103 expect(home).toContain('id="held-line"');
104 expect(home).toContain("2 issues are held");
105 expect(home).toContain("set by alice");
106 expect(home).toContain("/alice/engine/held");
107 });
108
109 test("a held issue says so at its top with Lift, and Lift lifts it", async () => {
110 const i = open("Held by the gate");
111 linkIssue(app, alice, fresh(), current(i), gate);
112 expect(names(i)).toContain("Later");
113 const page = await get(`/alice/engine/issues/${i.number}`);
114 expect(page).toContain('id="held"');
115 expect(page).toContain(`/alice/engine/issues/${i.number}/lift`);
116 const res = await post(`/alice/engine/issues/${i.number}/lift`);
117 expect(res.status).toBe(303);
118 expect(holds(i)).toBe(false);
119 expect(names(i)).not.toContain("Later");
120 expect(await get(`/alice/engine/issues/${i.number}`)).not.toContain('id="held"');
121 });
122
123 test("lifting what is not held is refused", () => {
124 const i = open("Free");
125 const lifted = liftHold(app, alice, fresh(), current(i));
126 expect(lifted.ok).toBe(false);
127 });
128
129 test("removing the Later label lifts the hold, and adding it holds", () => {
130 const i = open("Label and gate are one");
131 linkIssue(app, alice, fresh(), current(i), gate);
132 expect(holds(i)).toBe(true);
133 labels(i, []);
134 expect(holds(i)).toBe(false);
135 expect(names(i)).not.toContain("Later");
136 labels(i, ["Later"]);
137 expect(holds(i)).toBe(true);
138 expect(names(i)).toContain("Later");
139 });
140
141 test("an unrelated label change leaves the hold alone", () => {
142 const i = open("Other labels");
143 linkIssue(app, alice, fresh(), current(i), gate);
144 const made = createLabel(app, alice, fresh(), "ui", "336699");
145 if (!made.ok) throw new Error(String(made.error));
146 labels(i, ["Later", "ui"]);
147 expect(holds(i)).toBe(true);
148 });
149
150 test("triage makes every held issue a candidate; only one found done starts ticked", () => {
151 const themes = triage(
152 [
153 { number: 1, title: "Search the archive", waiting: 0 },
154 { number: 2, title: "Search archive export", waiting: 2 },
155 { number: 3, title: "Search archive filters", waiting: 0 },
156 { number: 4, title: "Paint the footer blue", waiting: 0 },
157 ],
158 new Set([4]),
159 );
160 const rows = themes.flatMap((t) => t.items);
161 expect(rows.length).toBe(4);
162 expect(rows.find((r) => r.number === 1)!.verdict).toMatchObject({ kind: "close", reason: "dropped" });
163 expect(rows.find((r) => r.number === 4)!.verdict).toMatchObject({ kind: "close", reason: "done" });
164 expect(rows.filter(startsTicked).map((r) => r.number)).toEqual([4]);
165 expect(rows.find((r) => r.number === 2)!.waiting).toBe(2);
166 expect(themes[0]!.items.length).toBe(3);
167 expect(triage([]).length).toBe(0);
168 });
169
170 test("one approval closes what is ticked, it stays findable, and one tap revives it", async () => {
171 const a = open("Idea nobody chose");
172 const b = open("Idea to keep behind the gate");
173 for (const i of [a, b]) linkIssue(app, alice, fresh(), current(i), gate);
174 const review = reviewGate(app, fresh())!;
175 expect(review.changes.map((c) => c.number)).toContain(a.number);
176 expect(await get("/alice/engine/held")).toContain("Close the ticked");
177 // A proposal that is not the one shown is refused whole.
178 expect(applyGateReview(app, alice, fresh(), [999999], review.digest)).toEqual({ ok: false, error: "stale" });
179 expect(current(a).state).toBe("open");
180 const res = await post("/alice/engine/held/apply", { n: String(a.number), digest: review.digest });
181 expect(res.status).toBe(303);
182 expect(current(a).state).toBe("closed");
183 expect(current(b).state).toBe("open");
184 expect(holds(b)).toBe(true);
185 // Findable: on the held page and with a Revive at the issue's top.
186 expect(await get("/alice/engine/held")).toContain(`/alice/engine/issues/${a.number}/lift`);
187 const closedPage = await get(`/alice/engine/issues/${a.number}`);
188 expect(closedPage).toContain("Revive");
189 const back = await post(`/alice/engine/issues/${a.number}/lift`);
190 expect(back.status).toBe(303);
191 expect(current(a).state).toBe("open");
192 expect(holds(a)).toBe(false);
193 expect(names(a)).not.toContain("Later");
194 });
195
196 test("an agent reads the proposal and never applies it", async () => {
197 const bot = await createUser(app.db, {
198 origin: "seed",
199 trusted: true,
200 username: "botty",
201 email: "[email protected]",
202 password: "a-good-passphrase",
203 });
204 expect(bot.ok).toBe(true);
205 const agent = { ...getUserByName(app.db, "botty")!, kind: "agent" as const };
206 expect(applyGateReview(app, agent, fresh(), [], reviewGate(app, fresh())!.digest)).toEqual({ ok: false, error: "forbidden" });
207 });
208
209 // ---- the review's safety, its marker, and who may do what ----
210
211 const held = (title: string): Issue => {
212 const i = open(title);
213 expect(linkIssue(app, alice, fresh(), current(i), gate).ok).toBe(true);
214 return i;
215 };
216 const proposal = () => reviewGate(app, fresh())!;
217 const reviewedBy = (i: Issue) => closedByReview(app.db, current(i));
218
219 test("the page ticks nothing unless the crew found it done, and flags what open work waits for", async () => {
220 const blocker = held("Blocker the review might close");
221 const waiter = open("Waits for the blocker");
222 expect(linkIssue(app, alice, fresh(), current(waiter), current(blocker)).ok).toBe(true);
223 expect(proposal().changes.find((c) => c.number === blocker.number)!.waiting).toBe(1);
224 expect(proposal().ticked).not.toContain(blocker.number);
225 const page = await get("/alice/engine/held");
226 expect(page).toContain("closing parks it");
227 expect(page).not.toMatch(/<input[^>]*name="n"[^>]*\bchecked\b/);
228 });
229
230 test("an apply that carries no digest, or a stale one, closes nothing", async () => {
231 const a = held("Digest candidate");
232 const read = proposal();
233 expect(applyGateReview(app, alice, fresh(), [a.number], undefined)).toEqual({ ok: false, error: "stale" });
234 // An issue newly held since the page was read changes what was shown.
235 const late = held("Held after the page was read");
236 expect(applyGateReview(app, alice, fresh(), [a.number], read.digest)).toEqual({ ok: false, error: "stale" });
237 const res = await post("/alice/engine/held/apply", { n: String(a.number), digest: read.digest });
238 expect(res.status).toBe(303);
239 expect(current(a).state).toBe("open");
240 expect(current(late).state).toBe("open");
241 expect(applyGateReview(app, alice, fresh(), [a.number], proposal().digest)).toEqual({ ok: true, value: 1 });
242 });
243
244 test("a comment cannot forge the review's marker; a close after a reopen is not the review's", () => {
245 const a = held("Forged marker candidate");
246 const forged = held("Closed by a person, with a lookalike note");
247 expect(addComment(app, alice, fresh(), current(forged), "Gate review: closed: not chosen.").ok).toBe(true);
248 expect(setIssueState(app, alice, fresh(), current(forged), "closed").ok).toBe(true);
249 expect(reviewedBy(forged)).toBe(false);
250 expect(reviewedClosed(app.db, fresh(), pillarsOf(app.db, fresh())).map((i) => i.number)).not.toContain(forged.number);
251 expect(applyGateReview(app, alice, fresh(), [a.number], proposal().digest).ok).toBe(true);
252 expect(reviewedBy(a)).toBe(true);
253 // Reopened, then closed by hand: no longer the review's.
254 expect(setIssueState(app, alice, fresh(), current(a), "open").ok).toBe(true);
255 expect(reviewedBy(a)).toBe(false);
256 expect(setIssueState(app, alice, fresh(), current(a), "closed").ok).toBe(true);
257 expect(reviewedBy(a)).toBe(false);
258 });
259
260 test("a close that does not happen leaves no marker and no note", () => {
261 const a = held("Closed elsewhere first");
262 const read = proposal();
263 expect(setIssueState(app, alice, fresh(), current(a), "closed").ok).toBe(true);
264 // Stale now: the issue left the candidates, and nothing is written.
265 expect(applyGateReview(app, alice, fresh(), [a.number], read.digest).ok).toBe(false);
266 expect(reviewedBy(a)).toBe(false);
267 expect(app.db.query("SELECT 1 FROM comment WHERE issue_id = ?1 AND body LIKE 'Gate review:%'").get(a.id)).toBeNull();
268 });
269
270 test("lifting what is not held changes nothing, even on a closed issue", () => {
271 const a = held("Closed by hand but still behind the gate");
272 expect(setIssueState(app, alice, fresh(), current(a), "closed").ok).toBe(true);
273 expect(liftHold(app, alice, fresh(), current(a))).toEqual({ ok: false, error: "not_held" });
274 expect(current(a).state).toBe("closed");
275 expect(holds(a)).toBe(false);
276 // Reviewed-closed, then let out of the gate by another road: nothing to lift, and it stays closed.
277 const b = held("Reviewed then unlinked");
278 expect(applyGateReview(app, alice, fresh(), [b.number], proposal().digest).ok).toBe(true);
279 expect(unlinkIssue(app, alice, fresh(), current(b), gate).ok).toBe(true);
280 expect(liftHold(app, alice, fresh(), current(b))).toEqual({ ok: false, error: "not_held" });
281 expect(current(b).state).toBe("closed");
282 });
283
284 describe("who may lift, revive and apply", () => {
285 let bobCookie = "";
286 let bobToken = "";
287 let aliceToken = "";
288 const as = (c: string, path: string, body: Record<string, string> = {}) =>
289 fetch(`${servers.url}${path}`, {
290 method: "POST",
291 headers: { ...(c === "" ? {} : { cookie: c }), origin: servers.url },
292 body: new URLSearchParams(body),
293 redirect: "manual",
294 });
295 const mcp = async (token: string, name: string, args: unknown) => {
296 const res = await fetch(`${servers.url}/mcp`, {
297 method: "POST",
298 headers: {
299 "content-type": "application/json",
300 accept: "application/json, text/event-stream",
301 authorization: `Bearer ${token}`,
302 },
303 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
304 });
305 const body = (await res.json()) as any;
306 const text: string = body.result.content[0].text;
307 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
308 };
309
310 beforeAll(async () => {
311 const made = await createUser(app.db, {
312 origin: "seed",
313 trusted: true,
314 username: "bob",
315 email: "[email protected]",
316 password: "a-good-passphrase",
317 });
318 if (!made.ok) throw new Error(made.error);
319 const bob = getUserByName(app.db, "bob")!;
320 const signin = await fetch(`${servers.url}/signin`, {
321 method: "POST",
322 headers: { origin: servers.url },
323 body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }),
324 redirect: "manual",
325 });
326 bobCookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
327 bobToken = createToken(app.db, bob.id, "rw", "rw").token;
328 aliceToken = createToken(app.db, alice.id, "rw", "rw").token;
329 });
330
331 test("a guest and a reader cannot lift or apply on the page; the work stays held", async () => {
332 const a = held("Reader cannot lift this");
333 const read = proposal();
334 for (const c of ["", bobCookie]) {
335 await as(c, `/alice/engine/issues/${a.number}/lift`);
336 expect(holds(a)).toBe(true);
337 await as(c, "/alice/engine/held/apply", { n: String(a.number), digest: read.digest });
338 expect(current(a).state).toBe("open");
339 }
340 expect(applyGateReview(app, getUserByName(app.db, "bob")!, fresh(), [a.number], read.digest)).toEqual({
341 ok: false,
342 error: "forbidden",
343 });
344 });
345
346 test("a reader cannot revive on the page, and sees no Revive form", async () => {
347 const a = held("Reader cannot revive this");
348 expect(applyGateReview(app, alice, fresh(), [a.number], proposal().digest).ok).toBe(true);
349 await as("", `/alice/engine/issues/${a.number}/lift`);
350 await as(bobCookie, `/alice/engine/issues/${a.number}/lift`);
351 expect(current(a).state).toBe("closed");
352 const page = await (await fetch(`${servers.url}/alice/engine/issues/${a.number}`, { headers: { cookie: bobCookie } })).text();
353 expect(page).not.toContain(`/issues/${a.number}/lift`);
354 });
355
356 test("the MCP lift and held tools hold the same line", async () => {
357 const a = held("Lifted over MCP");
358 const read = await mcp(aliceToken, "held", { app: "alice/engine" });
359 expect(read.value.digest).toBe(proposal().digest);
360 expect(read.value.themes.length).toBeGreaterThan(0);
361 expect((await mcp(bobToken, "lift", { app: "alice/engine", issue: a.number })).error).toBeDefined();
362 expect(holds(a)).toBe(true);
363 expect((await mcp(bobToken, "held", { app: "alice/engine", apply: [a.number], digest: read.value.digest })).error).toContain("forbidden");
364 expect(current(a).state).toBe("open");
365 // A writer's apply needs the digest it read.
366 expect((await mcp(aliceToken, "held", { app: "alice/engine", apply: [a.number] })).error).toBeDefined();
367 expect(current(a).state).toBe("open");
368 const lifted = await mcp(aliceToken, "lift", { app: "alice/engine", issue: a.number });
369 expect(lifted.value.says).toContain("lifted");
370 expect(holds(a)).toBe(false);
371 expect((await mcp(aliceToken, "lift", { app: "alice/engine", issue: a.number })).error).toContain("not_held");
372 });
373
374 test("a private repository's held page is not found by a guest or a stranger", async () => {
375 const made = await createRepo(app, alice, { name: "vault", visibility: "private" });
376 if (!made.ok) throw new Error(String(made.error));
377 const priv = made.value;
378 const mk = (title: string) => {
379 const o = openIssue(app, alice, priv, { title });
380 if (!o.ok) throw new Error(String(o.error));
381 return o.value;
382 };
383 mk("P0 Trust: be trusted");
384 const g = mk("Later gate: nothing behind this starts until the owner opens it");
385 const h = mk("Secret held idea");
386 expect(linkIssue(app, alice, priv, h, g).ok).toBe(true);
387 expect((await fetch(`${servers.url}/alice/vault/held`)).status).toBe(404);
388 const stranger = await fetch(`${servers.url}/alice/vault/held`, { headers: { cookie: bobCookie } });
389 expect(stranger.status).toBe(404);
390 expect(await stranger.text()).not.toContain("Secret held idea");
391 expect(await get("/alice/vault/held")).toContain("Secret held idea");
392 });
393 });
394 });
test/mcp.test.ts +2
@@ -329,6 +329,8 @@ describe("the forge over MCP", () => {
329329 "land_past_gate",
330330 "repo_labels",
331331 "wait_for",
332 "lift",
333 "held",
332334 "confirm_wait",
333335 "attach_look",
334336 "hold",
test/palette-roster.test.ts +4
@@ -50,6 +50,7 @@ test("a site-admin person is offered the whole roster", () => {
5050 ["profile", "Open a profile", "username or org"],
5151 ["repo", "Open a repository", "owner/name"],
5252 ["issues", "Issues in a repository", "owner/name"],
53 ["held", "Held by the Later gate", "owner/name"],
5354 ["pulls", "Pull requests in a repository", "owner/name"],
5455 ["milestones", "Milestones in a repository", "owner/name"],
5556 ["repo-usage", "Usage of a repository", "owner/name"],
@@ -132,6 +133,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
132133 ["profile", "Open a profile", "username or org"],
133134 ["repo", "Open a repository", "owner/name"],
134135 ["issues", "Issues in a repository", "owner/name"],
136 ["held", "Held by the Later gate", "owner/name"],
135137 ["pulls", "Pull requests in a repository", "owner/name"],
136138 ["milestones", "Milestones in a repository", "owner/name"],
137139 ["repo-usage", "Usage of a repository", "owner/name"],
@@ -188,6 +190,7 @@ test("a member person is offered the signed-in roster without the admin entries"
188190 ["profile", "Open a profile", "username or org"],
189191 ["repo", "Open a repository", "owner/name"],
190192 ["issues", "Issues in a repository", "owner/name"],
193 ["held", "Held by the Later gate", "owner/name"],
191194 ["pulls", "Pull requests in a repository", "owner/name"],
192195 ["milestones", "Milestones in a repository", "owner/name"],
193196 ["repo-usage", "Usage of a repository", "owner/name"],
@@ -238,6 +241,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
238241 ["profile", "Open a profile", "username or org"],
239242 ["repo", "Open a repository", "owner/name"],
240243 ["issues", "Issues in a repository", "owner/name"],
244 ["held", "Held by the Later gate", "owner/name"],
241245 ["pulls", "Pull requests in a repository", "owner/name"],
242246 ["milestones", "Milestones in a repository", "owner/name"],
243247 ["repo-usage", "Usage of a repository", "owner/name"],
test/tui-held.test.ts added +170
@@ -0,0 +1,170 @@
1 /**
2 * What the Later gate holds, in the terminal (#8700): `h` on a repository's
3 * issues opens the held screen, the page's twin. A writer ticks and closes,
4 * lifts and revives through the same actions; a reader is refused in the same
5 * words and changes nothing; a private repository is not found; and the web
6 * hears every write as the events its viewers already follow.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { linkIssue } from "../src/actions/dependencies.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { THREAD_SAYS } from "../src/actions/thread_words.ts";
16 import { createApp, type App as Forge } from "../src/app.ts";
17 import { gateHolding } from "../src/core/gate_hold.ts";
18 import { getIssueById, type Issue } from "../src/core/issues.ts";
19 import { getRepoById, type Repo } from "../src/core/repos.ts";
20 import { createUser, getUserByName, type User } from "../src/core/users.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import type { TuiIdentity } from "../src/tui/session.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25 import { settleApp } from "./helpers/tui.ts";
26
27 quietLogs();
28
29 describe("the Later gate, in the terminal", () => {
30 let dataDir: string;
31 let forge: Forge;
32 let repo: Repo;
33 let idea: Issue;
34 let other: Issue;
35 let gate: Issue;
36
37 const fresh = () => getRepoById(forge.db, repo.id)!;
38 const current = (i: Issue) => getIssueById(forge.db, i.id)!;
39 const held = (i: Issue) => gateHolding(forge.db, fresh(), current(i)) !== undefined;
40
41 function boot(user: User | undefined) {
42 const terminal = headlessTerminal(140, 32);
43 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
44 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
45 app.start();
46 const press = async (...keys: string[]) => {
47 for (const key of keys) {
48 const ctrl = key.startsWith("ctrl+");
49 app.dispatch({ type: "key", key: ctrl ? key.slice(5) : key, ctrl, alt: false, shift: false });
50 }
51 await settleApp(app);
52 };
53 const typeText = async (text: string) => {
54 for (const ch of text) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
55 await settleApp(app);
56 };
57 const toHeld = async (slug: string) => {
58 await settleApp(app);
59 await press("ctrl+k");
60 await typeText("Held by the Later gate");
61 await press("enter");
62 await typeText(slug);
63 await press("enter");
64 };
65 return { text: () => app.screen.toText(), press, typeText, toHeld, stop: () => app.stop() };
66 }
67
68 beforeAll(async () => {
69 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-held-"));
70 forge = createApp({ dataDir, port: 0 });
71 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
72 const made = await createUser(forge.db, {
73 origin: "seed",
74 trusted: true,
75 username,
76 email: `${username}@forge.local`,
77 password: "a-good-passphrase",
78 adminIfFirst,
79 });
80 if (!made.ok) throw new Error(made.error);
81 }
82 const alice = getUserByName(forge.db, "alice")!;
83 const made = await createRepo(forge, alice, { name: "plan", visibility: "public" });
84 if (!made.ok) throw new Error("repo");
85 repo = made.value;
86 const open = (r: Repo, title: string): Issue => {
87 const o = openIssue(forge, alice, r, { title });
88 if (!o.ok) throw new Error(String(o.error));
89 return o.value;
90 };
91 open(fresh(), "P0 Trust: be trusted");
92 gate = open(fresh(), "Later gate: nothing behind this starts until the owner opens it");
93 idea = open(fresh(), "Unchosen idea about colours");
94 other = open(fresh(), "Another idea about fonts");
95 for (const i of [idea, other]) linkIssue(forge, alice, fresh(), current(i), gate);
96 const vault = await createRepo(forge, alice, { name: "vault", visibility: "private" });
97 if (!vault.ok) throw new Error("vault");
98 open(vault.value, "P0 Trust: be trusted");
99 const g = open(vault.value, "Later gate: nothing behind this starts until the owner opens it");
100 linkIssue(forge, alice, vault.value, open(vault.value, "Secret held idea"), g);
101 }, 60_000);
102
103 afterAll(async () => {
104 forge.close();
105 await rm(dataDir, { recursive: true, force: true });
106 });
107
108 test("a reader sees the candidates unticked, and is refused to lift or close", async () => {
109 const h = boot(getUserByName(forge.db, "bob")!);
110 await h.toHeld("alice/plan");
111 expect(h.text()).toContain("Unchosen idea about colours");
112 expect(h.text()).toContain("[ ] #");
113 expect(h.text()).not.toContain("[x]");
114 await h.press("l");
115 expect(h.text()).toContain(THREAD_SAYS.planForbidden);
116 await h.press("space", "A");
117 expect(held(idea) && held(other)).toBe(true);
118 expect(current(idea).state).toBe("open");
119 h.stop();
120 });
121
122 test("a guest sees a public gate and changes nothing", async () => {
123 const h = boot(undefined);
124 await h.toHeld("alice/plan");
125 expect(h.text()).toContain("Unchosen idea about colours");
126 await h.press("l");
127 expect(held(idea) && held(other)).toBe(true);
128 await h.press("space", "A");
129 expect(current(idea).state).toBe("open");
130 h.stop();
131 });
132
133 test("a private repository is not found by a reader or a guest", async () => {
134 for (const user of [getUserByName(forge.db, "bob"), undefined]) {
135 const h = boot(user);
136 await h.toHeld("alice/vault");
137 expect(h.text()).toContain("not found");
138 expect(h.text()).not.toContain("Secret held idea");
139 h.stop();
140 }
141 });
142
143 test("a writer ticks and closes, lifts and revives, and the web hears each write", async () => {
144 const heard: string[] = [];
145 const off = forge.bus.subscribe((e) => {
146 if (e.repoId === repo.id) heard.push(`${e.kind}:${e.payload["number"]}`);
147 });
148 const h = boot(getUserByName(forge.db, "alice")!);
149 await h.toHeld("alice/plan");
150 // Nothing is ticked until the writer ticks: the first line is the newest.
151 await h.press("space");
152 expect(h.text()).toContain("[x]");
153 await h.press("A");
154 expect(current(other).state).toBe("closed");
155 expect(current(idea).state).toBe("open");
156 expect(heard).toContain(`issue.closed:${other.number}`);
157 expect(h.text()).toContain("Closed by the review");
158 // The closed one is listed after the candidates; Revive is `l` on it.
159 await h.press("j", "l");
160 expect(current(other).state).toBe("open");
161 expect(held(other)).toBe(false);
162 expect(heard).toContain(`issue.reopened:${other.number}`);
163 expect(heard).toContain(`issue.unblocked:${other.number}`);
164 // And lifting an open candidate lets it out.
165 await h.press("k", "l");
166 expect(held(idea)).toBe(false);
167 off();
168 h.stop();
169 });
170 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs