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

A vision thought opens a small pull to docs/VISION.md, linked under the thought, on /asked and in MCP `thought` #5910

Merged opened this

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

Merged

scout/forge:scout/5833-a-vision-thought-opens-a-small-pull-to-d → main 1 commit fork

  • Merged. scout/5833-a-vision-thought-opens-a-small-pull-to-d was merged into main as 19b0258.

Review

Sign in to review this change.
7 files changed +240 −5
src/core/placements.ts +3 −1
@@ -9,7 +9,7 @@ import type { Database } from "bun:sqlite";
99 export const KINDS = ["work", "amendment", "vision", "decision", "reversal"] as const;
1010 export type Kind = (typeof KINDS)[number];
1111
12 export type Action = "opened" | "amended" | "deferred" | "superseded" | "linked";
12 export type Action = "opened" | "amended" | "deferred" | "superseded" | "linked" | "proposed";
1313
1414 /** One thing done on the map: an issue of `repo` (owner/name), and the pillar it serves. */
1515 export interface Placed {
@@ -17,6 +17,8 @@ export interface Placed {
1717 repo: string;
1818 number: number;
1919 pillar?: string;
20 /** For `proposed`: `number` is a pull of `repo`, and this is its number again. It marks a vision pull. */
21 pull?: number;
2022 }
2123
2224 export interface Placement {
src/core/thought_stream.ts +1 −1
@@ -193,7 +193,7 @@ export function streamJson(t: StreamThought) {
193193 placedAt: t.placedAt,
194194 placed: t.placed.map((x) =>
195195 x.readable
196 ? { action: x.action, repo: x.repo, number: x.number, title: x.title }
196 ? { action: x.action, repo: x.repo, number: x.number, title: x.title, ...(x.pull === undefined ? {} : { pull: x.pull }) }
197197 : { action: x.action, repo: null, number: null, title: null },
198198 ),
199199 state: t.state,
src/crew/triage.ts +9
@@ -35,6 +35,7 @@ import { autonomyStatus, ensureDirectionIssue } from "./autonomy.ts";
3535 import { certain, decideOr, type Answers, type Questions } from "./decide.ts";
3636 import { linkThoughts, raiseIssue } from "../core/thought_links.ts";
3737 import { crewOffInPreview } from "./preview.ts";
38 import { proposeVision } from "./vision.ts";
3839
3940 const log = createLog("triage");
4041
@@ -345,6 +346,14 @@ export async function placeThought(
345346 comment(direction, kind === "vision" ? "The direction changes:" : "A decision:");
346347 placed.push({ action: "amended", repo: slug, number: direction.number });
347348 say.push(`${kind === "vision" ? "Noted as direction" : "Recorded as a decision"} on #${direction.number}.`);
349 if (kind === "vision") {
350 const proposed = await proposeVision(app, by, repo, thought, now);
351 if (proposed.ok) {
352 const n = proposed.pull.number;
353 placed.push({ action: "proposed", repo: slug, number: n, pull: n });
354 say.push(`Proposed the edit to docs/VISION.md as #${n}; you land it.`);
355 } else log.warn("could not propose the vision edit", { thought: thought.id, why: proposed.why });
356 }
348357 }
349358 if (say.length === 0) say.push("Kept; nothing on the map changed.");
350359 return finish(app, thought, kind, decided.by, question !== "", placed, [say.join(" "), question].filter(Boolean).join(" "), now);
src/crew/vision.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * A vision thought proposes its own edit (#5833): a small pull to
3 * docs/VISION.md adding one dated line under `Changes of direction`. It only
4 * proposes; a person lands it, and landing it changes no setting. The pull is
5 * recorded on the placement (`placed[].pull`, action `proposed`), which is what
6 * marks it as a vision pull, never its branch name.
7 */
8
9 import type { App } from "../app.ts";
10 import { editFiles } from "../actions/contents.ts";
11 import type { Pull } from "../core/pulls.ts";
12 import type { Repo } from "../core/repos.ts";
13 import type { User } from "../core/users.ts";
14 import { listTree, readerFor } from "../git/read.ts";
15 import { repoDir } from "../git/repos.ts";
16
17 export const VISION_PATH = "docs/VISION.md";
18 export const SECTION = "Changes of direction";
19 const LINE_SHOWN = 240;
20
21 /** The line a thought adds: one flat, dated bullet that carries the thought's id. */
22 export function directionLine(text: string, id: string, now: Date): string {
23 const flat = text.replace(/\s+/g, " ").trim();
24 const said = flat.length > LINE_SHOWN ? `${flat.slice(0, LINE_SHOWN - 1)}…` : flat;
25 return `- ${now.toISOString().slice(0, 10)}: ${said} (thought \`${id}\`)`;
26 }
27
28 /** `current` with `line` last under the `Changes of direction` section, made when missing. `current` null means no file. */
29 export function withDirection(current: string | null, line: string): string {
30 if (current === null) return `# Vision\n\n## ${SECTION}\n${line}\n`;
31 const lines = current.replace(/\n+$/, "").split("\n");
32 const at = lines.findIndex((l) => /^#{1,6}\s+/.test(l) && l.replace(/^#+\s+/, "").trim().toLowerCase() === SECTION.toLowerCase());
33 if (at < 0) return `${lines.join("\n")}\n\n## ${SECTION}\n${line}\n`;
34 const level = /^#+/.exec(lines[at]!)![0].length;
35 let end = lines.length;
36 for (let i = at + 1; i < lines.length; i++) {
37 const h = /^(#+)\s/.exec(lines[i]!);
38 if (h && h[1]!.length <= level) {
39 end = i;
40 break;
41 }
42 }
43 const next = end;
44 while (end > at + 1 && lines[end - 1]!.trim() === "") end--;
45 const tail = next < lines.length ? ["", ...lines.slice(next)] : [];
46 return `${[...lines.slice(0, end), line, ...tail].join("\n")}\n`;
47 }
48
49 async function readVision(app: App, repo: Repo): Promise<string | null> {
50 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
51 const tree = await listTree(dir, repo.defaultBranch, "docs");
52 const entry = tree.ok ? tree.value.find((e) => e.type === "blob" && e.name === "VISION.md") : undefined;
53 if (!entry) return null;
54 const obj = await readerFor(dir).object(entry.oid);
55 return obj.ok ? new TextDecoder().decode(obj.value.body) : null;
56 }
57
58 /** Open the pull for a vision thought, or say why not. */
59 export async function proposeVision(
60 app: App,
61 by: User,
62 repo: Repo,
63 thought: { id: string; text: string },
64 now = new Date(),
65 ): Promise<{ ok: true; pull: Pull } | { ok: false; why: string }> {
66 const line = directionLine(thought.text, thought.id, now);
67 const quoted = thought.text.split("\n").map((l) => `> ${l}`).join("\n");
68 const edited = await editFiles(app, by, repo, {
69 newBranch: `vision-${thought.id.toLowerCase().replace(/[^a-z0-9]/g, "")}`,
70 message: "Note a change of direction",
71 files: [{ path: VISION_PATH, content: withDirection(await readVision(app, repo), line) }],
72 openPull: {
73 title: "Note a change of direction in docs/VISION.md",
74 body: `Adds one line under \`${SECTION}\`. Landing it changes no setting.\n\nFrom thought \`${thought.id}\`:\n\n${quoted}`,
75 },
76 });
77 if (!edited.ok || !edited.value.pull) return { ok: false, why: "the edit could not be opened" };
78 return { ok: true, pull: edited.value.pull };
79 }
src/web/studio/asked_page.ts +1 −1
@@ -65,7 +65,7 @@ function placedLinks(t: StreamThought): Html {
6565 ? html`${x.action} in a repository you cannot read`
6666 : x.title === null
6767 ? html`${x.action} ${x.repo} #${x.number}`
68 : html`${x.action} <a href="/${x.repo}/issues/${x.number}">${x.repo} #${x.number}</a>`,
68 : html`${x.action} <a href="/${x.repo}/${x.pull === x.number ? "pulls" : "issues"}/${x.number}">${x.repo} #${x.number}</a>`,
6969 );
7070 return links.length === 0
7171 ? html`${t.reply ?? NOT_PLACED_YET}`
src/web/studio/feed.ts +2 −2
@@ -125,10 +125,10 @@ export function thoughtCard(thought: Thought, who: string, placement?: Placement
125125
126126 /** The planner's answer under a thought: one or two lines, `#N` linking to the issue it names. */
127127 export function replyUnder(placement: Placement): Html {
128 const where = new Map(placement.placed.map((p) => [p.number, p.repo]));
128 const where = new Map(placement.placed.map((p) => [p.number, `${p.repo}/${p.pull === p.number ? "pulls" : "issues"}`]));
129129 const parts = placement.reply.split(/(#\d+)/).map((part) => {
130130 const repo = /^#(\d+)$/.test(part) ? where.get(Number(part.slice(1))) : undefined;
131 return repo === undefined ? part : html`<a href="/${repo}/issues/${part.slice(1)}">${part}</a>`;
131 return repo === undefined ? part : html`<a href="/${repo}/${part.slice(1)}">${part}</a>`;
132132 });
133133 return html`<p class="small break" data-placement="${placement.kind}"><span class="muted">Planner · ${placement.kind}</span> ${parts}</p>`;
134134 }
test/vision-pull.test.ts added +145
@@ -0,0 +1,145 @@
1 /**
2 * A vision thought opens a small pull to docs/VISION.md (#5833): one dated
3 * line under `Changes of direction`, the pull recorded on the placement, and
4 * linked under the thought, on /asked and in MCP `thought`.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { editFiles } from "../src/actions/contents.ts";
12 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
13 import { keepThought } from "../src/actions/thought.ts";
14 import { getRepoById, type Repo } from "../src/core/repos.ts";
15 import { getIssue } from "../src/core/issues.ts";
16 import { placementJson, placementOf } from "../src/core/placements.ts";
17 import { thoughtStream } from "../src/core/thought_stream.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { placeThought } from "../src/crew/triage.ts";
20 import { directionLine, withDirection } from "../src/crew/vision.ts";
21 import type { Decider } from "../src/crew/decide.ts";
22 import { listTree, readerFor } from "../src/git/read.ts";
23 import { repoDir } from "../src/git/repos.ts";
24 import { replyUnder } from "../src/web/studio/feed.ts";
25 import { thoughtRow } from "../src/web/studio/asked_page.ts";
26
27 const visionDecider: Decider = {
28 name: "stub",
29 async decide(_s, questions) {
30 const pick = (key: string, choice: string) => ({
31 choice,
32 p: 0.9,
33 probabilities: Object.fromEntries(questions[key]!.options.map((o) => [o, o === choice ? 0.9 : 0])),
34 });
35 const out: Record<string, ReturnType<typeof pick>> = { kind: pick("kind", "vision") };
36 if (questions["target"]) out["target"] = pick("target", "none");
37 if (questions["pillar"]) out["pillar"] = pick("pillar", questions["pillar"].options[0]!);
38 return out as never;
39 },
40 };
41
42 describe("withDirection", () => {
43 const line = "- 2026-10-25: go (thought `t1`)";
44 test("a missing file is made", () => {
45 expect(withDirection(null, line)).toBe(`# Vision\n\n## Changes of direction\n${line}\n`);
46 });
47 test("a missing section is added at the end", () => {
48 expect(withDirection("# V\n\ntext\n", line)).toBe(`# V\n\ntext\n\n## Changes of direction\n${line}\n`);
49 });
50 test("an existing section gets the line last, before the next heading", () => {
51 const got = withDirection("# V\n\n## Changes of direction\n- old\n\n## Other\nx\n", line);
52 expect(got).toBe(`# V\n\n## Changes of direction\n- old\n${line}\n\n## Other\nx\n`);
53 });
54 test("the line is flat, dated and carries the id", () => {
55 expect(directionLine("a\n\nb", "abc", new Date("2026-01-02T00:00:00Z"))).toBe("- 2026-01-02: a b (thought `abc`)");
56 });
57 });
58
59 describe("a vision thought", () => {
60 let dataDir: string;
61 let app: App;
62 let alice: User;
63
64 beforeAll(async () => {
65 dataDir = await mkdtemp(join(tmpdir(), "forge-vision-"));
66 app = createApp({ dataDir, port: 0 });
67 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
68 if (!made.ok) throw new Error(String(made.error));
69 alice = made.value;
70 app.config.decider = visionDecider;
71 });
72 afterAll(async () => {
73 app.close();
74 await rm(dataDir, { recursive: true, force: true });
75 });
76
77 async function repoWith(name: string, files: { path: string; content: string }[]): Promise<Repo> {
78 const r = await createRepo(app, alice, { name, visibility: "private" });
79 if (!r.ok) throw new Error(String(r.error));
80 await updateRepoSettings(app, alice, r.value, { autonomy: "autonomous", autonomyDirection: "x", autonomyCeilingUsd: 10, autonomySlots: 1, autonomyBy: alice.id });
81 const repo = getRepoById(app.db, r.value.id)!;
82 const e = await editFiles(app, alice, repo, { files });
83 if (!e.ok) throw new Error(JSON.stringify(e.error));
84 return getRepoById(app.db, repo.id)!;
85 }
86
87 const fileOn = async (repo: Repo, branch: string, dir: string, name: string): Promise<string> => {
88 const d = repoDir(app.reposRoot, repo.ownerName, repo.name);
89 const tree = await listTree(d, branch, dir);
90 const e = tree.ok ? tree.value.find((x) => x.name === name) : undefined;
91 if (!e) return "";
92 const o = await readerFor(d).object(e.oid);
93 return o.ok ? new TextDecoder().decode(o.value.body) : "";
94 };
95
96 async function think(repo: Repo, text: string) {
97 const kept = keepThought(app, alice, text, "api");
98 if (!kept.ok) throw new Error(kept.error);
99 const p = await placeThought(app, repo, alice, { id: kept.value.id, text });
100 return { id: kept.value.id, text, p };
101 }
102
103 for (const [label, files] of [
104 ["with docs/VISION.md", [{ path: "docs/VISION.md", content: "# Vision\n\nWhy.\n" }]],
105 ["without docs/VISION.md", [{ path: "README.md", content: "hi\n" }]],
106 ] as const) {
107 test(`opens the pull ${label}, and the three links appear`, async () => {
108 const repo = await repoWith(label.includes("without") ? "bare" : "with", [...files]);
109 const t = await think(repo, "vision: the forge steers itself");
110 const slug = `${repo.ownerName}/${repo.name}`;
111 const proposed = t.p.placed.find((x) => x.action === "proposed")!;
112 expect(proposed.pull).toBe(proposed.number);
113 expect(placementOf(app.db, t.id)!.placed.some((x) => x.pull === proposed.pull)).toBe(true);
114
115 const pull = getIssue(app.db, repo.id, proposed.number)!;
116 expect(pull.kind).toBe("pull");
117 expect(pull.body).toContain(t.id);
118 expect(pull.body).toContain("> vision: the forge steers itself");
119 expect(pull.state).toBe("open");
120
121 // The default branch is untouched; the branch carries the one line.
122 expect(await fileOn(repo, repo.defaultBranch, "docs", "VISION.md")).not.toContain("Changes of direction");
123 const edited = await fileOn(repo, `vision-${t.id.toLowerCase().replace(/[^a-z0-9]/g, "")}`, "docs", "VISION.md");
124 expect(edited).toContain("## Changes of direction\n- ");
125 expect(edited).toContain(t.id);
126
127 // Under the thought, on /asked, and in MCP `thought`.
128 const href = `/${slug}/pulls/${proposed.number}`;
129 expect(String(replyUnder(t.p))).toContain(href);
130 const row = thoughtStream(app.db, alice).find((x) => x.id === t.id)!;
131 expect(String(thoughtRow(row, 3))).toContain(href);
132 const mcp = placementJson(placementOf(app.db, t.id)) as { placed: { action: string; pull?: number }[] };
133 expect(mcp.placed.find((x) => x.action === "proposed")?.pull).toBe(proposed.number);
134 });
135 }
136
137 test("a repository with no commits still places the thought, without a pull", async () => {
138 const r = await createRepo(app, alice, { name: "empty", visibility: "private" });
139 if (!r.ok) throw new Error(String(r.error));
140 await updateRepoSettings(app, alice, r.value, { autonomy: "autonomous", autonomyDirection: "x", autonomyCeilingUsd: 10, autonomySlots: 1, autonomyBy: alice.id });
141 const t = await think(getRepoById(app.db, r.value.id)!, "vision: smaller");
142 expect(t.p.kind).toBe("vision");
143 expect(t.p.placed.some((x) => x.action === "proposed")).toBe(false);
144 });
145 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs