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

The `map` tool's answer draws a ui://forge/map card: ready now, being worked on, waiting, by pillar #4178

Merged opened this

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

Merged

scout/forge:scout/3343-the-map-tool-s-answer-draws-a-ui-forge-m → main 11 commits fork 5 threads · 5 unresolved

  • Merged. scout/3343-the-map-tool-s-answer-draws-a-ui-forge-m was merged into main as 62e1278.

Review

Sign in to review this change.
5 files changed +107 −8
docs/MCP.md +5 −3
@@ -61,7 +61,7 @@ for a tunnel and a forge that sleeps.
6161 | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed` | |
6262 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
6363 | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes |
64 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`) | |
64 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | |
6565 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
6666 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
6767 | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes |
@@ -178,9 +178,11 @@ answers
178178
179179 ## Cards, the contract and the plugins (#1116)
180180
181 - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones` and `feed` name a `ui://` resource in
181 - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `map` and `feed` name a `ui://` resource in
182182 `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`,
183 `decision`, `wave`, `feed`. `resources/list` and `resources/read` serve one small
183 `decision`, `wave`, `map`, `feed`. The Plan card (`map`) shows one row per pillar with
184 its ready, working and waiting counts, and the first five ready issues linked
185 to their pages; it has no actions. `resources/list` and `resources/read` serve one small
184186 sandboxed page, which draws the `structuredContent.card` model that the
185187 tool result carries, with `textContent` only. The model is built in
186188 `src/web/studio/cards.ts`. A card's
src/http/mcp.ts +1 −1
@@ -2494,7 +2494,7 @@ const TOOLS: readonly Tool[] = [
24942494 {
24952495 name: "map",
24962496 description:
2497 "An app's plan: open issues as ready or waiting, the critical path, every edge, where the crew is, and `parkedTwice` issues.",
2497 "An app's plan: open issues ready or waiting, critical path, every edge, where the crew is, `parkedTwice` issues.",
24982498 inputSchema: {
24992499 type: "object",
25002500 properties: { app: APP_ARG },
src/web/studio/cards.ts +32 −3
@@ -10,7 +10,7 @@
1010 import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212
13 export type CardKind = "app" | "run" | "decision" | "wave" | "feed";
13 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed";
1414
1515 export interface CardRow {
1616 readonly label: string;
@@ -36,7 +36,7 @@ export interface Card {
3636
3737 export const CARD_MIME = "text/html;profile=mcp-app";
3838 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
39 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "feed"];
39 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed"];
4040
4141 /** The tool whose answer each card draws. */
4242 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -44,6 +44,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4444 now: "run",
4545 asks: "decision",
4646 milestones: "wave",
47 map: "map",
4748 feed: "feed",
4849 new_app: "app",
4950 };
@@ -53,6 +54,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
5354 run: "Working now",
5455 decision: "Waiting on you",
5556 wave: "Wave",
57 map: "Plan",
5658 feed: "Feed",
5759 };
5860
@@ -136,6 +138,33 @@ export function cardFor(tool: string, value: unknown, origin = ""): Card | undef
136138 if (repo !== "" && Number.isInteger(number) && number > 0)
137139 actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true });
138140 }
141 } else if (kind === "map") {
142 const v = obj(value);
143 const issues = list(v["issues"]).map(obj);
144 const pillars = list(v["pillars"]).map(obj);
145 // The plan's umbrellas are furniture, not work: `pillarRows` skips them, so does this.
146 const furniture = new Set(pillars.map((p) => text(p["umbrella"])));

Low: the headline leaves out only the pillar umbrellas, but pillarRows also leaves out the gate (isStructural) and repo.directionIssueId. On a repo with a gate or a direction issue, the headline will be one or two higher than the sum of the rows. The same happens when there are more than LIMIT pillars. The fix is in mapJson: have it return a furniture: number[] (or a work flag on each brief), and count from that here. Then the card can't drift from the page.

147 const work = issues.filter((i) => i["state"] === "open" && !furniture.has(text(i["number"])));
148 const count = (word: string): number => work.filter((i) => i["word"] === word).length;
outdated

High: the status line counts readyNow (graph) and blockedOn, but the pillar rows below count by wordFor. An issue the crew is on is counted as both 'ready' and 'working' here and only as 'working' in its pillar row, so the headline and the rows disagree. Use v.counts (ready / working / blocked), which mapJson already returns.

149 status = `${count("ready")} ready · ${count("working")} working · ${count("blocked")} waiting`;
150 for (const p of pillars.slice(0, LIMIT)) {
151 const name = p["pillar"] === null ? "" : text(p["pillar"]);
152 const working = work.filter((i) => i["word"] === "working" && (i["pillar"] ?? null) === (p["pillar"] ?? null)).length;
outdated

Count word === "working" among this pillar's issues instead of checking membership in working. That matches how pillarRows counts ready and blocked, so the three numbers on a row add up.

153 rows.push({
154 label: name === "" ? "No pillar" : name,
155 value: `${text(p["ready"])} ready · ${working} working · ${text(p["blocked"])} waiting`,
156 });
157 }
158 const repo = obj(v["repo"]);
159 const base = `${origin}/${text(repo["owner"])}/${text(repo["name"])}/issues/`;
160 for (const r of list(v["ready"]).map(obj).filter((r) => r["word"] === "ready").slice(0, 5)) {
outdated

Filter to r.word === "ready" before slicing. Otherwise an issue a crew member is already building (or one held or parked) is offered as 'ready now'.

161 const number = text(r["number"]);
162 rows.push({
163 label: `#${number}`,
164 value: text(r["title"]),
165 ...(/^https?:\/\//.test(origin) && number !== "" ? { link: base + number } : {}),
166 });
167 }
139168 } else if (kind === "feed") {
140169 const fresh = Number(obj(value)["new"]);
141170 status = Number.isInteger(fresh) && fresh > 0 ? `${fresh} new since you last looked` : "Latest";
@@ -177,7 +206,7 @@ function draw(card) {
177206 const dl = document.createElement("dl");
178207 for (const r of card.rows) {
179208 const dt = el("dt", r.label);
180 if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); }
209 if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.target = "_blank"; a.rel = "noopener noreferrer"; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); }
181210 dl.append(dt, el("dd", r.value));
182211 }
183212 root.append(dl);
test/card-shell.test.ts +67 −1
@@ -1,17 +1,20 @@
11 import { describe, expect, test } from "bun:test";
22 import { SHELL_JS, cardFor } from "../src/web/studio/cards.ts";
3 import { NO_MARKS, mapJson } from "../src/web/pages/map.ts";
34
45 class Node {
56 children: Node[] = [];
67 textContent = "";
78 className = "";
89 value = "";
9 href = "";
1010 onclick: ((ev?: any) => void) | undefined;
1111 focused = false;
1212 constructor(readonly tag: string) {}
1313 append(...n: Node[]) { this.children.push(...n); }
1414 replaceChildren(...n: Node[]) { this.children = n; }
15 href = "";
16 target = "";
17 rel = "";
1518 setAttribute() {}
1619 focus() { this.focused = true; }
1720 all(): Node[] { return [this, ...this.children.flatMap((c) => c.all())]; }
@@ -87,6 +90,69 @@ describe("the card shell", () => {
8790 expect(card.actions.some((a) => a.tool === "say")).toBe(false);
8891 });
8992
93 test("the map draws a Plan card from what mapJson returns: one count per issue, agreeing everywhere", () => {
94 const mk = (number: number, state = "open") =>
95 ({ id: `i${number}`, number, title: `T<${number}>`, state, closeReason: null, assigneeName: null, createdAt: `2026-01-${String(number).padStart(2, "0")}` }) as never;
96 // 1 umbrella (P0), 2-8 P0 work (3 is the crew's, 4 held), 9-10 P1 (10 waits on 9), 11 done.
97 const issues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((n) => mk(n, n === 11 ? "closed" : "open"));
98 const graph = {
99 issues,
100 edges: [{ issueId: "i10", blockedById: "i9" }],
outdated

The fixture encodes the contradiction: #3 is in ready and in working, and P0 has ready: 6 while the expected row also says 1 working, out of 6 open. Real mapJson output cannot look like this. Give the issues their word the way mapJson does (or build the input with mapJson itself), and assert that the status line agrees with the sum of the pillar rows.

101 held: [],
102 unadopted: new Set(),
103 structural: new Set(["i1"]),
104 deferred: new Map(),
105 } as never;
106 const marks = { ...NO_MARKS, crew: new Map([["i3", {}]]), onHold: new Map([["i4", "a pull"]]) } as never;
107 const of = new Map<string, string>([...[2, 3, 4, 5, 6, 7, 8].map((n) => [`i${n}`, "P0"] as const), ["i9", "P1"], ["i10", "P1"]]);
108 const pillars = {
109 set: { pillars: [{ name: "P0", umbrella: issues[0] }, { name: "P1", umbrella: mk(12) }], gate: undefined, later: undefined },
110 names: ["P0", "P1"], of, merged: [],
111 } as never;
112 const json = mapJson({ ownerName: "a", name: "b", crewOutsiders: false } as never, graph, marks, pillars);
113 const card = cardFor("map", json, "https://f.example")!;
114 expect(card.title).toBe("Plan");
115 expect(card.status).toBe("6 ready · 1 working · 1 waiting");
116 expect(card.rows.slice(0, 2)).toEqual([
117 { label: "P0", value: "5 ready · 1 working · 0 waiting" },
118 { label: "P1", value: "1 ready · 0 working · 1 waiting" },
119 ]);
120 // The headline is the sum of the rows.
121 const sum = (i: number) => card.rows.slice(0, 2).reduce((n, r) => n + Number(r.value.split(" ")[i === 0 ? 0 : i === 1 ? 3 : 6]), 0);
122 expect(`${sum(0)} ready · ${sum(1)} working · ${sum(2)} waiting`).toBe(card.status);
123 // Nothing the crew is on, or a person holds, is offered as ready; five at most.
124 const links = card.rows.slice(2);
125 expect(links.map((r) => r.label)).toEqual(["#9", "#2", "#5", "#6", "#7"]);
126 expect(links[0]).toEqual({ label: "#9", value: "T<9>", link: "https://f.example/a/b/issues/9" });
127 expect(cardFor("map", json, "javascript:x")!.rows[2]!.link).toBeUndefined();
128 });
129
130 test("a row's https link draws an anchor that asks the host to open it; any other scheme stays text", () => {
131 const s = shell();
132 s.send(s.result({
133 kind: "map", title: "Plan", status: "", actions: [],
134 rows: [
135 { label: "#1", value: "ok", link: "https://f.example/a/b/issues/1" },
136 { label: "#2", value: "bad", link: "javascript:alert(1)" },
137 ],
138 }));
139 const dts = s.root.all().filter((n) => n.tag === "dt");
140 expect(dts.length).toBe(2);
141 const a = dts[0]!.children[0]!;
142 expect(a.tag).toBe("a");
143 expect(a.textContent).toBe("#1");
144 expect(a.href).toBe("https://f.example/a/b/issues/1");
145 expect(a.rel).toBe("noopener noreferrer");
146 expect(a.target).toBe("_blank");
147 let prevented = false;
148 a.onclick!({ preventDefault: () => (prevented = true) });
149 expect(prevented).toBe(true);
150 expect(s.posted[1]).toMatchObject({ method: "ui/open-link", params: { url: "https://f.example/a/b/issues/1" } });
151 expect(dts[1]!.children.length).toBe(0);
152 expect(dts[1]!.textContent).toBe("#2");
153 expect(s.root.all().filter((n) => n.tag === "a").length).toBe(1);
154 });
155
90156 test("a feed row with a forge link is an anchor that asks the host to open it; no link, or a javascript: one, stays text", () => {
91157 const s = shell();
92158 const feed = cardFor("feed", { items: [{ what: "x", link: "/a/b/pulls/1" }, { what: "y" }] }, "https://forge.test")!;
test/mcp.test.ts +2
@@ -1024,6 +1024,7 @@ describe("the forge over MCP", () => {
10241024 "ui://forge/app",
10251025 "ui://forge/decision",
10261026 "ui://forge/feed",
1027 "ui://forge/map",
10271028 "ui://forge/run",
10281029 "ui://forge/wave",
10291030 ]);
@@ -1041,6 +1042,7 @@ describe("the forge over MCP", () => {
10411042 expect(meta("now")).toBe("ui://forge/run");
10421043 expect(meta("asks")).toBe("ui://forge/decision");
10431044 expect(meta("milestones")).toBe("ui://forge/wave");
1045 expect(meta("map")).toBe("ui://forge/map");
10441046 expect(meta("feed")).toBe("ui://forge/feed");
10451047 });
10461048

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs