Public: anyone can read this; the crew can change it.
A vision pull shows the direction it will write, and Settings marks which part follows the vision and which is the owner's #6260
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5835-a-vision-pull-shows-the-direction-it-wil → main 3 commits fork 2 threads · 2 unresolved
11 files
changed +246
−11
README.md +5
| @@ -2316,3 +2316,8 @@ ledger (`spawn/sizes.ts`). **Unset means no quota.** A spawn that would pass a | ||
| 2316 | 2316 | declared limit is a 409 `refused` with one sentence naming it, and the New |
| 2317 | 2317 | forge page shows that sentence above the form when even a small forge would |
| 2318 | 2318 | not fit (`spawn/quota.ts`). |
| 2319 | ||
| 2320 | ### Seeing the direction a vision pull will write | |
| 2321 | An open vision pull's page shows, to an admin of the repository, "Direction after the merge": today's direction beside the one an admin's merge will write, computed by the same `nextDirection` the merge uses (`src/crew/vision.ts`), so the two cannot differ. Repository settings (Crew) marks the direction's fenced block `follows docs/VISION.md` and the words around it `the owner's`. Today and After the merge stack vertically at every width. | |
| 2322 | ||
| 2323 | The same read is a second door: the MCP `vision` tool takes an optional `number`, and for an admin returns `preview` (`{ today, after }`, or `{ why }` the direction would stay) for that open vision pull, plus `directionParts` (the direction cut into `vision` and `owner` parts). The terminal (TUI) has no such view; it is not touched by this change. |
docs/MCP.md +1 −1
| @@ -136,7 +136,7 @@ for a tunnel and a forge that sleeps. | ||
| 136 | 136 | | `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 | |
| 137 | 137 | | `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 | | |
| 138 | 138 | | `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 | |
| 139 | | `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 | | | |
| 139 | | `vision` | `{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null | | | |
| 140 | 140 | | `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 | |
| 141 | 141 | | `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 | |
| 142 | 142 | | `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 | | |
docs/design/areas/direction.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Direction | |
| 2 | ||
| 3 | The repository's direction drawn with its two authors told apart (`src/web/studio/direction.ts`), on Settings → Crew and | |
| 4 | on an open vision pull's page. | |
| 5 | ||
| 6 | - `.direction-text` — one part of the direction as plain wrapped text (`white-space: pre-wrap`, `overflow-wrap: anywhere` | |
| 7 | so a long unbroken line never scrolls a 360px page sideways). A part is badged `follows docs/VISION.md` (the fenced | |
| 8 | block) or `the owner's`; the stacked "Today" and "After the merge" blocks of the pull's card are `[data-direction]` | |
| 9 | hooks, each part `[data-direction-part]`, used by tests only. |
src/crew/vision.ts +50 −4
| @@ -12,7 +12,7 @@ import { parseVision, type Vision } from "../core/vision.ts"; | ||
| 12 | 12 | import { canAdmin, getRepoById, updateRepoSettings } from "../core/repos.ts"; |
| 13 | 13 | import { createLog } from "../kernel/log.ts"; |
| 14 | 14 | import { editFiles } from "../actions/contents.ts"; |
| 15 | import type { Pull } from "../core/pulls.ts"; | |
| 15 | import { pullDir, type Pull } from "../core/pulls.ts"; | |
| 16 | 16 | import type { Repo } from "../core/repos.ts"; |
| 17 | 17 | import type { User } from "../core/users.ts"; |
| 18 | 18 | import { listTree, readerFor } from "../git/read.ts"; |
| @@ -53,8 +53,11 @@ export function withDirection(current: string | null, line: string): string { | ||
| 53 | 53 | } |
| 54 | 54 | |
| 55 | 55 | async function readVision(app: App, repo: Repo): Promise<string | null> { |
| 56 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 57 | const tree = await listTree(dir, repo.defaultBranch, "docs"); | |
| 56 | return readVisionAt(repoDir(app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch); | |
| 57 | } | |
| 58 | ||
| 59 | async function readVisionAt(dir: string, rev: string): Promise<string | null> { | |
| 60 | const tree = await listTree(dir, rev, "docs"); | |
| 58 | 61 | const entry = tree.ok ? tree.value.find((e) => e.type === "blob" && e.name === "VISION.md") : undefined; |
| 59 | 62 | if (!entry) return null; |
| 60 | 63 | const obj = await readerFor(dir).object(entry.oid); |
| @@ -135,6 +138,49 @@ export function withVisionBlock( | ||
| 135 | 138 | return block === null ? { ok: false, why: "the owner's words leave no room for the block" } : { ok: true, text: `${before}${block}${after}` }; |
| 136 | 139 | } |
| 137 | 140 | |
| 141 | /** What the direction becomes when `md` is the landed VISION.md: the one function both the merge and the preview call. */ | |
| 142 | export function nextDirection(direction: string, md: string): ReturnType<typeof withVisionBlock> { | |
| 143 | return withVisionBlock(direction, parseVision(md)); | |
| 144 | } | |
| 145 | ||
| 146 | export interface DirectionPart { | |
| 147 | readonly kind: "vision" | "owner"; | |
| 148 | readonly text: string; | |
| 149 | } | |
| 150 | ||
| 151 | /** The direction cut at its fenced block: the owner's words and the part that follows docs/VISION.md. Odd markers make it all the owner's. */ | |
| 152 | export function directionParts(direction: string): DirectionPart[] { | |
| 153 | const from = direction.indexOf(BLOCK_START); | |
| 154 | const to = direction.indexOf(BLOCK_END); | |
| 155 | const paired = count(direction, BLOCK_START) === 1 && count(direction, BLOCK_END) === 1 && to > from; | |
| 156 | const raw: DirectionPart[] = paired | |
| 157 | ? [ | |
| 158 | { kind: "owner", text: direction.slice(0, from) }, | |
| 159 | { kind: "vision", text: direction.slice(from, to + BLOCK_END.length) }, | |
| 160 | { kind: "owner", text: direction.slice(to + BLOCK_END.length) }, | |
| 161 | ] | |
| 162 | : [{ kind: "owner", text: direction }]; | |
| 163 | return raw.filter((p) => p.text.trim() !== "").map((p) => ({ kind: p.kind, text: p.text.trim() })); | |
| 164 | } | |
| 165 | ||
| 166 | export type VisionPreview = | |
| 167 | | { readonly ok: true; readonly today: string; readonly after: string } | |
| 168 | | { readonly ok: false; readonly why: string }; | |
| 169 | ||
| 170 | /** | |
| 171 | * For an open vision pull: the direction as it stands and as the merge of | |
| 172 | * this pull (by an admin) will write it, from the VISION.md on its head. | |
| 173 | * Null for any other pull. | |
| 174 | */ | |
| 175 | export async function previewVision(app: App, repo: Repo, pull: Pull): Promise<VisionPreview | null> { | |
| 176 | if (pull.state !== "open" || pull.mergedAt !== null) return null; | |
| 177 | if (!isVisionPull(app.db, `${repo.ownerName}/${repo.name}`, pull.number)) return null; | |
| 178 | const md = await readVisionAt(await pullDir(app.db, app.reposRoot, repo, pull), pull.headOid); | |
| 179 | if (md === null) return { ok: false, why: "this pull has no docs/VISION.md" }; | |
| 180 | const next = nextDirection(repo.autonomyDirection, md); | |
| 181 | return next.ok ? { ok: true, today: repo.autonomyDirection, after: next.text } : { ok: false, why: next.why }; | |
| 182 | } | |
| 183 | ||
| 138 | 184 | /** |
| 139 | 185 | * After a pull lands: when it is a vision pull (its placement says so, never |
| 140 | 186 | * its branch name) and the merger administers the repository, regenerate the |
| @@ -154,7 +200,7 @@ export async function landVision(app: App, merger: User, repo: Repo, pull: Pull) | ||
| 154 | 200 | const fresh = getRepoById(app.db, repo.id) ?? repo; |
| 155 | 201 | const md = await readVision(app, fresh); |
| 156 | 202 | if (md === null) return; |
| 157 | const next = withVisionBlock(fresh.autonomyDirection, parseVision(md)); | |
| 203 | const next = nextDirection(fresh.autonomyDirection, md); | |
| 158 | 204 | if (!next.ok) { |
| 159 | 205 | log.warn("left the direction unchanged", { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number, why: next.why }); |
| 160 | 206 | return; |
src/http/mcp/tools/vision.ts +11 −3
| @@ -1,3 +1,5 @@ | ||
| 1 | import { getPull } from "../../../core/pulls.ts"; | |
| 2 | import { directionParts, previewVision } from "../../../crew/vision.ts"; | |
| 1 | 3 | import { canAdmin } from "../../../core/repos.ts"; |
| 2 | 4 | import { NO_VISION, parseVision } from "../../../core/vision.ts"; |
| 3 | 5 | import { blobForRender } from "../../../git/read.ts"; |
| @@ -7,12 +9,12 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | ||
| 7 | 9 | |
| 8 | 10 | export const tool: Tool = { |
| 9 | 11 | 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", | |
| 12 | doc: "`{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null", | |
| 11 | 13 | description: |
| 12 | 14 | "VISION.md: purpose, order, rules.", |
| 13 | 15 | inputSchema: { |
| 14 | 16 | type: "object", |
| 15 | properties: { repo: REPO_ARG }, | |
| 17 | properties: { repo: REPO_ARG, number: { type: "integer", minimum: 1 } }, | |
| 16 | 18 | required: ["repo"], |
| 17 | 19 | }, |
| 18 | 20 | writes: false, |
| @@ -26,9 +28,15 @@ export const tool: Tool = { | ||
| 26 | 28 | const last = found |
| 27 | 29 | ? await runGit(["log", "-1", "--format=%cI", repo.defaultBranch, "--", "docs/VISION.md"], { cwd: dir }) |
| 28 | 30 | : undefined; |
| 31 | const admin = canAdmin(ctx.app.db, repo, user); | |
| 32 | const n = args["number"]; | |
| 33 | const pull = admin && typeof n === "number" && Number.isInteger(n) && n > 0 ? getPull(ctx.app.db, repo.id, n) : undefined; | |
| 34 | const shown = pull ? await previewVision(ctx.app, repo, pull) : null; | |
| 29 | 35 | return good({ |
| 30 | 36 | ...vision, |
| 31 | direction: canAdmin(ctx.app.db, repo, user) ? repo.autonomyDirection : null, | |
| 37 | direction: admin ? repo.autonomyDirection : null, | |
| 38 | directionParts: admin ? directionParts(repo.autonomyDirection) : null, | |
| 39 | preview: shown === null ? null : shown.ok ? { today: shown.today, after: shown.after } : { why: shown.why }, | |
| 32 | 40 | updatedAt: last?.ok && last.value.trim() ? last.value.trim() : null, |
| 33 | 41 | }); |
| 34 | 42 | }, |
src/web/studio/change.ts +8 −1
| @@ -29,7 +29,7 @@ import { | ||
| 29 | 29 | type Pull, |
| 30 | 30 | pullDir, |
| 31 | 31 | } from "../../core/pulls.ts"; |
| 32 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 32 | import { canAdmin, canWrite, type Repo } from "../../core/repos.ts"; | |
| 33 | 33 | import { listReviews, type Review } from "../../core/reviews.ts"; |
| 34 | 34 | import { wallOf, wallSentence, type Wall } from "../../contract/policy.ts"; |
| 35 | 35 | import { checkSummary } from "../../core/runs.ts"; |
| @@ -84,6 +84,8 @@ import { | ||
| 84 | 84 | type Decision, |
| 85 | 85 | } from "./asks.ts"; |
| 86 | 86 | import { tellLine, tellsAnswered } from "./branch_tell.ts"; |
| 87 | import { previewVision } from "../../crew/vision.ts"; | |
| 88 | import { visionPreviewCard } from "./direction.ts"; | |
| 87 | 89 | import { holdCard } from "./hold.ts"; |
| 88 | 90 | import { pullMayRun } from "../../core/pull_trust.ts"; |
| 89 | 91 | import { changeHref } from "./nouns.ts"; |
| @@ -532,6 +534,10 @@ async function changeConversation( | ||
| 532 | 534 | : ownNotReadyNote !== undefined |
| 533 | 535 | ? html`<p class="muted small">Not ready to ship — ${ownNotReadyNote}.</p>` |
| 534 | 536 | : undefined; |
| 537 | const visionPreview = | |
| 538 | ctx.auth.user !== undefined && canAdmin(db, repo, ctx.auth.user) | |
| 539 | ? await previewVision(ctx.app, repo, pull) | |
| 540 | : null; | |
| 535 | 541 | const decisionSection = when(decisionBody, (body) => |
| 536 | 542 | card(body, { |
| 537 | 543 | head: html`${icon("alert")} |
| @@ -604,6 +610,7 @@ async function changeConversation( | ||
| 604 | 610 | ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)} |
| 605 | 611 | ${teamsCard(teamVerdicts(db, { ...pull, headOid }))} |
| 606 | 612 | ${when(wall, (w) => wallCard(w))} ${decisionSection} |
| 613 | ${when(visionPreview, (v) => visionPreviewCard(v))} | |
| 607 | 614 | ${takeBackSection} |
| 608 | 615 | ${when(record, (r) => lookCard(ctx, repo, r, pull, pull.headOid))} |
| 609 | 616 | <div class="split"> |
src/web/studio/crew.ts +2
| @@ -38,6 +38,7 @@ import { html, raw, type Html } from "../html.ts"; | ||
| 38 | 38 | import { icon } from "../icons.ts"; |
| 39 | 39 | import { crewReadiness } from "../pages/crew.ts"; |
| 40 | 40 | import { grantsSection } from "../pages/subscriptions.ts"; |
| 41 | import { directionMarked } from "./direction.ts"; | |
| 41 | 42 | import { markOf } from "../../core/marks.ts"; |
| 42 | 43 | import { |
| 43 | 44 | btn, |
| @@ -305,6 +306,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html | ||
| 305 | 306 | })} |
| 306 | 307 | <div class="field"> |
| 307 | 308 | <label for="f-direction">Direction</label> |
| 309 | ${directionMarked(repo.autonomyDirection)} | |
| 308 | 310 | <textarea |
| 309 | 311 | class="input" |
| 310 | 312 | id="f-direction" |
src/web/studio/direction.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * The direction drawn with its two authors told apart (#5835): the fenced block | |
| 3 | * that follows docs/VISION.md, and the owner's own words. A vision pull shows | |
| 4 | * the block as the merge will write it, beside today's. | |
| 5 | */ | |
| 6 | import { directionParts, type DirectionPart, type VisionPreview } from "../../crew/vision.ts"; | |
| 7 | import { html, type Html } from "../html.ts"; | |
| 8 | import { icon } from "../icons.ts"; | |
| 9 | import { badge, card } from "../ui.ts"; | |
| 10 | ||
| 11 | const FOLLOWS = "follows docs/VISION.md"; | |
| 12 | const OWNER = "the owner's"; | |
| 13 | ||
| 14 | function part(p: DirectionPart): Html { | |
| 15 | return html`<div data-direction-part="${p.kind}"> | |
| 16 | <p>${p.kind === "vision" ? badge(FOLLOWS) : badge(OWNER, { variant: "outline" })}</p> | |
| 17 | <div class="direction-text">${p.text}</div> | |
| 18 | </div>`; | |
| 19 | } | |
| 20 | ||
| 21 | /** The direction in parts, each marked; nothing when it is empty. */ | |
| 22 | export function directionMarked(direction: string): Html | null { | |
| 23 | const parts = directionParts(direction); | |
| 24 | if (parts.length === 0) return null; | |
| 25 | return html`<div class="stack" id="direction-marked">${parts.map(part)}</div>`; | |
| 26 | } | |
| 27 | ||
| 28 | /** Today's direction beside the one the merge writes, or why it will stay. */ | |
| 29 | export function visionPreviewCard(preview: VisionPreview): Html { | |
| 30 | const head = html`${icon("code")}<h2>Direction after the merge</h2>`; | |
| 31 | if (!preview.ok) | |
| 32 | return card(html`<p class="muted small">An admin's merge would leave the direction as it is: ${preview.why}.</p>`, { | |
| 33 | head, | |
| 34 | id: "direction-preview", | |
| 35 | }); | |
| 36 | const same = preview.today === preview.after; | |
| 37 | return card( | |
| 38 | html`${same ? html`<p class="muted small">The merge changes nothing in the direction.</p>` : null} | |
| 39 | <div class="stack"> | |
| 40 | <div data-direction="today"> | |
| 41 | <h3 class="small">Today</h3> | |
| 42 | ${directionMarked(preview.today) ?? html`<p class="muted small">(none written)</p>`} | |
| 43 | </div> | |
| 44 | <div data-direction="after"> | |
| 45 | <h3 class="small">After the merge</h3> | |
| 46 | ${directionMarked(preview.after) ?? html`<p class="muted small">(none written)</p>`} | |
| 47 | </div> | |
| 48 | </div>`, | |
| 49 | { head, id: "direction-preview" }, | |
| 50 | ); | |
| 51 | } |
src/web/style/content.ts +2
| @@ -175,6 +175,8 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 175 | 175 | color: var(--muted-fg); margin: 8px 0 4px; |
| 176 | 176 | } |
| 177 | 177 | |
| 178 | .direction-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; color: var(--muted-fg); margin: 6px 0 0; } | |
| 179 | ||
| 178 | 180 | .chart-box { color: var(--accent); aspect-ratio: 720 / 260; max-width: 720px; } |
| 179 | 181 | .chart-box .chart { display: block; width: 100%; height: auto; } |
| 180 | 182 | .chart-box text { fill: var(--muted-fg); font-family: var(--mono); } |
test/mcp-vision.test.ts +34 −2
| @@ -5,9 +5,12 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { commitToBranch } from "../src/actions/repos.ts"; |
| 8 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { openPull } from "../src/actions/pulls.ts"; | |
| 9 | import { savePlacement } from "../src/core/placements.ts"; | |
| 10 | import { nextDirection } from "../src/crew/vision.ts"; | |
| 11 | import { createRepo, getRepo } from "../src/core/repos.ts"; | |
| 9 | 12 | import { createToken } from "../src/core/tokens.ts"; |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 11 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | 16 | |
| @@ -80,3 +83,32 @@ test("a reader who may not see settings gets no direction", async () => { | ||
| 80 | 83 | expect(v.direction).toBeNull(); |
| 81 | 84 | expect(v.order.length).toBe(8); |
| 82 | 85 | }); |
| 86 | ||
| 87 | test("number: an admin reads the direction after an open vision pull; a reader reads nothing", async () => { | |
| 88 | const owner = ok(getUserByName(app.db, "ada") ? { ok: true as const, value: getUserByName(app.db, "ada")! } : { ok: false as const, error: "no ada" }); | |
Nit: this line wraps a lookup in a hand-made Result just to unwrap it. |
||
| 89 | const repo = getRepo(app.db, "ada", "with")!; | |
| 90 | const md = (await readFile(join(import.meta.dir, "..", "docs", "VISION.md"), "utf8")).replace("Done means reachable", "Done means shown"); | |
| 91 | ok(await commitToBranch(app, owner, repo, { branch: "vision-m", message: "v", files: [{ path: "docs/VISION.md", content: md }] })); | |
| 92 | const pull = ok(await openPull(app, owner, repo, { title: "Note", body: "", baseBranch: "main", headBranch: "vision-m" })); | |
| 93 | const call = async (token: string, args: object): Promise<any> => { | |
| 94 | const res = await fetch(`${servers.url}/mcp`, { | |
| 95 | method: "POST", | |
| 96 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 97 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "vision", arguments: args } }), | |
| 98 | }); | |
| 99 | return JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 100 | }; | |
| 101 | expect((await call(tok["owner"]!, { repo: "ada/with", number: pull.number })).preview).toBeNull(); | |
| 102 | savePlacement(app.db, { | |
| 103 | thoughtId: "thmcp", kind: "vision", reply: "", asked: false, decidedBy: "t", placedAt: new Date().toISOString(), | |
| 104 | placed: [{ action: "proposed", repo: "ada/with", number: pull.number, pull: pull.number }], | |
| 105 | }); | |
| 106 | const v = await call(tok["owner"]!, { repo: "ada/with", number: pull.number }); | |
| 107 | const expected = nextDirection("ship the feed", md); | |
| 108 | if (!expected.ok) throw new Error(expected.why); | |
| 109 | expect(v.preview).toEqual({ today: "ship the feed", after: expected.text }); | |
| 110 | expect(v.directionParts).toEqual([{ kind: "owner", text: "ship the feed" }]); | |
| 111 | const r = await call(tok["reader"]!, { repo: "ada/with", number: pull.number }); | |
| 112 | expect(r.preview).toBeNull(); | |
| 113 | expect(r.directionParts).toBeNull(); | |
| 114 | }); |
test/vision-land.test.ts +73
| @@ -164,3 +164,76 @@ describe("landing a vision pull", () => { | ||
| 164 | 164 | expect(html).toContain("the forge kept the repository's direction as it was: only an admin's merge rewrites it"); |
| 165 | 165 | }); |
| 166 | 166 | }); |
| 167 | ||
| 168 | describe("showing the direction (#5835)", () => { | |
| 169 | let dataDir: string; | |
| 170 | let app: App; | |
| 171 | let servers: Servers; | |
| 172 | let owner: User; | |
| 173 | let repo: Repo; | |
| 174 | let cookie: string; | |
| 175 | ||
| 176 | beforeAll(async () => { | |
| 177 | dataDir = await mkdtemp(join(tmpdir(), "forge-vision-show-")); | |
| 178 | app = createApp({ dataDir, port: 0 }); | |
| 179 | const made = await signUp(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 180 | if (!made.ok) throw new Error("sign-up failed"); | |
| 181 | owner = made.value; | |
| 182 | promote(app.db, owner); | |
| 183 | const r = await createRepo(app, owner, { owner: "alice", name: "site", visibility: "public" }); | |
| 184 | if (!r.ok) throw new Error(String(r.error)); | |
| 185 | repo = r.value; | |
| 186 | expect((await commitToBranch(app, owner, repo, { message: "seed", files: [{ path: "docs/VISION.md", content: MD }] })).ok).toBe(true); | |
| 187 | servers = await serve(app); | |
| 188 | const signin = await fetch(`${servers.url}/signin`, { | |
| 189 | method: "POST", | |
| 190 | headers: { origin: servers.url }, | |
| 191 | body: new URLSearchParams({ username: "alice", password: "a-long-passphrase" }), | |
| 192 | redirect: "manual", | |
| 193 | }); | |
| 194 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 195 | }, 30_000); | |
| 196 | ||
| 197 | afterAll(async () => { | |
| 198 | servers.stop(); | |
| 199 | app.close(); | |
| 200 | await rm(dataDir, { recursive: true, force: true }); | |
| 201 | }); | |
| 202 | ||
| 203 | const get = async (path: string): Promise<string> => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 204 | ||
| 205 | test("settings marks the fenced block as following the vision and the rest as the owner's", async () => { | |
| 206 | updateRepoSettings(app.db, getRepoById(app.db, repo.id)!, { | |
| 207 | autonomyDirection: `Ship small.\n\n${BLOCK_START}\nRule: Done means reachable.\n${BLOCK_END}\n\nNever page me.`, | |
| 208 | }); | |
| 209 | const page = await get("/alice/site/settings/crew"); | |
| 210 | const marked = page.slice(page.indexOf('id="direction-marked"')); | |
| 211 | expect(marked).toMatch(/data-direction-part="owner"[\s\S]*Ship small\./); | |
| 212 | expect(marked).toMatch(/data-direction-part="vision"[\s\S]*follows docs\/VISION\.md[\s\S]*Rule: Done means reachable\./); | |
| 213 | expect(marked).toMatch(/data-direction-part="owner"[\s\S]*Never page me\./); | |
| 214 | expect((marked.match(/follows docs\/VISION\.md/g) ?? []).length).toBe(1); | |
| 215 | }); | |
| 216 | ||
| 217 | test("a vision pull shows today's block beside the one the merge writes, and the merge writes exactly that", async () => { | |
| 218 | updateRepoSettings(app.db, getRepoById(app.db, repo.id)!, { autonomyDirection: "Mine.\n\nOld block text." }); | |
| 219 | const md = MD.replace("Done means reachable.", "Done means shown first."); | |
| 220 | expect((await commitToBranch(app, owner, repo, { branch: "vision-x", message: "v", files: [{ path: "docs/VISION.md", content: md }] })).ok).toBe(true); | |
| 221 | const pull = await openPull(app, owner, repo, { title: "Note a change of direction", body: "", baseBranch: "main", headBranch: "vision-x" }); | |
| 222 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 223 | const plain = await get(`/alice/site/pulls/${pull.value.number}`); | |
| 224 | expect(plain).not.toContain("direction-preview"); | |
| 225 | savePlacement(app.db, { | |
| 226 | thoughtId: "thshow", kind: "vision", reply: "", asked: false, decidedBy: "t", placedAt: new Date().toISOString(), | |
| 227 | placed: [{ action: "proposed", repo: "alice/site", number: pull.value.number, pull: pull.value.number }], | |
| 228 | }); | |
| 229 | const page = await get(`/alice/site/pulls/${pull.value.number}`); | |
| 230 | const card = page.slice(page.indexOf('id="direction-preview"')); | |
| 231 | expect(card).toMatch(/data-direction="today"[\s\S]*Old block text\./); | |
| 232 | expect(card).toMatch(/data-direction="after"[\s\S]*follows docs\/VISION\.md[\s\S]*Rule: Done means shown first\./); | |
| 233 | const expected = withVisionBlock("Mine.\n\nOld block text.", parseVision(md)); | |
| 234 | if (!expected.ok) throw new Error(expected.why); | |
| 235 | expect((await mergePull(app, owner, repo, pull.value, "squash")).ok).toBe(true); | |
| 236 | expect(getRepoById(app.db, repo.id)!.autonomyDirection).toBe(expected.text); | |
| 237 | expect(await get(`/alice/site/pulls/${pull.value.number}`)).not.toContain('id="direction-preview"'); | |
| 238 | }); | |
| 239 | }); |
new above
offline
docs/MCP.mdis on the direction's freeze list while there are more than 40 open pulls. One row is acceptable here because the MCP door was asked for, but don't add further edits to this file in this pull.