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

MCP `vision`: docs/VISION.md's order of work and the repository's current direction, in one answer #5860

Merged opened this

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

Merged

scout/forge:scout/5836-mcp-vision-docs-vision-md-s-order-of-wor → main 9 commits fork 6 threads · 6 unresolved

  • Merged. scout/5836-mcp-vision-docs-vision-md-s-order-of-wor was merged into main as f9f5558.

Review

Sign in to review this change.
12 files changed +167 −6
docs/MCP.md +1
@@ -134,6 +134,7 @@ for a tunnel and a forge that sleeps.
134134 | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes |
135135 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
136136 | `variant` | `{ repo, number, answer, reason? }`: like or dislike one pull among its variants (`answer` like or dislike); a later vote replaces the earlier; a dislike closes the pull with `reason` | yes |
137 | `vision` | `{ repo }`: `{ purpose, order: [{ n, name, text }], rules, direction, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) only for those who may read the repository's settings, else null | |
137138 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
138139 | `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 |
139140 | `whoami_can` | `{ kind? }`: your role and that role's row of the roles table, each ability yes or no; `kind: "passkeys"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only | |
src/core/vision.ts added +41
@@ -0,0 +1,41 @@
1 /** docs/VISION.md as data: what the forge is for, the order of work, and how it is built. */
2
3 export interface Vision {
4 readonly purpose: string | null;
5 readonly order: readonly { n: number; name: string; text: string }[];
6 readonly rules: readonly string[];
7 }
8
9 export const NO_VISION: Vision = { purpose: null, order: [], rules: [] };
10
11 const strip = (s: string): string => s.replace(/\*\*/g, "").trim();
12
13 /** Sections by their `##` heading; the lines before the first one are the preamble. */
14 function sections(md: string): { preamble: string[]; byTitle: Map<string, string[]> } {
15 const preamble: string[] = [];
16 const byTitle = new Map<string, string[]>();
17 let current = preamble;
18 for (const line of md.split("\n")) {
19 const h = /^##\s+(.+?)\s*$/.exec(line);
20 if (h) {
21 current = [];
22 byTitle.set(h[1]!.toLowerCase(), current);
23 } else current.push(line);
24 }
25 return { preamble, byTitle };
26 }
27
28 export function parseVision(md: string): Vision {
29 const { preamble, byTitle } = sections(md);
30 const purpose = preamble.filter((l) => !/^#\s/.test(l)).join("\n").trim();
31 const order: { n: number; name: string; text: string }[] = [];
32 for (const line of byTitle.get("order of work") ?? []) {
33 const m = /^(\d+)\.\s+\*\*(.+?)\*\*\s*(.*)$/.exec(line);
34 if (m) order.push({ n: Number(m[1]), name: m[2]!.replace(/\.$/, "").trim(), text: strip(m[3]!) });
35 }
36 const rules = (byTitle.get("how we build") ?? [])
37 .map((l) => /^-\s+(.+)$/.exec(l)?.[1])
38 .filter((r): r is string => r !== undefined)
39 .map(strip);
40 return { purpose: purpose || null, order, rules };
41 }
src/http/mcp/tools/audit.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "audit",
77 doc: "who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`)",
88 description:
9 "Who can do what in each app you see, from contract.json: resource × verb, yes, own or no.",
9 "Who can do what per app: resource × verb, yes, own or no.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/cloud_accounts.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "cloud_accounts",
77 doc: "the forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls; a forge admin only (counted as a write so a `ro` token never lists them)",
88 description:
9 "Cloud accounts (admin): each provider token's use, verification, write permission, calls today.",
9 "Cloud accounts (admin): each token's use and verification.",
1010 inputSchema: { type: "object", properties: {} },
1111 // A read, but marked a write so a `ro` token never lists the accounts; it
1212 // is a forge admin's and the audit rows name every vendor call.
src/http/mcp/tools/front_page.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "front_page",
77 doc: "`{ 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)",
88 description:
9 "The front page as a stranger sees it; a site admin gets and sets `pinned`, `hidden`, `weights`.",
9 "The front page as a stranger sees it; admin sets `pinned`, `hidden`, `weights`.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/index.ts +1
@@ -121,6 +121,7 @@ export { tool as trail } from "./trail.ts";
121121 export { tool as upstream } from "./upstream.ts";
122122 export { tool as usage } from "./usage.ts";
123123 export { tool as variant } from "./variant.ts";
124 export { tool as vision } from "./vision.ts";
124125 export { tool as wait_for } from "./wait_for.ts";
125126 export { tool as watch } from "./watch.ts";
126127 export { tool as whoami_can } from "./whoami_can.ts";
src/http/mcp/tools/pull.ts +1 −1
@@ -12,7 +12,7 @@ export const tool: Tool = {
1212 name: "pull",
1313 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
1414 description:
15 "Read a pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `check`.",
15 "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `check`.",

checkStale is still in the answer, so it should stay in the description. Dropping "Read" alone saves bytes without losing a field.

Low: this drops checkStale, a field the tool still returns. Keep it in the list.

1616 inputSchema: {
1717 type: "object",
1818 properties: {
src/http/mcp/tools/system.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "system",
77 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read`",
88 description:
9 "The forge's machine now: CPU, memory, disk, network, processes. Admin or telemetry:read.",
9 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read.",
1010 inputSchema: { type: "object", properties: {} },
1111 writes: false,
1212 run(ctx, user) {
src/http/mcp/tools/tell.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "tell",
99 doc: "a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick",
1010 description:
11 "Tell an app's crew what to change (first line is the title). No `repo`: kept.",
11 "Tell an app's crew what to change (first line: title). No `repo`: kept.",
outdated

This drops "answers a thought id", which is the answer a caller gets back when it sends no repo. Keep that fact; cut other words instead.

1212 inputSchema: {
1313 type: "object",
1414 properties: {
src/http/mcp/tools/vision.ts added +35
@@ -0,0 +1,35 @@
1 import { canAdmin } from "../../../core/repos.ts";
2 import { NO_VISION, parseVision } from "../../../core/vision.ts";
3 import { blobForRender } from "../../../git/read.ts";
4 import { runGit } from "../../../git/env.ts";
5 import { repoDir } from "../../../git/repos.ts";
6 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
7
8 export const tool: Tool = {
9 name: "vision",
10 doc: "`{ repo }`: `{ purpose, order: [{ n, name, text }], rules, direction, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) only for those who may read the repository's settings, else null",
11 description:
12 "VISION.md: purpose, order, rules.",
13 inputSchema: {
14 type: "object",
15 properties: { repo: REPO_ARG },
16 required: ["repo"],
17 },
18 writes: false,
19 async run(ctx, user, args) {
20 const repo = repoArg(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
23 const blob = await blobForRender(dir, repo.defaultBranch, "docs/VISION.md", 1_000_000);
24 const found = blob.kind === "text";
25 const vision = found ? parseVision(new TextDecoder().decode(blob.body)) : NO_VISION;
26 const last = found
27 ? await runGit(["log", "-1", "--format=%cI", repo.defaultBranch, "--", "docs/VISION.md"], { cwd: dir })
28 : undefined;
29 return good({
30 ...vision,
31 direction: canAdmin(ctx.app.db, repo, user) ? repo.autonomyDirection : null,
32 updatedAt: last?.ok && last.value.trim() ? last.value.trim() : null,
33 });
34 },
35 };
test/mcp-vision.test.ts added +82
@@ -0,0 +1,82 @@
1 /** MCP `vision`: docs/VISION.md parsed; none is no error; the direction only for who may read settings. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, readFile, 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 { commitToBranch } from "../src/actions/repos.ts";
8 import { createRepo } from "../src/core/repos.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 const tok: Record<string, string> = {};
20
21 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
22 if (!r.ok) throw new Error(JSON.stringify(r.error));
23 return r.value;
24 };
25
26 async function vision(token: string, repo: string): Promise<any> {
27 const res = await fetch(`${servers.url}/mcp`, {
28 method: "POST",
29 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
30 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "vision", arguments: { repo } } }),
31 });
32 const body = (await res.json()) as any;
33 return JSON.parse(body.result.content[0].text);
34 }
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-vision-mcp-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 const make = (username: string) =>
41 createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" });
42 const owner = ok(await make("ada"));
43 const reader = ok(await make("rita"));
44 const text = await readFile(join(import.meta.dir, "..", "docs", "VISION.md"), "utf8");

This reads the repository's live docs/VISION.md. Any edit to the vision would fail this suite (8 items, 4 rules, exact names) in a pull that never touched the parser. Write a small fixture inline instead, and add cases for a missing "Order of work" section and a bold name without the trailing period.

45 const withDoc = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "with", visibility: "public" }));
46 ok(await commitToBranch(app, owner, withDoc, { message: "vision", files: [{ path: "docs/VISION.md", content: text }] }));
47 const without = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "without", visibility: "public" }));
48 ok(await commitToBranch(app, owner, without, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }));
49 app.db.query("UPDATE repo SET autonomy_direction = 'ship the feed' WHERE id = ?1").run(withDoc.id);
50 tok["owner"] = createToken(app.db, owner.id, "rw", "rw").token;
51 tok["reader"] = createToken(app.db, reader.id, "rw", "rw").token;
52 }, 30_000);
53
54 afterAll(async () => {
55 servers?.stop();
56 app?.close();
57 await rm(dataDir, { recursive: true, force: true });
58 });
59
60 test("this repository's VISION.md is parsed", async () => {
61 const v = await vision(tok["owner"]!, "ada/with");
62 expect(v.purpose).toStartWith("The forge is a social place");
63 expect(v.order.map((o: any) => o.n)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);

Non-blocking: this test reads the live docs/VISION.md and checks exact counts (8 order items, 4 rules) and exact wording, so any future edit to VISION.md will fail an MCP test. The issue does ask for a test against this repository's document, so it can stay. A sturdier version would check the shape (n values consecutive from 0, every name non-empty, rules.length > 0) and pin exact values only against a small fixture document.

64 expect(v.order[0].name).toBe("Trust, always first");
65 expect(v.order[0].text).toStartWith("Agents are contained.");
66 expect(v.order[1].name).toBe("One front door: the feed");
67 expect(v.rules.length).toBe(4);
68 expect(v.rules[2]).toStartWith("Done means reachable");
69 expect(v.direction).toBe("ship the feed");
70 expect(v.updatedAt).toMatch(/^\d{4}-\d\d-\d\dT/);
71 });
72
73 test("a repository without the document answers empty, not an error", async () => {
74 const v = await vision(tok["owner"]!, "ada/without");
75 expect(v).toMatchObject({ purpose: null, order: [], rules: [], updatedAt: null });
76 });
77
78 test("a reader who may not see settings gets no direction", async () => {
79 const v = await vision(tok["reader"]!, "ada/with");
80 expect(v.direction).toBeNull();
81 expect(v.order.length).toBe(8);
82 });
test/mcp.test.ts +1
@@ -289,6 +289,7 @@ describe("the forge over MCP", () => {
289289 "ssh_keys",
290290 "email_verify",
291291 "parent_unlink",
292 "vision",
292293 "boards",
293294 "board_write",
294295 "board_pins",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs