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

asks, now and milestones answer with their ui:// card, like apps, flow and waves already do #11080

Merged opened this

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

Merged

scout/forge:scout/11066-asks-now-and-milestones-answer-with-thei → main 2 commits fork

  • Merged. scout/11066-asks-now-and-milestones-answer-with-thei was merged into main as f098b22.

Review

Sign in to review this change.
4 files changed +162 −8
docs/MCP.md +2 −2
@@ -58,7 +58,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
5858 | `archive` | — | Archive or wake a repository (`archived`) | 332 |
5959 | `asked` | — | What people asked the crew for: unmerged by default, oldest first | 372 |
6060 | `asks` | — | What waits on you, most important first; saved filter applies | 457 |
61 | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 486 |
61 | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 539 |
6262 | `asks_decide` | — | Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`) | 270 |
6363 | `asks_proposal` | — | Act on a wave proposal: `approve`, `redirect` (`words`), `later` (`again`) | 315 |
6464 | `asks_undecide` | — | Put a set-aside card back on your board, within a day | 208 |
@@ -139,7 +139,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
139139 | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 |
140140 | `merge` | — | Land a pull, as its page's Merge button does; a person's only | 319 |
141141 | `migrate` | — | Bring a repo in by public `url` (`name`, `crew`), history and all | 269 |
142 | `milestones` | — | How far each ask is | 385 |
142 | `milestones` | — | How far each ask is | 391 |
143143 | `mint_invite` | — | Mint a one-use invite link, shown once, valid `days` (1–30, default 7) | 225 |
144144 | `mirror_now` | — | Admin: bring the snapshot mirror level now | 129 |
145145 | `mirror_set` | — | Admin: set or forget the snapshot mirror | 296 |
src/web/studio/cards.ts +23 −5
@@ -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" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew" | "board" | "forks";
14 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew" | "board" | "forks" | "milestones";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -42,7 +42,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4242
4343 export const CARD_MIME = "text/html;profile=mcp-app";
4444 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
45 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew", "board", "forks"];
45 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew", "board", "forks", "milestones"];
4646
4747 /** The tool whose answer each card draws. */
4848 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -50,7 +50,8 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
5050 now: "run",
5151 runs: "run",
5252 asks: "decision",
53 milestones: "wave",
53 milestones: "milestones",
54 asks_act: "decision",
5455 waves: "wave",
5556 map: "map",
5657 feed: "feed",
@@ -82,6 +83,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
8283 crew: "Crew",
8384 board: "Board",
8485 forks: "Forks",
86 milestones: "Milestones",
8587 };
8688
8789 type Obj = Record<string, unknown>;
@@ -207,6 +209,17 @@ export function cardFor(
207209 if (v["truncated"] === true) rows.push({ label: "Truncated", value: "more forks exist than the tool returned; ask with a larger limit or a smaller depth" });
208210 return { kind, title: CARD_TITLE[kind], status: `${nodes.length} repositor${nodes.length === 1 ? "y" : "ies"} · from ${text(v["root"])}`, rows, actions };
209211 }
212 if (tool === "asks_act") {
213 // Only a done bulk action has a card: its receipt, and the undo that takes it back.
214 // A plan (`dryRun`) or a `listen` has no `receipt`.
215 const v = obj(value);
216 const operation = text(v["id"]);
217 if (text(v["receipt"]) === "" || operation === "") return undefined;
218 rows.push({ label: "Receipt", value: text(v["receipt"]) });
219 if (v["undoOf"] == null && v["undoneBy"] == null)
220 actions.push({ label: "Undo", tool: "asks_act", args: { action: "undo", operation } });
221 return { kind, title: "Asks decided", status: text(v["receipt"]), rows, actions };
222 }
210223 if (kind === "crew") {
211224 // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`.
212225 const v = obj(value);
@@ -378,9 +391,10 @@ export function cardFor(
378391 status = runs.length === 0 ? "nothing running" : `${runs.length} going`;
379392 for (const r of runs.slice(0, LIMIT)) {
380393 const cost = Number(r["costUsd"]) > 0 ? ` · $${Number(r["costUsd"]).toFixed(2)}` : "";
394 const since = text(r["since"]) === "" ? "" : ` · ${timeAgo(text(r["since"]))}`;
381395 rows.push({
382396 label: `${text(r["who"])} on ${text(r["app"])}`,
383 value: `${text(r["on"])} — ${text(r["state"])}${cost}`,
397 value: `${text(r["on"]) === "" ? "" : `${text(r["on"])} — `}${text(r["state"])}${since}${cost}`,
384398 ...(web(origin) && /^[^/]+\/[^/]+$/.test(text(r["app"])) && RUN_ID.test(text(r["run"]))
385399 ? { link: `${origin}/${text(r["app"])}/runs/${text(r["run"])}` }
386400 : {}),
@@ -396,12 +410,13 @@ export function cardFor(
396410 const repo = text(a["repo"]);
397411 const number = Number(a["number"]);
398412 const page = a["numberOf"] === "pull" ? "pulls" : a["numberOf"] === "issue" ? "issues" : "";
413 const at = text(a["link"]);
399414 rows.push({
400415 label: text(a["line"]),
401416 value: `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim(),
402417 ...(web(origin) && slugOf(repo) && Number.isInteger(number) && number > 0 && page !== ""
403418 ? { link: `${origin}/${repo}/${page}/${number}` }
404 : {}),
419 : web(origin) && here(origin, at) ? { link: origin + at } : {}),
405420 });
406421 }
407422 const first = asks[0];
@@ -414,6 +429,7 @@ export function cardFor(
414429 if (kindOf === "look" || kindOf === "review")
415430 actions.push({ label: "Approve", tool: "asks_act", args: { action: "ship", items } });
416431 actions.push({ label: "Decline", tool: "asks_act", args: { action: "close", items } });
432 actions.push({ label: "Dismiss", tool: "asks_act", args: { action: "dismiss", items } });
417433 const repo = text(first["repo"]);
418434 const number = Number(first["number"]);
419435 if (repo !== "" && Number.isInteger(number) && number > 0)
@@ -486,6 +502,8 @@ export function cardFor(
486502 });
487503 }
488504 } else {
505 // One milestone opened (`issue`) answers without `limbs`: plain JSON.
506 if (!Array.isArray(obj(value)["limbs"]) || "buildingNow" in obj(value)) return undefined;
489507 const limbs = list(obj(value)["limbs"]).map(obj);
490508 const repo = obj(obj(value)["repo"]);
491509 const slug = `${text(repo["owner"])}/${text(repo["name"])}`;
test/mcp-asks-now-milestones-cards.test.ts added +134
@@ -0,0 +1,134 @@
1 /** MCP `asks`, `now` and `milestones` answer with their ui:// card beside the text: URI, title, rows, rooted links. */
2 import { afterAll, beforeAll, describe, 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 { openIssue } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { newApp } from "../src/actions/apps.ts";
10 import { setAssignee } from "../src/core/issues.ts";
11 import { claimRun, createRun } from "../src/core/runs.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { CARD_URI, cardFor } from "../src/web/studio/cards.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { seedMilestones } from "./fixtures/milestones-seed.ts";
18 import { listVia } from "./mcp-list.ts";
19
20 quietLogs();
21
22 describe("MCP asks, now and milestones cards", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let token = "";
27
28 const call = async (name: string, args: Record<string, unknown> = {}) => {
29 const res = await fetch(`${servers.url}/mcp`, {
30 method: "POST",
31 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
32 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
33 });
34 return (await res.json()) as any;
35 };
36 const rooted = (origin: string, rows: { link?: string }[]) => {
37 for (const r of rows) if (r.link !== undefined) expect(r.link.startsWith(`${origin}/`) && !r.link.startsWith(`${origin}//`)).toBe(true);
38 };
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-three-cards-"));
42 app = createApp({ dataDir, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
43 servers = await serve(app);
44 const a = await createUser(app.db, { origin: "seed", trusted: true,
45 username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
46 if (!a.ok) throw new Error("users");
47 token = createToken(app.db, a.value.id, "ro", "ro").token;
48 const made0 = await newApp(app, a.value, { name: "game", description: "x", visibility: "private" });
49 if (!made0.ok) throw new Error(String(made0.error));
50 const made = { value: made0.value.repo };
51 const issue = openIssue(app, a.value, made.value, { title: "Sweep the path", body: "" });
52 if (!issue.ok) throw new Error(String(issue.error));
53 setAssignee(app.db, issue.value.id, a.value.id);
54 const run = createRun(app.db, { repoId: made.value.id, kind: "agent", issueId: issue.value.id, reason: "issue" });
55 claimRun(app.db, run.id);
56 const plan = await createRepo(app, a.value, { name: "plan", visibility: "private" });
57 if (!plan.ok) throw new Error(String(plan.error));
58 seedMilestones(app.db, plan.value.id, a.value.id, new Date());
59 }, 30_000);
60
61 afterAll(async () => {
62 servers.stop();
63 app.close();
64 await rm(dataDir, { recursive: true, force: true });
65 });
66
67 test("asks: the decision card, a row per ask with a rooted link, and actions that carry the row's items", async () => {
68 const body = await call("asks");
69 const text = JSON.parse(body.result.content[0].text);
70 const card = body.result.structuredContent.card;
71 expect(CARD_URI(card.kind)).toBe("ui://forge/decision");
72 expect(card.title).toBe("Waiting on you");
73 expect(card.rows.length).toBe(text.rows.length);
74 expect(card.rows.map((r: any) => r.label)).toEqual(text.rows.map((r: any) => r.line));
75 expect(card.rows.some((r: any) => r.link === `${servers.url}/alice/game/issues/${text.rows.find((x: any) => x.numberOf === "issue")?.number}`)).toBe(true);
76 rooted(servers.url, card.rows);
77 const act = card.actions.filter((a: any) => a.tool === "asks_act");
78 expect(act.map((a: any) => a.args.action)).toEqual(expect.arrayContaining(["close", "dismiss"]));
79 for (const a of act) expect(a.args.items).toEqual(text.rows[0].items);
80 // The tools/list entry names the card.
81 const list = await listVia(async (p) => (await fetch(`${servers.url}/mcp`, { method: "POST",
82 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
83 body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: p }) })).json());
84 const by = (n: string) => list.result.tools.find((t: any) => t.name === n)?._meta?.ui?.resourceUri;
85 expect(by("asks")).toBe("ui://forge/decision");
86 expect(by("milestones")).toBe("ui://forge/milestones");
87 });
88
89 test("asks_act: a done action's receipt carries Undo with its operation; a plan has no card", () => {
90 expect(cardFor("asks_act", { wouldDismiss: ["x"] }, "https://forge.test")).toBeUndefined();
91 const done = cardFor("asks_act", { id: "op1", receipt: "Dismissed 2 asks", undoOf: null, undoneBy: null }, "https://forge.test")!;
92 expect(done.rows).toEqual([{ label: "Receipt", value: "Dismissed 2 asks" }]);
93 expect(done.actions).toEqual([{ label: "Undo", tool: "asks_act", args: { action: "undo", operation: "op1" } }]);
94 const undone = cardFor("asks_act", { id: "op2", receipt: "Undid it", undoOf: "op1", undoneBy: null }, "")!;
95 expect(undone.actions).toEqual([]);
96 });
97
98 test("now: the run card, who on what and since when, links rooted; a planner waiting on you is the first row", async () => {
99 const body = await call("now");
100 const text = JSON.parse(body.result.content[0].text);
101 const card = body.result.structuredContent.card;
102 expect(CARD_URI(card.kind)).toBe("ui://forge/run");
103 expect(card.title).toBe("Working now");
104 expect(card.rows.length).toBe(text.length);
105 expect(card.rows[0].label).toBe("the crew on alice/game");
106 expect(card.rows[0].value).toContain("Sweep the path");
107 expect(card.rows[0].value).toContain("just now");
108 expect(card.rows[0].link).toBe(`${servers.url}/alice/game/runs/${text[0].run}`);
109 rooted(servers.url, card.rows);
110 const waiting = cardFor("now", [
111 { run: null, app: "alice/game", who: "the planner", on: "#3 Plan", state: "waiting for you", since: new Date().toISOString(), costUsd: 0 },
112 { run: "0".repeat(26), app: "alice/game", who: "smith", on: null, state: "running", since: new Date().toISOString(), costUsd: 1.5 },
113 ], "https://forge.test")!;
114 expect(waiting.rows[0]!.label).toBe("the planner on alice/game");
115 expect(waiting.rows[0]!.link).toBeUndefined();
116 expect(waiting.rows[1]!.value).toContain("$1.50");
117 expect(waiting.rows[1]!.value.startsWith(" —")).toBe(false);
118 });
119
120 test("milestones: the milestones card, done/total and a rooted link each; one milestone opened has no card", async () => {
121 const body = await call("milestones", { app: "alice/plan" });
122 const text = JSON.parse(body.result.content[0].text);
123 const card = body.result.structuredContent.card;
124 expect(CARD_URI(card.kind)).toBe("ui://forge/milestones");
125 expect(card.title).toBe("Milestones");
126 expect(card.rows.length).toBeGreaterThan(0);
127 expect(card.rows[0].label).toBe(`#${text.limbs[0].number} ${text.limbs[0].name}`);
128 expect(card.rows[0].value).toContain(`${text.limbs[0].done}/${text.limbs[0].total} done`);
129 expect(card.rows[0].link).toBe(`${servers.url}/alice/plan/milestones/${text.limbs[0].number}`);
130 rooted(servers.url, card.rows);
131 const one = await call("milestones", { app: "alice/plan", issue: text.limbs[0].number });
132 expect(one.result.structuredContent).toBeUndefined();
133 });
134 });
test/mcp.test.ts +3 −1
@@ -1296,6 +1296,7 @@ describe("the forge over MCP", () => {
12961296 "ui://forge/forks",
12971297 "ui://forge/issue",
12981298 "ui://forge/map",
1299 "ui://forge/milestones",
12991300 "ui://forge/pull",
13001301 "ui://forge/pulls",
13011302 "ui://forge/run",
@@ -1314,7 +1315,8 @@ describe("the forge over MCP", () => {
13141315 expect(meta("new_app")).toBe("ui://forge/app");
13151316 expect(meta("now")).toBe("ui://forge/run");
13161317 expect(meta("asks")).toBe("ui://forge/decision");
1317 expect(meta("milestones")).toBe("ui://forge/wave");
1318 expect(meta("milestones")).toBe("ui://forge/milestones");
1319 expect(meta("asks_act")).toBe("ui://forge/decision");
13181320 expect(meta("map")).toBe("ui://forge/map");
13191321 expect(meta("boards")).toBe("ui://forge/board");
13201322 expect(meta("feed")).toBe("ui://forge/feed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs