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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11408-mcp-held-draws-a-ui-forge-held-card-the → main 5 commits fork 3 threads · 3 unresolved
docs/MCP.md +2 −1
| @@ -118,7 +118,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 118 | 118 | | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 | |
| 119 | 119 | | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 | |
| 120 | 120 | | `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 | | |
| 122 | 122 | | `here` | — | What you may do on a forge page; given an app's link, what its /__forge/ door shows | 237 | |
| 123 | 123 | | `hold` | — | Hold a pull (`why`) or `release` it; or `path`: hold new work on a file | 522 | |
| 124 | 124 | | `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 | ||
| 522 | 522 | | `ui://forge/issues` | `issues` | A repository's issues, one row each: state, pillar, what it waits on, `untrusted` when an outsider wrote it. | |
| 523 | 523 | | `ui://forge/release` | `releases` | Kept releases, the last 10 newest first, each proven, unproven or rolled back, linked to its pull. | |
| 524 | 524 | | `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. | | |
| 525 | 526 | |
| 526 | 527 | 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 |
| 527 | 528 | 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" | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 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", | |
| 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", | |
| 8 | 8 | description: "What the Later gate holds, and one proposed pass over it.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| @@ -37,14 +37,17 @@ export const tool: Tool = { | ||
| 37 | 37 | } |
| 38 | 38 | const review = reviewGate(ctx.app, repo); |
| 39 | 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 | }); | |
| 40 | return { | |
| 41 | ...good({ | |
| 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 | }; | |
| 49 | 52 | }, |
| 50 | 53 | }; |
src/web/studio/cards.ts +39 −2
| @@ -11,7 +11,7 @@ import { html, raw } from "../html.ts"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 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"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 44 | 44 | |
| 45 | 45 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 46 | 46 | 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"]; | |
| 48 | 48 | |
| 49 | 49 | /** The tool whose answer each card draws. */ |
| 50 | 50 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -70,6 +70,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 70 | 70 | fork_tree: "forks", |
| 71 | 71 | search: "search", |
| 72 | 72 | issues: "issues", |
| 73 | held: "held", | |
| 73 | 74 | }; |
| 74 | 75 | |
| 75 | 76 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -95,6 +96,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 95 | 96 | releases: "Shipped", |
| 96 | 97 | search: "Search", |
| 97 | 98 | issues: "Issues", |
| 99 | held: "Later", | |
| 98 | 100 | }; |
| 99 | 101 | |
| 100 | 102 | type Obj = Record<string, unknown>; |
| @@ -117,6 +119,7 @@ const EXPLORE_ROWS = 20; | ||
| 117 | 119 | const PULLS_LIMIT = 20; |
| 118 | 120 | export const SEARCH_ROWS = 20; |
| 119 | 121 | export const ISSUES_ROWS = 20; |
| 122 | const HELD_ROWS = 40; | |
| 120 | 123 | const ASKED_WORDS = 80; |
| 121 | 124 | const ASKED_LIMIT = 20; |
| 122 | 125 | const RELEASE_ROWS = 10; |
| @@ -225,6 +228,40 @@ export function cardFor( | ||
| 225 | 228 | if (panels.length > BOARD_PANELS) rows.push({ label: "More", value: `${panels.length - BOARD_PANELS} more` }); |
| 226 | 229 | return { kind, title: text(v["title"]), status: `${panels.length} panel${panels.length === 1 ? "" : "s"} · ${text(v["range"])}`, rows, actions }; |
| 227 | 230 | } |
| 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 |
||
| 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 | } | |
| 228 | 265 | if (kind === "forks") { |
| 229 | 266 | // `fork_tree` answers `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`; a repo with no readable tree has no card. |
| 230 | 267 | 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 |
||
| 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", () => { | ||
| 1295 | 1295 | "ui://forge/feed", |
| 1296 | 1296 | "ui://forge/flow", |
| 1297 | 1297 | "ui://forge/forks", |
| 1298 | "ui://forge/held", | |
| 1298 | 1299 | "ui://forge/issue", |
| 1299 | 1300 | "ui://forge/issues", |
| 1300 | 1301 | "ui://forge/map", |
The
good({...})body was wrapped but not re-indented; fix the indentation. Also extend the tooldocwith "draws theui://forge/heldcard" like issue/issues do (mind the mcp-size ceiling), and cover thiscard: { repo }line with a test that runs the tool.