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

MCP `held` draws a `ui://forge/held` card: the Later gate's themes, and one button that closes the ones already done #11416

Merged opened this

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

Merged

mason/forge:mason/11408-mcp-held-draws-a-ui-forge-held-card-the → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/11408-mcp-held-draws-a-ui-forge-held-card-the was merged into main as 0cc6678.

Review

Sign in to review this change.
6 files changed +170 −13
docs/MCP.md +2 −1
@@ -118,7 +118,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
118118 | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 |
119119 | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 |
120120 | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 |
121 | `held` | — | What the Later gate holds, and one proposed pass over it | 270 |
121 | `held` | — | What the Later gate holds, and one proposed pass over it | 319 |
122122 | `here` | — | What you may do on a forge page; given an app's link, what its /__forge/ door shows | 237 |
123123 | `hold` | — | Hold a pull (`why`) or `release` it; or `path`: hold new work on a file | 522 |
124124 | `hooks` | `list`, `add`, `remove`, `deliveries`, `ping`, `pause`, `resume`, `redeliver` | List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins | 494 |
@@ -522,6 +522,7 @@ answers
522522 | `ui://forge/issues` | `issues` | A repository's issues, one row each: state, pillar, what it waits on, `untrusted` when an outsider wrote it. |
523523 | `ui://forge/release` | `releases` | Kept releases, the last 10 newest first, each proven, unproven or rolled back, linked to its pull. |
524524 | `ui://forge/releases` | `releases` with `shipped` | What shipped: one linked row per landing, the live version, what is on its way. |
525 | `ui://forge/held` | `held` | What waits on a person: themes, one row each, and one button that closes the done ones. |
525526
526527 The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with
527528 its ready, working and waiting counts, and the first five ready issues linked
src/http/mcp/tools/held.ts +13 −10
@@ -4,7 +4,7 @@ import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"
44
55 export const tool: Tool = {
66 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",
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; a read draws the `ui://forge/held` card",
88 description: "What the Later gate holds, and one proposed pass over it.",
99 inputSchema: {
1010 type: "object",
@@ -37,14 +37,17 @@ export const tool: Tool = {
3737 }
3838 const review = reviewGate(ctx.app, repo);
3939 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 });
40 return {
41 ...good({

The good({...}) body was wrapped but not re-indented; fix the indentation. Also extend the tool doc with "draws the ui://forge/held card" like issue/issues do (mind the mcp-size ceiling), and cover this card: { repo } line with a test that runs the tool.

42 says: `${review.held} held; ${review.ticked.length} found done on main.`,
43 themes: review.themes.map((t) => ({
44 theme: t.name,
45 issues: t.items.map((i) => ({ number: i.number, title: i.title, waiting: i.waiting, ...i.verdict })),
46 })),
47 approve: review.ticked,
48 digest: review.digest,
49 }),
50 card: { repo: `${repo.ownerName}/${repo.name}` },
51 };
4952 },
5053 };
src/web/studio/cards.ts +39 −2
@@ -11,7 +11,7 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues";
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "held";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4444
4545 export const CARD_MIME = "text/html;profile=mcp-app";
4646 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues"];
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held"];
4848
4949 /** The tool whose answer each card draws. */
5050 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -70,6 +70,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
7070 fork_tree: "forks",
7171 search: "search",
7272 issues: "issues",
73 held: "held",
7374 };
7475
7576 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -95,6 +96,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
9596 releases: "Shipped",
9697 search: "Search",
9798 issues: "Issues",
99 held: "Later",
98100 };
99101
100102 type Obj = Record<string, unknown>;
@@ -117,6 +119,7 @@ const EXPLORE_ROWS = 20;
117119 const PULLS_LIMIT = 20;
118120 export const SEARCH_ROWS = 20;
119121 export const ISSUES_ROWS = 20;
122 const HELD_ROWS = 40;
120123 const ASKED_WORDS = 80;
121124 const ASKED_LIMIT = 20;
122125 const RELEASE_ROWS = 10;
@@ -225,6 +228,40 @@ export function cardFor(
225228 if (panels.length > BOARD_PANELS) rows.push({ label: "More", value: `${panels.length - BOARD_PANELS} more` });
226229 return { kind, title: text(v["title"]), status: `${panels.length} panel${panels.length === 1 ? "" : "s"} · ${text(v["range"])}`, rows, actions };
227230 }
231 if (kind === "held") {
232 // `held` read answers `{ says, themes: [{ theme, issues: [{ number, title, reason }] }], approve, digest }`;
233 // the repo comes in `extra`. An apply's answer has no `themes`, so no card.
234 const v = obj(value);
235 if (!Array.isArray(v["themes"])) return undefined;
236 const slug = text(extra["repo"]);
237 let shown = 0;
238 let total = 0;
239 const seen = new Set<number>();
240 for (const t of v["themes"].map(obj)) {
241 const issues = list(t["issues"]).map(obj);
242 rows.push({ label: text(t["theme"]), value: `${issues.length} held` });
outdated

Rows past HELD_ROWS are dropped silently, yet "Close N done" applies all of approve. Push a More row (N more) like the board card does, or limit the button to the shown rows, so the button never closes what the card did not show.

243 for (const i of issues) {
244 total++;
245 if (shown >= HELD_ROWS) continue;
246 shown++;
247 const n = text(i["number"]);
248 seen.add(Number(n));
249 const path = `/${slug}/issues/${n}`;
250 rows.push({
251 label: `#${n} ${text(i["title"])}`.trim(),
252 value: i["reason"] === "done" ? "done" : "",
253 depth: 1,
254 ...(web(origin) && slugOf(slug) && /^[1-9]\d*$/.test(n) && here(origin, path) ? { link: origin + path } : {}),
255 });
256 }
257 }
258 if (total > shown) rows.push({ label: "More", value: `${total - shown} more not shown` });
259 // The button closes only what the card shows: a person never presses it for issues they cannot see.
260 const approve = list(v["approve"]).filter((n): n is number => Number.isInteger(n) && (n as number) > 0 && seen.has(n as number));
261 if (approve.length > 0 && slugOf(slug) && text(v["digest"]) !== "")
262 actions.push({ label: `Close ${approve.length} done`, tool: "held", args: { app: slug, apply: approve, digest: text(v["digest"]) } });
263 return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions };
264 }
228265 if (kind === "forks") {
229266 // `fork_tree` answers `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`; a repo with no readable tree has no card.
230267 const v = obj(value);
test/mcp-held-card.test.ts added +53
@@ -0,0 +1,53 @@
1 /** MCP `held` draws the `ui://forge/held` card: a group per theme, a row per issue, and one button that closes the done ones. */

Every test here calls cardFor with a hand-made extra. Nothing would fail if held.ts stopped returning card. Add a test through the MCP tool run against a gate fixture (see test/mcp.test.ts) asserting card.repo and a Close N done action.

2 import { expect, test } from "bun:test";
3 import { CARD_OF_TOOL, CARD_KINDS, cardFor } from "../src/web/studio/cards.ts";
4
5 const origin = "https://forge.test";
6 const read = (approve: number[]) => ({
7 says: "3 held; 1 found done on main.",
8 themes: [
9 { theme: "mcp", issues: [
10 { number: 7, title: "Old thing", waiting: 0, kind: "close", reason: "done", why: "on main" },
11 { number: 9, title: "Maybe", waiting: 0, kind: "keep", reason: "kept", why: "serves a pillar" },
12 ] },
13 { theme: "other", issues: [{ number: 12, title: "Stray", waiting: 0, kind: "close", reason: "dropped", why: "x" }] },
14 ],
15 approve,
16 digest: "abc123",
17 });
18
19 test("held is drawn as the Later card", () => {
20 expect(CARD_OF_TOOL["held"]).toBe("held");
21 expect(CARD_KINDS).toContain("held");
22 expect(cardFor("held", read([7]), origin, { repo: "alice/app" })?.title).toBe("Later");
23 });
24
25 test("a group per theme, a row per issue linked to it, done marked", () => {
26 const card = cardFor("held", read([7]), origin, { repo: "alice/app" })!;
27 expect(card.status).toBe("3 held; 1 found done on main.");
28 expect(card.rows.map((r) => [r.label, r.value, r.link])).toEqual([
29 ["mcp", "2 held", undefined],
30 ["#7 Old thing", "done", `${origin}/alice/app/issues/7`],
31 ["#9 Maybe", "", `${origin}/alice/app/issues/9`],
32 ["other", "1 held", undefined],
33 ["#12 Stray", "", `${origin}/alice/app/issues/12`],
34 ]);
35 });
36
37 test("one button, Close N done, applies approve with the read's digest", () => {
38 const card = cardFor("held", read([7, 12]), origin, { repo: "alice/app" })!;
39 expect(card.actions).toEqual([{ label: "Close 2 done", tool: "held", args: { app: "alice/app", apply: [7, 12], digest: "abc123" } }]);
40 });
41
42 test("no button when nothing is done, and an apply's answer has no card", () => {
43 expect(cardFor("held", read([]), origin, { repo: "alice/app" })!.actions).toEqual([]);
44 expect(cardFor("held", { says: "Closed 2." }, origin, { repo: "alice/app" })).toBeUndefined();
45 });
46
47 test("a card never offers to close what it does not show: a More row, and the button keeps to the shown", () => {
48 const issues = Array.from({ length: 45 }, (_, i) => ({ number: i + 1, title: `T${i + 1}`, waiting: 0, kind: "close", reason: "done", why: "x" }));
49 const card = cardFor("held", { says: "s", themes: [{ theme: "t", issues }], approve: issues.map((i) => i.number), digest: "d" }, origin, { repo: "alice/app" })!;
50 expect(card.rows.at(-1)).toEqual({ label: "More", value: "5 more not shown" });
51 expect(card.actions[0]!.label).toBe("Close 40 done");
52 expect(card.actions[0]!.args["apply"]).toEqual(issues.slice(0, 40).map((i) => i.number));
53 });
test/mcp-held-tool-card.test.ts added +62
@@ -0,0 +1,62 @@
1 /** The `held` tool, run against a real Later gate, answers with the card: the repo comes from the tool, the button from the review. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { linkIssue } from "../src/actions/dependencies.ts";
8 import { reviewGate } from "../src/actions/gate_review.ts";
9 import { openIssue } from "../src/actions/issues.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { getRepo } from "../src/core/repos.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let token: string;
20
21 beforeAll(async () => {
22 dataDir = await mkdtemp(join(tmpdir(), "forge-held-tool-card-"));
23 app = createApp({ dataDir, port: 0 });
24 servers = await serve(app);
25 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
26 if (!made.ok) throw new Error(made.error);
27 const alice = getUserByName(app.db, "alice")!;
28 token = createToken(app.db, alice.id, "rw", "rw").token;
29 const created = await createRepo(app, alice, { name: "engine", visibility: "public" });
30 if (!created.ok) throw new Error(String(created.error));
31 const repo = created.value;
32 const open = (title: string, body = "") => {
33 const o = openIssue(app, alice, repo, { title, body });
34 if (!o.ok) throw new Error(String(o.error));
35 return o.value;
36 };
37 open("P0 Trust: be trusted");
38 const gate = open("Later gate: nothing behind this starts until the owner opens it");
39 for (const t of ["Frobnicate the widget pipeline", "Frobnicate widget pipeline"]) linkIssue(app, alice, repo, open(t, "words"), gate);
40 });
41
42 afterAll(async () => {
43 await servers.stop();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 test("the tool's read carries card.repo, and the card has the Close N done button", async () => {
48 const res = await fetch(`${servers.url}/mcp`, {
49 method: "POST",
50 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
51 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "held", arguments: { app: "alice/engine" } } }),
52 });
53 const body: any = await res.json();
54 const card = body.result.structuredContent?.card;
55 const review = reviewGate(app, getRepo(app.db, "alice", "engine")!)!;
56 expect(review.ticked.length).toBeGreaterThan(0);
57 expect(card.title).toBe("Later");
58 expect(card.rows.some((r: any) => r.link === `${servers.url}/alice/engine/issues/${review.ticked[0]}`)).toBe(true);
59 expect(card.actions).toEqual([
60 { label: `Close ${review.ticked.length} done`, tool: "held", args: { app: "alice/engine", apply: review.ticked, digest: review.digest } },
61 ]);
62 });
test/mcp.test.ts +1
@@ -1295,6 +1295,7 @@ describe("the forge over MCP", () => {
12951295 "ui://forge/feed",
12961296 "ui://forge/flow",
12971297 "ui://forge/forks",
1298 "ui://forge/held",
12981299 "ui://forge/issue",
12991300 "ui://forge/issues",
13001301 "ui://forge/map",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs