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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5833-a-vision-thought-opens-a-small-pull-to-d → main 1 commit fork
7 files
changed +240
−5
src/core/placements.ts +3 −1
| @@ -9,7 +9,7 @@ import type { Database } from "bun:sqlite"; | ||
| 9 | 9 | export const KINDS = ["work", "amendment", "vision", "decision", "reversal"] as const; |
| 10 | 10 | export type Kind = (typeof KINDS)[number]; |
| 11 | 11 | |
| 12 | export type Action = "opened" | "amended" | "deferred" | "superseded" | "linked"; | |
| 12 | export type Action = "opened" | "amended" | "deferred" | "superseded" | "linked" | "proposed"; | |
| 13 | 13 | |
| 14 | 14 | /** One thing done on the map: an issue of `repo` (owner/name), and the pillar it serves. */ |
| 15 | 15 | export interface Placed { |
| @@ -17,6 +17,8 @@ export interface Placed { | ||
| 17 | 17 | repo: string; |
| 18 | 18 | number: number; |
| 19 | 19 | 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; | |
| 20 | 22 | } |
| 21 | 23 | |
| 22 | 24 | export interface Placement { |
src/core/thought_stream.ts +1 −1
| @@ -193,7 +193,7 @@ export function streamJson(t: StreamThought) { | ||
| 193 | 193 | placedAt: t.placedAt, |
| 194 | 194 | placed: t.placed.map((x) => |
| 195 | 195 | 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 }) } | |
| 197 | 197 | : { action: x.action, repo: null, number: null, title: null }, |
| 198 | 198 | ), |
| 199 | 199 | state: t.state, |
src/crew/triage.ts +9
| @@ -35,6 +35,7 @@ import { autonomyStatus, ensureDirectionIssue } from "./autonomy.ts"; | ||
| 35 | 35 | import { certain, decideOr, type Answers, type Questions } from "./decide.ts"; |
| 36 | 36 | import { linkThoughts, raiseIssue } from "../core/thought_links.ts"; |
| 37 | 37 | import { crewOffInPreview } from "./preview.ts"; |
| 38 | import { proposeVision } from "./vision.ts"; | |
| 38 | 39 | |
| 39 | 40 | const log = createLog("triage"); |
| 40 | 41 | |
| @@ -345,6 +346,14 @@ export async function placeThought( | ||
| 345 | 346 | comment(direction, kind === "vision" ? "The direction changes:" : "A decision:"); |
| 346 | 347 | placed.push({ action: "amended", repo: slug, number: direction.number }); |
| 347 | 348 | 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 | } | |
| 348 | 357 | } |
| 349 | 358 | if (say.length === 0) say.push("Kept; nothing on the map changed."); |
| 350 | 359 | 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 { | ||
| 65 | 65 | ? html`${x.action} in a repository you cannot read` |
| 66 | 66 | : x.title === null |
| 67 | 67 | ? 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>`, | |
| 69 | 69 | ); |
| 70 | 70 | return links.length === 0 |
| 71 | 71 | ? 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 | ||
| 125 | 125 | |
| 126 | 126 | /** The planner's answer under a thought: one or two lines, `#N` linking to the issue it names. */ |
| 127 | 127 | 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"}`])); | |
| 129 | 129 | const parts = placement.reply.split(/(#\d+)/).map((part) => { |
| 130 | 130 | 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>`; | |
| 132 | 132 | }); |
| 133 | 133 | return html`<p class="small break" data-placement="${placement.kind}"><span class="muted">Planner · ${placement.kind}</span> ${parts}</p>`; |
| 134 | 134 | } |
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 | }); |
new above
offline