Public: anyone can read this; the crew can change it.
"How is my forge?" has one answer everywhere: the same needs-you stack as the… #12186
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12183-how-is-my-forge-has-one-answer-everywher → main 8 commits fork
12 files
changed +614
−14
docs/MCP.md +1
| @@ -114,6 +114,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 114 | 114 | | `flow` | `bugs`, `cpu`, `drift`, `growth` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 530 | |
| 115 | 115 | | `follow_mode` | — | How a fork follows its parent: take updates, tell me, or don't follow | 281 | |
| 116 | 116 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 117 | | `forge` | — | How is my forge? What needs you, recommended; `apply` takes them | 199 | | |
| 117 | 118 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 118 | 119 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 282 | |
| 119 | 120 | | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 431 | |
docs/features/forge-needs.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # How is my forge? (#12183) | |
| 2 | ||
| 3 | One stack of what needs the owner: `needsStack` (`src/actions/needs.ts`) is the asks' rows plus the forge's own needs (a site admin's), most consequential first, from `askStackFor` (`src/web/studio/asks.ts`). The home page's count, `/asks` and the MCP `forge` tool read that stack. `forge` is found with `find_tool`; it is not in the default tool list, so `tools/list` does not grow. | |
| 4 | ||
| 5 | The terminal has it too: palette command "How is my forge?" (`src/tui/screens/needs.ts`, offered to anyone signed in). `j`/`k` move, `a` takes the recommended choice of the need under the cursor. It reads and writes through the same actions as the tool, so a person's gates are the page's, and an agent's session applies nothing. Any bus event redraws it, so a change made on the page shows there. | |
| 6 | ||
| 7 | Each need: `sentence`, `why` it ranks, `waits` (what happens if nobody acts), `choices`, and `recommended`: `choice`, `reason`, `undo` (`receipt` or `none`), `safe`, and for an unsafe one the `ask` to put to the owner. A recommendation comes from state (`recommend`). Only a close the crew advised on a pull is safe: it has a receipt. Everything else is "Ask the owner" with the question, including a green pull: a merge has no undo but a revert pull, and green is not a person's approval. | |
| 8 | ||
| 9 | `forge {apply: "recommended"}` plans (dry by default); `dryRun: false` does the safe ones through the asks' bulk actions (`asks_bulk.ts`), so the per-row access check and the receipt are theirs. Undo with `asks_act {action: "undo", operation}`. The rest is returned untouched under `left`. |
src/actions/needs.ts added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | /** | |
| 2 | * The one stack of what needs the owner (#12183): the home page's needs-you | |
| 3 | * count, `/asks`, the terminal and the MCP `forge` tool all read `needsStack`, | |
| 4 | * which is the asks' own rows (`askLinesFor`) plus the forge's own needs, | |
| 5 | * each with a recommended choice derived from state — never a default. | |
| 6 | * Applying "recommended" goes through the asks' bulk actions (`asks_bulk.ts`), | |
| 7 | * so the preview, the per-row access check, the receipt and the undo are theirs. | |
| 8 | */ | |
| 9 | import { applyAsksBulk, receiptOf } from "./asks_bulk.ts"; | |
| 10 | import { askStackFor, type askLinesFor } from "../web/studio/asks.ts"; | |
| 11 | import { needLine } from "./health.ts"; | |
| 12 | import { forgeHealth } from "../kernel/guard.ts"; | |
| 13 | import type { Chips } from "../core/feed_chips.ts"; | |
| 14 | import type { User } from "../core/users.ts"; | |
| 15 | import type { Ctx } from "../http/context.ts"; | |
| 16 | ||
| 17 | type Home = Pick<Ctx, "app">; | |
| 18 | ||
| 19 | export interface Recommended { | |
| 20 | /** The choice, in the words `choices` use; "Ask the owner" when the forge cannot tell. */ | |
| 21 | readonly choice: string; | |
| 22 | readonly reason: string; | |
| 23 | /** What undoing it takes: "undo" (a receipt), "revert pull", or "none". */ | |
| 24 | readonly undo: "receipt" | "a revert pull" | "none"; | |
| 25 | /** Whether it is taken without a person's word: reversible, and already allowed. */ | |
| 26 | readonly safe: boolean; | |
| 27 | /** For an item not safe: the question to put to the owner. */ | |
| 28 | readonly ask?: string; | |
| 29 | /** The asks' bulk action that carries it out, for a safe one: only a close (it has a receipt). */ | |
| 30 | readonly action?: "close"; | |
| 31 | } | |
| 32 | ||
| 33 | export interface Need { | |
| 34 | readonly id: string; | |
| 35 | readonly sentence: string; | |
| 36 | readonly why: string; | |
| 37 | readonly waits: string; | |
| 38 | readonly choices: readonly string[]; | |
| 39 | readonly recommended: Recommended; | |
| 40 | readonly link: string; | |
| 41 | /** The asks' ids the item holds: what a bulk action takes. */ | |
| 42 | readonly items: readonly string[]; | |
| 43 | } | |
| 44 | ||
| 45 | type Row = ReturnType<typeof askLinesFor>[number]; | |
| 46 | ||
| 47 | const askOwner = (question: string, reason: string): Recommended => ({ | |
| 48 | choice: "Ask the owner", | |
| 49 | reason, | |
| 50 | undo: "none", | |
| 51 | safe: false, | |
| 52 | ask: question, | |
| 53 | }); | |
| 54 | ||
| 55 | /** What to do about one row, from its state: its kind, what it waits for, the crew's advice. */ | |
| 56 | export function recommend(row: Row): Recommended { | |
| 57 | const about = row.line; | |
| 58 | if (row.items.length !== 1) | |
| 59 | return askOwner(`Which of these should be taken first: ${about}?`, "several changes share this row; each is its own call"); | |
| 60 | const advice = row.recommendation; | |
| 61 | const conflicts = row.waitingFor.word === "conflicts with main"; | |
| 62 | if (row.numberOf === "pull" && !conflicts) { | |
| 63 | // A merge has no undo but a revert pull, so no change is shipped on a recommendation: | |
| 64 | // the owner's word is the approval. | |
| 65 | if (row.kind === "look") | |
| 66 | return askOwner( | |
| 67 | `${about}: ship it? Green, no conflicts, nothing asks for changes.`, | |
| 68 | "a merge is undone only by a revert pull, and green is not a person's approval", | |
| 69 | ); | |
| 70 | if (row.kind === "waiting" && advice?.verdict === "merge") | |
| 71 | return askOwner(`${about}: ship it? The crew advises merging: ${advice.reason || "no reason given"}.`, "a merge is undone only by a revert pull, and the crew's advice is not a person's approval"); | |
| 72 | if (advice?.verdict === "close" && (row.kind === "waiting" || row.kind === "handover" || row.kind === "stopped")) | |
| 73 | return { | |
| 74 | choice: "Close it", | |
| 75 | reason: `the crew advises closing: ${advice.reason || "no reason given"}`, | |
| 76 | undo: "receipt", | |
| 77 | safe: true, | |
| 78 | action: "close", | |
| 79 | }; | |
| 80 | } | |
| 81 | if (conflicts) return askOwner(`${about}: it conflicts with main; should its author rebase it?`, "it cannot land until its conflicts are gone"); | |
| 82 | if (row.kind === "proposal" || row.kind === "declined") | |
| 83 | return askOwner(`${about}: what direction should the crew take?`, "a product decision: only the owner can make it"); | |
| 84 | if (row.kind === "paused") | |
| 85 | return askOwner(`${about}: raise today's ceiling?`, "it spends money above what is set"); | |
| 86 | if (row.kind === "review") | |
| 87 | return askOwner(`${about}: ship it, or say what is off?`, "an agent's change is landed after a person's review"); | |
| 88 | return askOwner(`${about}: what should happen?`, "the state does not say which way to go"); | |
| 89 | } | |
| 90 | ||
| 91 | /** The stack: the forge's own needs first (an admin's), then the asks, most consequential first. */ | |
| 92 | export function needsStack(ctx: Home, user: User, chips?: Chips): Need[] { | |
| 93 | const stack = askStackFor(ctx, user, chips); | |
| 94 | const forge = stack.forge.map( | |
| 95 | (d, i): Need => ({ | |
| 96 | id: `forge:${d.cause || i}`, | |
| 97 | sentence: needLine(d), | |
| 98 | why: "the forge itself is degraded", | |
| 99 | waits: "it stays that way until someone looks", | |
| 100 | choices: ["Open it"], | |
| 101 | recommended: askOwner(`${d.sentence} Look into it?`, "the forge's own health is the owner's to act on"), | |
| 102 | link: d.href, | |
| 103 | items: [], | |
| 104 | }), | |
| 105 | ); | |
| 106 | const rows = stack.rows.map( | |
| 107 | (row): Need => ({ | |
| 108 | id: row.id, | |
| 109 | sentence: row.line, | |
| 110 | why: row.consequence.why, | |
| 111 | waits: row.words.ifNothing, | |
| 112 | choices: row.words.options, | |
| 113 | recommended: recommend(row), | |
| 114 | link: row.link, | |
| 115 | items: row.items, | |
| 116 | }), | |
| 117 | ); | |
| 118 | return [...forge, ...rows]; | |
| 119 | } | |
| 120 | ||
| 121 | /** One or two sentences: serving or not, the crew, what is unusual. */ | |
| 122 | export function forgeSays(stack: readonly Need[], running: number): string { | |
| 123 | const serving = forgeHealth().ok ? "The forge is serving" : "The forge is not serving normally"; | |
| 124 | const crew = running === 0 ? "the crew is idle" : `the crew has ${running} ${running === 1 ? "run" : "runs"} going`; | |
| 125 | const safe = stack.filter((n) => n.recommended.safe).length; | |
| 126 | const unusual = | |
| 127 | stack.length === 0 | |
| 128 | ? "nothing needs you." | |
| 129 | : `${stack.length} ${stack.length === 1 ? "thing needs" : "things need"} you, ${safe} with a recommendation safe to take now.`; | |
| 130 | return `${serving}, ${crew}; ${unusual}`; | |
| 131 | } | |
| 132 | ||
| 133 | export interface Applied { | |
| 134 | readonly dryRun: boolean; | |
| 135 | /** What was (or would be) done, one row each. */ | |
| 136 | readonly plan: { id: string; choice: string; reason: string; undo: Recommended["undo"] }[]; | |
| 137 | /** Left for the owner, each with the question to ask. */ | |
| 138 | readonly left: { id: string; sentence: string; ask: string }[]; | |
| 139 | readonly receipts: { operation: string; receipt: string; undo?: string }[]; | |
| 140 | readonly refused: { item: string; reason: string }[]; | |
| 141 | } | |
| 142 | ||
| 143 | /** Apply the recommended choice of every safe item; dry unless `dryRun` is false. */ | |
| 144 | export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, only?: string): Promise<Applied> { | |
| 145 | const stack = needsStack(ctx, user).filter((n) => only === undefined || n.id === only); | |
| 146 | const safe = stack.filter((n) => n.recommended.safe && n.recommended.action !== undefined); | |
| 147 | const out: Applied = { | |
| 148 | dryRun, | |
| 149 | plan: safe.map((n) => ({ id: n.id, choice: n.recommended.choice, reason: n.recommended.reason, undo: n.recommended.undo })), | |
| 150 | left: stack | |
| 151 | .filter((n) => !safe.includes(n)) | |
| 152 | .map((n) => ({ id: n.id, sentence: n.sentence, ask: n.recommended.ask ?? n.sentence })), | |
| 153 | receipts: [], | |
| 154 | refused: [], | |
| 155 | }; | |
| 156 | if (dryRun) return out; | |
| 157 | for (const action of ["close"] as const) { | |
| 158 | const items = safe.filter((n) => n.recommended.action === action).flatMap((n) => [...n.items]); | |
| 159 | if (items.length === 0) continue; | |
| 160 | const reason = "Closed by the recommended choice: the crew advised it."; | |
| 161 | const done = await applyAsksBulk(ctx.app, user, action, items, reason); | |
| 162 | if (!done.ok) { | |
| 163 | out.refused.push(...items.map((item) => ({ item, reason: done.error.message }))); | |
| 164 | continue; | |
| 165 | } | |
| 166 | out.refused.push(...done.value.skipped); | |
| 167 | out.receipts.push({ | |
| 168 | operation: done.value.id, | |
| 169 | receipt: receiptOf(done.value), | |
| 170 | ...(done.value.reversible ? { undo: `asks_act {action:"undo", operation:"${done.value.id}", dryRun:false}` } : {}), | |
| 171 | }); | |
| 172 | } | |
| 173 | return out; | |
| 174 | } |
src/http/mcp/tools/forge.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | import { crewNow } from "../../../actions/crew_now.ts"; | |
| 2 | import { applyRecommended, forgeSays, needsStack } from "../../../actions/needs.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "forge", | |
| 7 | description: "How is my forge? What needs you, recommended; `apply` takes them.", | |
| 8 | inputSchema: { | |
| 9 | type: "object", | |
| 10 | properties: { | |
| 11 | apply: { type: "string" }, | |
| 12 | dryRun: { type: "boolean" }, | |
| 13 | }, | |
| 14 | }, | |
| 15 | doc: | |
| 16 | "`{ apply?, dryRun? }`: `{ says, needs, recommended }`: one or two sentences, then what needs you (the home page's own stack, most consequential first: `sentence`, `why`, `choices`, `recommended` with `reason`, `undo`, `safe`, and the `ask` to put to the owner when not), and the call that applies them. `apply: \"recommended\"` (or one need's `id`) takes the recommended choice of each safe need (a close the crew advised, which has a receipt); a ship is never one: a merge has no undo, so it is a question for the owner and lists the rest, untouched, with their questions; planned first (`dryRun` is true unless `false`); each receipt names its undo (`asks_act` `undo`)", | |
| 17 | writes: true, | |
| 18 | dryByDefault: true, | |
| 19 | readsWithout: "apply", | |
| 20 | async run(ctx, user, args) { | |
| 21 | const apply = args["apply"]; | |
| 22 | if (apply !== undefined) { | |
| 23 | if (typeof apply !== "string" || apply === "") return bad("invalid", "apply is \"recommended\" or a need's id"); | |
| 24 | return good(await applyRecommended(ctx, user, args["dryRun"] !== false, apply === "recommended" ? undefined : apply)); | |
| 25 | } | |
| 26 | const needs = needsStack(ctx, user); | |
| 27 | const running = crewNow(ctx, user).filter((r) => r.run !== null).length; | |
| 28 | return good({ | |
| 29 | says: forgeSays(needs, running), | |
| 30 | needs, | |
| 31 | recommended: needs.some((n) => n.recommended.safe) ? 'forge {apply:"recommended", dryRun:false}' : null, | |
| 32 | }); | |
| 33 | }, | |
| 34 | }; |
src/http/mcp/tools/index.ts +1
| @@ -73,6 +73,7 @@ export { tool as flags } from "./flags.ts"; | ||
| 73 | 73 | export { tool as flow } from "./flow.ts"; |
| 74 | 74 | export { tool as follow_mode } from "./follow_mode.ts"; |
| 75 | 75 | export { tool as following } from "./following.ts"; |
| 76 | export { tool as forge } from "./forge.ts"; | |
| 76 | 77 | export { tool as forge_proposals } from "./forge_proposals.ts"; |
| 77 | 78 | export { tool as forge_view } from "./forge_view.ts"; |
| 78 | 79 | export { tool as forges } from "./forges.ts"; |
src/tui/forge.ts +2
| @@ -18,6 +18,7 @@ import { sourceFor } from "../view/source.ts"; | ||
| 18 | 18 | import { fleetCommand } from "./screens/fleet.ts"; |
| 19 | 19 | import { dataCommand } from "./screens/data.ts"; |
| 20 | 20 | import { packsCommand } from "./screens/packs.ts"; |
| 21 | import { needsCommands } from "./screens/needs.ts"; | |
| 21 | 22 | import { sendChangeCommand } from "./screens/send_change.ts"; |
| 22 | 23 | import { interruptedOutCommand } from "./screens/interrupted_out.ts"; |
| 23 | 24 | import { getForgeCommand } from "./screens/getforge.ts"; |
| @@ -257,6 +258,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 257 | 258 | hint: "Atom address", |
| 258 | 259 | run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"), |
| 259 | 260 | }, |
| 261 | ...needsCommands(session, ctx), | |
| 260 | 262 | packsCommand(session, ctx), |
| 261 | 263 | sendChangeCommand(ctx), |
| 262 | 264 | interruptedOutCommand(session, ctx), |
src/tui/screens/needs.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** | |
| 2 | * "How is my forge?" in the terminal (#12183): the stack the home page and the | |
| 3 | * MCP `forge` tool show (`needsStack`), one row per need with the recommended | |
| 4 | * choice under it. Anyone signed in sees their own stack, gated by the same | |
| 5 | * reads as the page. `a` takes the recommended choice of the need under the | |
| 6 | * cursor through `applyRecommended`, the asks' bulk action with its per-row | |
| 7 | * access check and receipt; only a person's, and only a close the crew | |
| 8 | * advised: a ship is a merge with no undo, so it stays a question. Any event | |
| 9 | * on the bus redraws the stack, so what the page does shows here and back. | |
| 10 | */ | |
| 11 | ||
| 12 | import { crewNow } from "../../actions/crew_now.ts"; | |
| 13 | import { applyRecommended, forgeSays, needsStack, type Need } from "../../actions/needs.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import { binding } from "../app/keys.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { style } from "../term/style.ts"; | |
| 18 | import { truncate } from "../term/width.ts"; | |
| 19 | import { fill, len, split } from "../ui/layout.ts"; | |
| 20 | import { ListState, drawList, live, runAsync, type ListRow } from "./common.ts"; | |
| 21 | import type { Command } from "../app/palette.ts"; | |
| 22 | ||
| 23 | /** The stack this session's user may see; none when nobody is signed in. */ | |
| 24 | export function stackFor(f: Session): Need[] { | |
| 25 | return f.user === undefined ? [] : needsStack({ app: f.app }, f.user); | |
| 26 | } | |
| 27 | ||
| 28 | /** How many of the crew's runs this session's user may see going: what the MCP tool counts. */ | |
| 29 | export function runningFor(f: Session): number { | |
| 30 | return f.user === undefined ? 0 : crewNow({ app: f.app }, f.user).filter((r) => r.run !== null).length; | |
| 31 | } | |
| 32 | ||
| 33 | /** Why `a` does nothing on a need, in words; empty when it applies. */ | |
| 34 | export function whyNot(f: Session, need: Need | undefined): string { | |
| 35 | if (need === undefined) return "Nothing needs you."; | |
| 36 | if (f.user === undefined || f.user.kind !== "user") return "A person applies a recommendation, never an agent."; | |
| 37 | if (!need.recommended.safe) return need.recommended.ask ?? "That one is for the owner to decide."; | |
| 38 | return ""; | |
| 39 | } | |
| 40 | ||
| 41 | export function needsScreen(f: Session, ctx: Ctx): Screen { | |
| 42 | const state = new ListState(); | |
| 43 | let stack = stackFor(f); | |
| 44 | let running = runningFor(f); | |
| 45 | const reload = (): void => { | |
| 46 | stack = stackFor(f); | |
| 47 | running = runningFor(f); | |
| 48 | ctx.invalidate(); | |
| 49 | }; | |
| 50 | return { | |
| 51 | title: "How is my forge?", | |
| 52 | keys: [binding(["j", "k"], "move cursor"), binding(["a"], "take the recommended choice")], | |
| 53 | async load(signal) { | |
| 54 | live(f, signal, {}, reload); | |
| 55 | }, | |
| 56 | render(area, s) { | |
| 57 | const c = ctx.theme.c; | |
| 58 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 59 | const says = f.user === undefined ? "Sign in to see what needs you." : forgeSays(stack, running); | |
| 60 | s.text(head!.x, head!.y, truncate(says, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 61 | const rows: ListRow[] = stack.map((n) => ({ | |
| 62 | title: n.sentence, | |
| 63 | tone: n.recommended.safe ? "success" : "attn", | |
| 64 | note: `${n.recommended.choice}: ${n.recommended.reason}`, | |
| 65 | ...(n.recommended.safe ? { meta: "a applies" } : {}), | |
| 66 | })); | |
| 67 | drawList(s, list!, c, state, rows); | |
| 68 | }, | |
| 69 | key(e) { | |
| 70 | if (state.key(e, stack.length)) return true; | |
| 71 | if (e.ctrl || e.alt || e.key !== "a") return false; | |
| 72 | const at = stack[state.index]; | |
| 73 | const why = whyNot(f, at); | |
| 74 | if (why !== "" || at === undefined || f.user === undefined) { | |
| 75 | ctx.toast(why, "danger"); | |
| 76 | return true; | |
| 77 | } | |
| 78 | runAsync(ctx, async () => { | |
| 79 | const done = await applyRecommended({ app: f.app }, f.user!, false, at.id); | |
| 80 | const left = done.refused[0]; | |
| 81 | ctx.toast(left ? `Refused: ${left.reason}` : done.receipts[0]?.receipt ?? "Nothing to do.", left ? "danger" : undefined); | |
| 82 | reload(); | |
| 83 | }); | |
| 84 | return true; | |
| 85 | }, | |
| 86 | }; | |
| 87 | } | |
| 88 | ||
| 89 | /** Offered to anyone signed in: the stack is their own. */ | |
| 90 | export const needsCommands = (f: Session, ctx: Ctx): Command[] => | |
| 91 | f.user === undefined | |
| 92 | ? [] | |
| 93 | : [ | |
| 94 | { | |
| 95 | id: "needs", | |
| 96 | title: "How is my forge?", | |
| 97 | hint: "what needs you, with a recommendation each; a takes a safe one", | |
| 98 | run: () => ctx.push(needsScreen(f, ctx)), | |
| 99 | }, | |
| 100 | ]; |
src/web/studio/asks.ts +20 −13
| @@ -1185,24 +1185,30 @@ export function askLinesFor( | ||
| 1185 | 1185 | * health answer (`/api/v1/health`) as one ask. Anyone else gets none — the |
| 1186 | 1186 | * forge's standing is the admin's to read and nobody else's to know about. |
| 1187 | 1187 | */ |
| 1188 | export function forgeNeedsFor(ctx: Ctx, user: User | undefined): Degraded[] { | |
| 1188 | export function forgeNeedsFor(ctx: Pick<Ctx, "app">, user: User | undefined): Degraded[] { | |
| 1189 | 1189 | if (!user?.isAdmin) return []; |
| 1190 | 1190 | return forgeVitals(ctx.app).degraded; |
| 1191 | 1191 | } |
| 1192 | 1192 | |
| 1193 | 1193 | /** |
| 1194 | * How many things ask the viewer: the one number the studio header and the | |
| 1195 | * asks page both show. It counts the rows the cards are made into | |
| 1196 | * (`askRows` over `waitingOn`) plus the forge's own needs, so the two | |
| 1197 | * cannot disagree. | |
| 1194 | * The one stack of what asks the viewer: the forge's own needs (an admin's) | |
| 1195 | * and the asks' rows. `needsStack` (`actions/needs.ts`) words it, `askCountFor` | |
| 1196 | * counts it and `asksAnswer` returns it, so they cannot disagree. | |
| 1198 | 1197 | */ |
| 1199 | export function askCountFor(ctx: Ctx, user: User | undefined): number { | |
| 1198 | export function askStackFor( | |
| 1199 | ctx: Pick<Ctx, "app">, | |
| 1200 | user: User, | |
| 1201 | chips?: Chips, | |
| 1202 | onHidden?: (rows: number) => void, | |
| 1203 | ) { | |
| 1204 | return { forge: forgeNeedsFor(ctx, user), rows: askLinesFor(ctx.app.db, user, chips, onHidden) }; | |
| 1205 | } | |
| 1206 | ||
| 1207 | /** How many things ask the viewer: the stack's length, the number the header and asks page show. */ | |
| 1208 | export function askCountFor(ctx: Pick<Ctx, "app">, user: User | undefined): number { | |
| 1200 | 1209 | if (!user) return 0; |
| 1201 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }); | |
| 1202 | return ( | |
| 1203 | askRows(waitingOn(ctx.app.db, user, repos)).length + | |
| 1204 | forgeNeedsFor(ctx, user).length | |
| 1205 | ); | |
| 1210 | const stack = askStackFor(ctx, user); | |
| 1211 | return stack.forge.length + stack.rows.length; | |
| 1206 | 1212 | } |
| 1207 | 1213 | |
| 1208 | 1214 | /** The single sentence the asks page leads with, and the dashboard links with. */ |
| @@ -1221,12 +1227,13 @@ export function askSentence(count: number): string { | ||
| 1221 | 1227 | export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) { |
| 1222 | 1228 | const set = reviewSet(ctx, user); |
| 1223 | 1229 | let hidden = 0; |
| 1224 | const rows = askLinesFor(ctx.app.db, user, chips, (n) => (hidden = n)); | |
| 1230 | const stack = askStackFor(ctx, user, chips, (n) => (hidden = n)); | |
| 1231 | const rows = stack.rows; | |
| 1225 | 1232 | return { |
| 1226 | 1233 | // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days. |
| 1227 | 1234 | waiting: { count: set.count, stale: set.stale.length }, |
| 1228 | 1235 | rows, |
| 1229 | forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)), | |
| 1236 | forge: stack.forge.map((d) => needLine(d)), | |
| 1230 | 1237 | measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) }, |
| 1231 | 1238 | ...(hasAskFilters(askFiltersOf(ctx.app.db, user.id)) ? { filters: { hidden, line: hiddenLine(hidden) } } : {}), |
| 1232 | 1239 | }; |
test/conversation-confirm.test.ts +1 −1
| @@ -123,7 +123,7 @@ describe("the one list of what asks first", () => { | ||
| 123 | 123 | "access", "account_requests", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas", |
| 124 | 124 | "asks", "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "estimate", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner", |
| 126 | "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app", | |
| 126 | "follow_mode", "forge", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app", | |
| 127 | 127 | "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "repo", "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", |
| 129 | 129 | "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file", |
test/forge-needs-one-stack.test.ts added +268
| @@ -0,0 +1,268 @@ | ||
| 1 | /** | |
| 2 | * One answer to "how is my forge?" (#12183): MCP, home, asks and terminal | |
| 3 | * read one stack; apply never merges on a recommendation. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { addComment, openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { getPull } from "../src/core/pulls.ts"; | |
| 15 | import { addComment as commentAs } from "../src/core/issues.ts"; | |
| 16 | import { id as newId } from "../src/kernel/id.ts"; | |
| 17 | import { applyAsksBulk } from "../src/actions/asks_bulk.ts"; | |
| 18 | import type { ForgeEvent } from "../src/core/events.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 21 | import { createToken } from "../src/core/tokens.ts"; | |
| 22 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 23 | import { BREAKER_NOTE } from "../src/crew/breaker.ts"; | |
| 24 | import type { Ctx } from "../src/http/context.ts"; | |
| 25 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 26 | import { askAnchor, askCountFor, asksAnswer } from "../src/web/studio/asks.ts"; | |
| 27 | import { forgeSays, needsStack, type Need } from "../src/actions/needs.ts"; | |
| 28 | import { crewNow } from "../src/actions/crew_now.ts"; | |
| 29 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 30 | import { needsCommands, needsScreen, runningFor } from "../src/tui/screens/needs.ts"; | |
| 31 | import type { Ctx as TuiCtx } from "../src/tui/app/app.ts"; | |
| 32 | import type { Session } from "../src/tui/session.ts"; | |
| 33 | import { needsYouSection } from "../src/web/studio/asks_page.ts"; | |
| 34 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 35 | import { quietLogs } from "./helpers/globals.ts"; | |
| 36 | ||
| 37 | const press = (key: string) => ({ type: "key" as const, key, ctrl: false, alt: false, shift: false }); | |
| 38 | ||
| 39 | quietLogs(); | |
| 40 | ||
| 41 | let dataDir = ""; | |
| 42 | let app: App; | |
| 43 | let servers: Servers; | |
| 44 | let alice: User; | |
| 45 | let token = ""; | |
| 46 | let repo: Repo; | |
| 47 | let pullId = ""; | |
| 48 | let pullNumber = 0; | |
| 49 | let issueNumber = 0; | |
| 50 | ||
| 51 | const ctx = (): Ctx => ({ | |
| 52 | app, | |
| 53 | request: new Request("http://x/"), | |
| 54 | url: new URL("http://x/"), | |
| 55 | params: {}, | |
| 56 | auth: { user: alice, scope: "rw", via: "session" }, | |
| 57 | theme: undefined, | |
| 58 | }); | |
| 59 | interface Answer { | |
| 60 | says: string; | |
| 61 | needs: Need[]; | |
| 62 | recommended: string | null; | |
| 63 | dryRun: boolean; | |
| 64 | plan: { id: string }[]; | |
| 65 | left: { id: string; ask: string }[]; | |
| 66 | receipts: unknown[]; | |
| 67 | } | |
| 68 | const forge = async (args: unknown = {}): Promise<Answer> => (await mcpTool(servers.url, token, "forge", args)).value as Answer; | |
| 69 | const session = (user: User | undefined) => ({ user, db: app.db, app }) as unknown as Session; | |
| 70 | const tracked: Promise<unknown>[] = []; | |
| 71 | const tui = (toasts: string[]) => | |
| 72 | ({ | |
| 73 | toast: (t: string) => toasts.push(t), | |
| 74 | invalidate() {}, | |
| 75 | push() {}, | |
| 76 | track: (p: Promise<unknown>) => (tracked.push(p), p), | |
| 77 | }) as unknown as TuiCtx; | |
| 78 | ||
| 79 | /** A bot's pull no person has touched for two days, the crew advising to close it: a safe need. */ | |
| 80 | const staleAdvisedClose = (title: string): { id: string; number: number } => { | |
| 81 | const bot = getUserByName(app.db, "bot")!; | |
| 82 | const issueId = newId(); | |
| 83 | const number = app.db | |
| 84 | .query<{ issue_seq: number }, [string]>("UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq") | |
| 85 | .get(repo.id)!.issue_seq; | |
| 86 | const ago = new Date(Date.now() - 50 * 3_600_000).toISOString(); | |
| 87 | app.db | |
| 88 | .query( | |
| 89 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) | |
| 90 | VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`, | |
| 91 | ) | |
| 92 | .run(issueId, repo.id, number, title, bot.id, ago); | |
| 93 | app.db | |
| 94 | .query( | |
| 95 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) | |
| 96 | VALUES (?1, 'main', ?2, ?3, ?3, ?4)`, | |
| 97 | ) | |
| 98 | .run(issueId, `stale/${number}`, "0".repeat(40), repo.id); | |
| 99 | commentAs(app.db, { issueId, authorId: bot.id, body: "Recommendation: close — superseded by the green work." }); | |
| 100 | return { id: issueId, number }; | |
| 101 | }; | |
| 102 | const stateOf = (number: number): string => getPull(app.db, repo.id, number)!.state; | |
| 103 | ||
| 104 | beforeAll(async () => { | |
| 105 | dataDir = await mkdtemp(join(tmpdir(), "forge-needs-")); | |
| 106 | app = createApp({ dataDir, port: 0 }); | |
| 107 | servers = await serve(app); | |
| 108 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-needs-passphrase", adminIfFirst: true }); | |
| 109 | if (!a.ok) throw new Error(a.error); | |
| 110 | alice = a.value; | |
| 111 | token = createToken(app.db, alice.id, "rw", "rw").token; | |
| 112 | await createAgent(app, alice, { username: "bot" }); | |
| 113 | const made = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 114 | if (!made.ok) throw new Error(String(made.error)); | |
| 115 | repo = made.value; | |
| 116 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# open\n" }] }); | |
| 117 | addToCrew(app, alice, repo, "bot"); | |
| 118 | await commitToBranch(app, alice, repo, { branch: "bot/1", base: "main", message: "Green work", files: [{ path: "F.md", content: "1\n" }] }); | |
| 119 | const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, { title: "Green work", body: "", baseBranch: "main", headBranch: "bot/1" }); | |
| 120 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 121 | pullId = opened.value.id; | |
| 122 | pullNumber = opened.value.number; | |
| 123 | const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: pullId, env: `pull-${pullNumber}`, commitOid: opened.value.headOid, reason: "pull" }); | |
| 124 | claimRun(app.db, run.id); | |
| 125 | finishRun(app.db, run.id, { state: "ok", summary: "ok" }); | |
| 126 | const issue = openIssue(app, alice, repo, { title: "Stuck thing" }); | |
| 127 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 128 | issueNumber = issue.value.number; | |
| 129 | addComment(app, alice, repo, issue.value, BREAKER_NOTE); | |
| 130 | }, 30_000); | |
| 131 | ||
| 132 | afterAll(async () => { | |
| 133 | await servers.stop(); | |
| 134 | await rm(dataDir, { recursive: true, force: true }); | |
| 135 | }); | |
| 136 | ||
| 137 | test("`forge` answers with the stack the home, asks and count show, same ids, same order; it is found, not listed first", async () => { | |
| 138 | const answer = await forge(); | |
| 139 | const ids = answer.needs.map((n) => n.id); | |
| 140 | expect(ids).toEqual(needsStack(ctx(), alice).map((n) => n.id)); | |
| 141 | expect(ids).toEqual([...asksAnswer(ctx(), alice).rows.map((r) => r.id)]); | |
| 142 | expect(ids.length).toBe(2); | |
| 143 | const home = needsYouSection(ctx(), alice).body.text; | |
| 144 | const at = ids.map((id) => home.indexOf(askAnchor(id))); | |
| 145 | expect(at.every((i) => i >= 0)).toBe(true); | |
| 146 | expect([...at].sort((x, y) => x - y)).toEqual(at); | |
| 147 | expect(askCountFor(ctx(), alice)).toBe(ids.length); | |
| 148 | expect(home).toContain(`${ids.length} things need you`); | |
| 149 | expect(typeof answer.says).toBe("string"); | |
| 150 | const found = (await mcpTool(servers.url, token, "find_tool", { query: "forge needs" })).value as { tools: { name: string }[] }; | |
| 151 | expect(found.tools.map((t) => t.name)).toContain("forge"); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a green agent pull is a question for the owner, never safe to ship", async () => { | |
| 155 | const { needs } = await forge(); | |
| 156 | const pull = needs.find((n) => n.link.endsWith(`/pulls/${pullNumber}`))!; | |
| 157 | const parked = needs.find((n) => n.link.endsWith(`/issues/${issueNumber}`))!; | |
| 158 | expect(pull.recommended).toMatchObject({ choice: "Ask the owner", safe: false }); | |
| 159 | expect(pull.recommended.ask).toContain("ship it?"); | |
| 160 | expect(parked.recommended).toMatchObject({ choice: "Ask the owner", safe: false }); | |
| 161 | expect(parked.recommended.ask).toContain(`#${issueNumber}`); | |
| 162 | }); | |
| 163 | ||
| 164 | test("apply recommended does not merge an unapproved agent pull, dry or not", async () => { | |
| 165 | const dry = await forge({ apply: "recommended" }); | |
| 166 | expect(dry.dryRun).toBe(true); | |
| 167 | expect(dry.plan).toHaveLength(0); | |
| 168 | expect(dry.left).toHaveLength(2); | |
| 169 | const done = await forge({ apply: "recommended", dryRun: false }); | |
| 170 | expect(done.receipts).toHaveLength(0); | |
| 171 | expect(getPull(app.db, repo.id, pullNumber)!.mergedAt).toBeNull(); | |
| 172 | expect(done.left.map((l) => l.ask).join(" ")).toContain("ship it?"); | |
| 173 | expect((await forge()).needs).toHaveLength(2); | |
| 174 | }); | |
| 175 | ||
| 176 | test("the terminal reads the same stack and refuses what the page refuses", () => { | |
| 177 | expect(needsCommands(session(undefined), tui([]))).toEqual([]); | |
| 178 | expect(needsCommands(session(alice), tui([]))).toHaveLength(1); | |
| 179 | const toasts: string[] = []; | |
| 180 | const screen = needsScreen(session(alice), tui(toasts)); | |
| 181 | const rows = needsStack(ctx(), alice); | |
| 182 | for (let i = 0; i < rows.findIndex((n) => n.link.endsWith(`/pulls/${pullNumber}`)); i++) screen.key?.(press("j")); | |
| 183 | expect(screen.key?.(press("a"))).toBe(true); | |
| 184 | expect(toasts.join(" ")).toContain("ship it?"); | |
| 185 | expect(getPull(app.db, repo.id, pullNumber)!.mergedAt).toBeNull(); | |
| 186 | const bot = getUserByName(app.db, "bot")!; | |
| 187 | const botToasts: string[] = []; | |
| 188 | expect(needsScreen(session(bot), tui(botToasts)).key?.(press("a"))).toBe(true); | |
| 189 | expect(botToasts.join(" ")).toMatch(/never an agent|ship it|Nothing needs/); | |
| 190 | }); | |
| 191 | ||
| 192 | test("a close the crew advised is applied, leaves a receipt, reaches the bus and is undone", async () => { | |
| 193 | const stale = staleAdvisedClose("Superseded work"); | |
| 194 | const need = needsStack(ctx(), alice).find((n) => n.link.endsWith(`/pulls/${stale.number}`))!; | |
| 195 | expect(need.recommended).toMatchObject({ choice: "Close it", safe: true, action: "close", undo: "receipt" }); | |
| 196 | const dry = await forge({ apply: "recommended" }); | |
| 197 | expect(dry.plan.map((p) => p.id)).toEqual([need.id]); | |
| 198 | expect(stateOf(stale.number)).toBe("open"); | |
| 199 | const events: ForgeEvent[] = []; | |
| 200 | const off = app.bus.subscribe((e) => void events.push(e)); | |
| 201 | const done = (await mcpTool(servers.url, token, "forge", { apply: "recommended", dryRun: false })).value as { | |
| 202 | receipts: { operation: string; receipt: string; undo?: string }[]; | |
| 203 | refused: unknown[]; | |
| 204 | }; | |
| 205 | off(); | |
| 206 | expect(done.refused).toEqual([]); | |
| 207 | expect(done.receipts).toHaveLength(1); | |
| 208 | expect(done.receipts[0]!.undo).toContain(done.receipts[0]!.operation); | |
| 209 | expect(stateOf(stale.number)).toBe("closed"); | |
| 210 | expect(getPull(app.db, repo.id, pullNumber)!.mergedAt).toBeNull(); | |
| 211 | expect(events.some((e) => e.kind.endsWith("closed") && e.repoId === repo.id)).toBe(true); | |
| 212 | expect((await forge()).needs.some((n) => n.id === need.id)).toBe(false); | |
| 213 | const undo = await mcpTool(servers.url, token, "asks_act", { action: "undo", operation: done.receipts[0]!.operation, dryRun: false }); | |
| 214 | expect(undo.value).toBeDefined(); | |
| 215 | expect(stateOf(stale.number)).toBe("open"); | |
| 216 | }); | |
| 217 | ||
| 218 | test("someone without the right to close it is refused, by the stack and per row", async () => { | |
| 219 | const stale = staleAdvisedClose("Not yours to close"); | |
| 220 | const need = needsStack(ctx(), alice).find((n) => n.link.endsWith(`/pulls/${stale.number}`))!; | |
| 221 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-carol-passphrase" }); | |
| 222 | if (!made.ok) throw new Error(made.error); | |
| 223 | const carol = made.value; | |
| 224 | const carolToken = createToken(app.db, carol.id, "rw", "rw").token; | |
| 225 | const theirs = (await mcpTool(servers.url, carolToken, "forge", { apply: need.id, dryRun: false })).value as Answer; | |
| 226 | expect(theirs.plan).toEqual([]); | |
| 227 | expect(theirs.receipts).toEqual([]); | |
| 228 | const direct = await applyAsksBulk(app, carol, "close", [...need.items], "not mine"); | |
| 229 | if (direct.ok) expect(direct.value.skipped.map((s) => s.item)).toEqual([...need.items]); | |
| 230 | expect(stateOf(stale.number)).toBe("open"); | |
| 231 | const toasts: string[] = []; | |
| 232 | expect(needsScreen(session(undefined), tui(toasts)).key?.(press("a"))).toBe(true); | |
| 233 | expect(toasts.join(" ")).toContain("Nothing needs you"); | |
| 234 | expect(stateOf(stale.number)).toBe("open"); | |
| 235 | }); | |
| 236 | ||
| 237 | test("a in the terminal closes the advised one, with its receipt, and the web sees it on the bus", async () => { | |
| 238 | const stale = staleAdvisedClose("Close from the terminal"); | |
| 239 | const toasts: string[] = []; | |
| 240 | const screen = needsScreen(session(alice), tui(toasts)); | |
| 241 | const at = needsStack(ctx(), alice).findIndex((n) => n.link.endsWith(`/pulls/${stale.number}`)); | |
| 242 | expect(at).toBeGreaterThanOrEqual(0); | |
| 243 | for (let i = 0; i < at; i++) screen.key?.(press("j")); | |
| 244 | const events: ForgeEvent[] = []; | |
| 245 | const off = app.bus.subscribe((e) => void events.push(e)); | |
| 246 | tracked.length = 0; | |
| 247 | expect(screen.key?.(press("a"))).toBe(true); | |
| 248 | await Promise.all(tracked); | |
| 249 | off(); | |
| 250 | expect(stateOf(stale.number)).toBe("closed"); | |
| 251 | expect(toasts.join(" ")).not.toContain("Refused"); | |
| 252 | expect(toasts.length).toBe(1); | |
| 253 | expect(events.some((e) => e.kind.endsWith("closed") && e.repoId === repo.id)).toBe(true); | |
| 254 | expect(needsStack(ctx(), alice).some((n) => n.link.endsWith(`/pulls/${stale.number}`))).toBe(false); | |
| 255 | }); | |
| 256 | ||
| 257 | test("the terminal's sentence counts the crew's runs as the MCP tool does, not a fixed idle", () => { | |
| 258 | createAppRecord(app.db, { repoId: repo.id, slug: "open", access: "public" }); | |
| 259 | expect(runningFor(session(alice))).toBe(crewNow({ app }, alice).filter((r) => r.run !== null).length); | |
| 260 | const before = runningFor(session(alice)); | |
| 261 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: pullId, env: "", reason: "work" }); | |
| 262 | claimRun(app.db, run.id); | |
| 263 | expect(runningFor(session(alice))).toBe(before + 1); | |
| 264 | expect(forgeSays([], runningFor(session(alice)))).toContain(`the crew has ${before + 1}`); | |
| 265 | finishRun(app.db, run.id, { state: "ok", summary: "ok" }); | |
| 266 | expect(runningFor(session(alice))).toBe(before); | |
| 267 | expect(runningFor(session(undefined))).toBe(0); | |
| 268 | }); |
test/mcp.test.ts +1
| @@ -300,6 +300,7 @@ describe("the forge over MCP", () => { | ||
| 300 | 300 | "flag_set", |
| 301 | 301 | "economics", |
| 302 | 302 | "follow_mode", |
| 303 | "forge", | |
| 303 | 304 | "fork", |
| 304 | 305 | "forks", |
| 305 | 306 | "fork_tree", |
test/palette-roster.test.ts +3
| @@ -48,6 +48,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 48 | 48 | ["home", "Home", "your repositories"], |
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["explore-feed", "Explore feed", "Atom address"], |
| 51 | ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"], | |
| 51 | 52 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 52 | 53 | ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"], |
| 53 | 54 | ["pack-repos", "Pack repositories", "which repositories /packs lists"], |
| @@ -213,6 +214,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 213 | 214 | ["home", "Home", "your repositories"], |
| 214 | 215 | ["explore", "Explore", "on the web"], |
| 215 | 216 | ["explore-feed", "Explore feed", "Atom address"], |
| 217 | ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"], | |
| 216 | 218 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 217 | 219 | ["send-change", "Send a change", "fork, patch, pull: no forge of your own"], |
| 218 | 220 | ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"], |
| @@ -303,6 +305,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 303 | 305 | ["home", "Home", "your repositories"], |
| 304 | 306 | ["explore", "Explore", "on the web"], |
| 305 | 307 | ["explore-feed", "Explore feed", "Atom address"], |
| 308 | ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"], | |
| 306 | 309 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 307 | 310 | ["send-change", "Send a change", "fork, patch, pull: no forge of your own"], |
| 308 | 311 | ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"], |
new above
offline