Public: anyone can read this; the crew can change it.
Making a forge says what each follow mode does: tell-me opens an inbox card, take-updates opens a pull — not "recorded for now" #7216
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7199-making-a-forge-says-what-each-follow-mod → main 1 commit fork 1 thread · 1 unresolved
7 files
changed +51
−11
docs/MCP.md +1 −1
| @@ -222,7 +222,7 @@ longer sentences: | ||
| 222 | 222 | |
| 223 | 223 | - `dryRun`: plans unless it is `false`; anything else changes nothing. `limb` applying takes an admin token. |
| 224 | 224 | - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder. |
| 225 | - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) or `take-updates`. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed. | |
| 225 | - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) tells the owner in their inbox when this forge's code moves; `take-updates` opens a pull with the new code, and a person merges it. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed. | |
| 226 | 226 | - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`. |
| 227 | 227 | - `explore.query`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window) and `archived:yes`. |
| 228 | 228 | - `archive.repos`: several repositories, as `owner/name`, to archive in one operation. |
src/http/mcp/tools/spawn.ts +2
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { spawnDoor } from "../../../spawn/door.ts"; |
| 2 | import { FOLLOW_SHORT } from "../../../spawn/follow_words.ts"; | |
| 2 | 3 | import { good, bad, type Tool, APP_ARG, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| @@ -30,6 +31,7 @@ export const tool: Tool = { | ||
| 30 | 31 | followMode: { |
| 31 | 32 | type: "string", |
| 32 | 33 | enum: ["tell-me", "take-updates"], |
| 34 | description: FOLLOW_SHORT, | |
| 33 | 35 | }, |
| 34 | 36 | size: { |
| 35 | 37 | type: "string", |
src/spawn/follow_words.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * Follow words (#7199): what each follow mode does, in one plain line, so | |
| 3 | * `/forges/new`, the terminal's new-forge screen and MCP `spawn` say the | |
| 4 | * same thing. They describe `src/spawn/upstream.ts`: `tell-me` sends the | |
| 5 | * owner an inbox card when this forge's code moves; `take-updates` opens the | |
| 6 | * one `forge/take-updates` pull (`src/spawn/take.ts`), which a person merges. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { FollowMode } from "./chain.ts"; | |
| 10 | ||
| 11 | export const FOLLOW_LABELS: Readonly<Record<FollowMode, string>> = { | |
| 12 | "tell-me": "Tell me", | |
| 13 | "take-updates": "Take updates", | |
| 14 | }; | |
| 15 | ||
| 16 | export const FOLLOW_WORDS: Readonly<Record<FollowMode, string>> = { | |
| 17 | "tell-me": "tells the owner in their inbox when this forge's code moves.", | |
| 18 | "take-updates": "opens a pull with the new code, and a person merges it.", | |
| 19 | }; | |
| 20 | ||
| 21 | /** One line naming both modes, for the terminal's hint. */ | |
| 22 | export const FOLLOW_LINE = | |
| 23 | `tell-me (default) ${FOLLOW_WORDS["tell-me"]} take-updates ${FOLLOW_WORDS["take-updates"]}`; | |
| 24 | ||
| 25 | /** The same, short enough for an MCP field (60 bytes); docs/MCP.md has the long words. */ | |
| 26 | export const FOLLOW_SHORT = "tell-me: an inbox card; take-updates: a pull a person merges"; |
src/tui/screens/newforge.ts +5 −2
| @@ -10,6 +10,8 @@ | ||
| 10 | 10 | import { DEFAULT_SIZE, SIZES, type Size } from "../../spawn/sizes.ts"; |
| 11 | 11 | import { childUrlOf, spawnDoor } from "../../spawn/door.ts"; |
| 12 | 12 | import { quotaFull } from "../../spawn/quota.ts"; |
| 13 | import { FOLLOW_LINE, FOLLOW_WORDS } from "../../spawn/follow_words.ts"; | |
| 14 | import { isFollowMode } from "../../spawn/chain.ts"; | |
| 13 | 15 | import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts"; |
| 14 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 17 | import { binding } from "../app/keys.ts"; |
| @@ -39,7 +41,7 @@ const SPECS: readonly Spec[] = [ | ||
| 39 | 41 | { key: "modelKey", label: "Model key", hint: "Sealed in the child, never shown again. Optional.", secret: true }, |
| 40 | 42 | { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." }, |
| 41 | 43 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, |
| 42 | { key: "followMode", label: "Follows this forge's code", hint: "tell-me (default) or take-updates. Recorded for now: nothing tells you or takes updates yet." }, | |
| 44 | { key: "followMode", label: "Follows this forge's code", hint: FOLLOW_LINE }, | |
| 43 | 45 | { key: "size", label: "Size", hint: "small (default), medium or large: left and right choose, or type it.", choices: SIZE_CHOICES }, |
| 44 | 46 | ]; |
| 45 | 47 | |
| @@ -119,9 +121,10 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 119 | 121 | const bytes = Number(body["bytes"] ?? 0); |
| 120 | 122 | const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null; |
| 121 | 123 | const size = (body["plan"] as { size?: string } | undefined)?.size; |
| 124 | const mode = val("followMode") === "" ? "tell-me" : val("followMode"); | |
| 122 | 125 | plan = [ |
| 123 | 126 | `A forge at ${childUrlOf(f.app, val("name"))}.`, |
| 124 | `Recorded for now, not acted on: it follows this forge's code as ${val("followMode") === "" ? "tell-me" : val("followMode")}.`, | |
| 127 | `It follows this forge's code as ${mode}${isFollowMode(mode) ? `: it ${FOLLOW_WORDS[mode]}` : "."}`, | |
| 125 | 128 | "Whoever opens the link becomes its admin.", |
| 126 | 129 | ...(budget === null ? [] : [`Its crew runs on your key, up to $${budget} for the key's whole life.`]), |
| 127 | 130 | "Copied into it:", |
src/web/pages/new-forge.ts +4 −3
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | MIN_BUDGET_USD, |
| 25 | 25 | } from "../../spawn/plan.ts"; |
| 26 | 26 | import { DEFAULT_SIZE, SIZES, sizesView } from "../../spawn/sizes.ts"; |
| 27 | import { FOLLOW_LABELS, FOLLOW_WORDS } from "../../spawn/follow_words.ts"; | |
| 27 | 28 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 28 | 29 | import { field, readForm, type Form } from "../form.ts"; |
| 29 | 30 | import { html, type Html } from "../html.ts"; |
| @@ -220,7 +221,7 @@ function formView( | ||
| 220 | 221 | ...(errors["fresh"] ? { error: errors["fresh"] } : {}), |
| 221 | 222 | })} |
| 222 | 223 | <fieldset class="field" ${errors["followMode"] ? html`data-invalid` : null}> |
| 223 | <legend>How it follows this forge's code (recorded for now; nothing acts on it yet)</legend> | |
| 224 | <legend>How it follows this forge's code</legend> | |
| 224 | 225 | <label class="check"> |
| 225 | 226 | <input |
| 226 | 227 | type="radio" |
| @@ -229,7 +230,7 @@ function formView( | ||
| 229 | 230 | ${values.followMode !== "take-updates" ? html`checked` : null} |
| 230 | 231 | /> |
| 231 | 232 | <span |
| 232 | >Recorded for now: tell me <span class="muted small">your choice is kept; nothing tells you yet.</span></span | |
| 233 | >${FOLLOW_LABELS["tell-me"]} <span class="muted small">${FOLLOW_WORDS["tell-me"]}</span></span | |
| 233 | 234 | > |
| 234 | 235 | </label> |
| 235 | 236 | <label class="check"> |
| @@ -240,7 +241,7 @@ function formView( | ||
| 240 | 241 | ${values.followMode === "take-updates" ? html`checked` : null} |
| 241 | 242 | /> |
| 242 | 243 | <span |
| 243 | >Recorded for now: take updates <span class="muted small">your choice is kept; nothing acts on it yet.</span></span | |
| 244 | >${FOLLOW_LABELS["take-updates"]} <span class="muted small">${FOLLOW_WORDS["take-updates"]}</span></span | |
| 244 | 245 | > |
| 245 | 246 | </label> |
| 246 | 247 | ${errors["followMode"] |
test/new-forge.test.ts +10 −3
| @@ -20,6 +20,7 @@ import { usePlacement, useProbe } from "../src/spawn/door.ts"; | ||
| 20 | 20 | import { childrenForMemory, maxChildren, setCapacity } from "../src/core/capacity.ts"; |
| 21 | 21 | import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; |
| 22 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 23 | import { tool as spawnTool } from "../src/http/mcp/tools/spawn.ts"; | |
| 23 | 24 | |
| 24 | 25 | quietLogs(); |
| 25 | 26 | |
| @@ -302,13 +303,19 @@ describe("+ New forge", () => { | ||
| 302 | 303 | |
| 303 | 304 | test("the page promises no update that does not happen", async () => { |
| 304 | 305 | const html = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " "); |
| 305 | expect(html).toContain("Recorded for now: tell me"); | |
| 306 | expect(html).toContain("Recorded for now: take updates"); | |
| 307 | expect(html).toContain("nothing acts on it yet"); | |
| 306 | expect(html).toContain("Tell me <span class=\"muted small\">tells the owner in their inbox when this forge's code moves.</span>"); | |
| 307 | expect(html).toContain("Take updates <span class=\"muted small\">opens a pull with the new code, and a person merges it.</span>"); | |
| 308 | expect(html).toContain("<legend>How it follows this forge's code</legend>"); | |
| 309 | expect(html).not.toMatch(/recorded for now|nothing acts on it yet/i); | |
| 308 | 310 | expect(html).not.toContain("when this forge changes"); |
| 309 | 311 | expect(html).not.toMatch(/as long as its own changes merge/); |
| 310 | 312 | }); |
| 311 | 313 | |
| 314 | test("MCP spawn says what each follow mode does, as the page does", () => { | |
| 315 | const followMode = (spawnTool.inputSchema.properties as Record<string, { description?: string }>)["followMode"]; | |
| 316 | expect(followMode?.description).toBe("tell-me: an inbox card; take-updates: a pull a person merges"); | |
| 317 | }); | |
| 318 | ||
| 312 | 319 | test("a forge at its depth limit shows no door, and its page says why", async () => { |
| 313 | 320 | useProbe(app, () => async () => true); |
| 314 | 321 | usePlacement(app, hostedPlacement({ host: "forge.test", depth: 2 })); |
test/tui-newforge.test.ts +3 −2
| @@ -87,11 +87,12 @@ describe("the new forge screen", () => { | ||
| 87 | 87 | test("the screen and its plan promise no update that does not happen", () => |
| 88 | 88 | withScreen(admin, async ({ tui, terminal }) => { |
| 89 | 89 | const form = tui.screen.toText(); |
| 90 | expect(form).toContain("Recorded for now"); | |
| 90 | expect(form).toContain("tell-me (default) tells the owner in their inbox"); | |
| 91 | 91 | terminal.feed("kai\x10"); |
| 92 | 92 | await settleApp(tui); |
| 93 | 93 | const text = form + tui.screen.toText(); |
| 94 | expect(tui.screen.toText()).toContain("Recorded for now, not acted on"); | |
| 94 | expect(tui.screen.toText()).toContain("It follows this forge's code as tell-me: it tells the owner in their inbox"); | |
| 95 | expect(text).not.toMatch(/recorded for now/i); | |
| 95 | 96 | expect(text).not.toMatch(/keeps? (itself )?up to date|updates itself|automatically/i); |
| 96 | 97 | })); |
| 97 | 98 |
new above
offline
Fine as a hint. To be complete: on a conflict, take.ts opens an issue that names the files instead of a pull. If this line ever grows, that is the case to add.