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

The crew page says in one line what the slot governor last did and why #11536

Merged opened this

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

Merged

scout/forge:scout/11459-the-crew-page-says-in-one-line-what-the → main 7 commits fork

  • Merged. scout/11459-the-crew-page-says-in-one-line-what-the was merged into main as 9860029.

Review

Sign in to review this change.
17 files changed +184 −17
docs/features/governor-line.md added +13
@@ -0,0 +1,13 @@
1 # The governor's line
2
3 The crew card (studio and repository crew page) and MCP `crew` (`governor.says`) say in one line what the slot governor last did and why: "Builders 7 → 3 at 20:41: 9 opened vs 2 landed", or "Builders back to 7 at 21:30" (UTC). Nothing is said until the slot count has changed once: the first reading is only a baseline.
4
5 The tick (`announceGovernor`, src/crew/governed.ts) feeds each reading through `governorStep` (src/crew/governor_log.ts) and keeps the result in the `governor_last` table of the app's sqlite file, so a restart keeps it.
6
7 ## Where to read it
8
9 - Page: the crew card's one Governor row (repository page and crew page) shows the line while the governor holds the crew below its slots; otherwise the current reason, and after a release "Builders back to 7 at 21:30" followed by the reason. It is one row, so the reason is not said twice.
10 - MCP: `crew` returns `governor.says` (empty until a change) beside `governor.why`.
11 - Terminal: the dashboard's repository row shows the same line (`governorLine`, src/crew/governed.ts) while the crew is held.
12
13 Deleting a repository deletes its `governor_last` row.
scripts/unreached-allow.json −1
@@ -1,5 +1,4 @@
11 [
2 "src/crew/governor_log.ts",
32 "src/flow/clog_words.ts",
43 "src/flow/conflict_pairs.ts",
54 "src/flow/shared_red.ts",
src/core/migration-pins/20261111160000-governor-last.pin added +1
@@ -0,0 +1,1 @@
1 efa01f230b5afaca9c7b611dadfd91e585170785ed5855c0ed2619ee3cd48005
src/core/migrations/20261111160000-governor-last.ts added +11
@@ -0,0 +1,11 @@
1 // The governor's last change of slot count (#11459), one row per repository, so the crew page can
2 // say what it did and why after a restart. A baseline is stored with from = to. Additive.
3 export default `
4 CREATE TABLE governor_last (
5 repo_id TEXT PRIMARY KEY,
6 from_slots INTEGER NOT NULL,
7 to_slots INTEGER NOT NULL,
8 why TEXT NOT NULL DEFAULT '',
9 at INTEGER NOT NULL
10 );
11 `;
src/core/repos.ts +1
@@ -1052,6 +1052,7 @@ export async function deleteRepo(
10521052 if (!removed.ok && present) return err("delete_failed");
10531053 // The doctor's rows carry `repo_id` without a foreign key (added columns on `maintenance_run`).
10541054 db.query("DELETE FROM maintenance_run WHERE duty = 'batch-doctor' AND repo_id = ?1").run(repo.id);
1055 db.query("DELETE FROM governor_last WHERE repo_id = ?1").run(repo.id);
10551056 db.query("DELETE FROM repo WHERE id = ?1").run(repo.id);
10561057 forgetRepoCursors(db, repo.id);
10571058 forgetListings(db);
src/crew/governed.ts +15 −1
@@ -10,6 +10,7 @@ import type { App } from "../app.ts";
1010 import { canSeeOwnerFacts, type Repo } from "../core/repos.ts";
1111 import type { User } from "../core/users.ts";
1212 import { readRepoFlow } from "../flow/repo_flow.ts";
13 import { governorSays, readLast, rememberGovernor } from "./governor_log.ts";
1314 import { governedSlots, type Governed } from "./governor.ts";
1415
1516 /** The governor's reading of this repository's own flow, against the operator's slots (#7644). */
@@ -22,6 +23,8 @@ export interface GovernorSeen extends Governed {
2223 readonly ceiling: number;
2324 /** True while the governor holds the crew below the operator's slots. */
2425 readonly held: boolean;
26 /** What the governor last did, in one line (#11459); empty until it has changed the slots. */
27 readonly says: string;
2528 }
2629
2730 /**
@@ -32,7 +35,17 @@ export interface GovernorSeen extends Governed {
3235 export function governorSeen(app: App, actor: User | undefined, repo: Repo, now: Date): GovernorSeen | undefined {
3336 if (repo.autonomy === "off" || !canSeeOwnerFacts(app.db, repo, actor)) return undefined;
3437 const g = governorFor(app, repo, now);
35 return { ...g, ceiling: repo.autonomySlots, held: g.slots < repo.autonomySlots };
38 return { ...g, ceiling: repo.autonomySlots, held: g.slots < repo.autonomySlots, says: governorSays(readLast(app.db, repo.id)) };
39 }
40
41 /**
42 * The governor in one line for every door (#11459): what it last did while the
43 * crew is held (that line carries the reason), else the current reason, with
44 * "back to N" followed by the reason it now gives.
45 */
46 export function governorLine(g: GovernorSeen): string {
47 if (g.says === "") return g.why;
48 return g.held ? g.says : `${g.says}. ${g.why}`;
3649 }
3750
3851 /** The governed number each repository was last seen at; unseen means the operator's slots. */
@@ -45,6 +58,7 @@ const seen = new Map<string, number>();
4558 */
4659 export function announceGovernor(app: App, repo: Repo, now: Date): string | undefined {
4760 const g = governorFor(app, repo, now);
61 rememberGovernor(app.db, repo.id, g, now.getTime());
4862 const before = seen.get(repo.id) ?? repo.autonomySlots;
4963 seen.set(repo.id, g.slots);
5064 if (g.slots !== before)
src/crew/governor_log.ts +21
@@ -4,6 +4,7 @@
44 * reading is recorded with from === to (a baseline, not a change).
55 */
66
7 import type { Database } from "bun:sqlite";
78 import type { Governed } from "./governor.ts";
89
910 export interface GovernorLast {
@@ -35,3 +36,23 @@ export function governorSays(last: GovernorLast | null): string {
3536 if (last.to > last.from) return `Builders back to ${last.to} at ${clock(last.at)}`;
3637 return `Builders ${last.from} → ${last.to} at ${clock(last.at)}: ${last.why}`;
3738 }
39
40 interface Row { from_slots: number; to_slots: number; why: string; at: number }
41
42 /** The remembered last change for a repository (kept in the app's sqlite file), or null before any reading. */
43 export function readLast(db: Database, repoId: string): GovernorLast | null {
44 const r = db.query("SELECT from_slots, to_slots, why, at FROM governor_last WHERE repo_id = ?1").get(repoId) as Row | null;
45 return r ? { from: r.from_slots, to: r.to_slots, why: r.why, at: r.at } : null;
46 }
47
48 /** Feeds one reading through `governorStep` and keeps the result; the line that says it. */
49 export function rememberGovernor(db: Database, repoId: string, governed: Governed, now: number): GovernorLast {
50 const prev = readLast(db, repoId);
51 const { last } = governorStep(prev, governed, now);
52 if (last !== prev)
53 db.query(
54 "INSERT INTO governor_last (repo_id, from_slots, to_slots, why, at) VALUES (?1, ?2, ?3, ?4, ?5) " +
55 "ON CONFLICT(repo_id) DO UPDATE SET from_slots = ?2, to_slots = ?3, why = ?4, at = ?5",
56 ).run(repoId, last.from, last.to, last.why, last.at);
57 return last;
58 }
src/http/mcp/tools/crew.ts +1 −1
@@ -15,7 +15,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
1515
1616 export const tool: Tool = {
1717 name: "crew",
18 doc: "`{ kind: \"week\" }` (forge admin): `{ days, members[{ username, merged, returned, spentUsd, idle }] }`, the last 7 days per agent; otherwise `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, `slots[{ role, slots, max, running, waiting, spentUsd, sharePct }]`, how much each may do at once now and today's spend (0 is paused, still on the crew), and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `seat` (planner, builder or reviewer) with `slots` (0 to 8, the planner 0 or 1) sets how much that seat may do at once, 0 pausing it, on the trail at once; `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`",
18 doc: "`{ kind: \"week\" }` (forge admin): `{ days, members[{ username, merged, returned, spentUsd, idle }] }`, the last 7 days per agent; otherwise `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, `slots[{ role, slots, max, running, waiting, spentUsd, sharePct }]`, how much each may do at once now and today's spend (0 is paused, still on the crew), and `governor { slots, ceiling, held, why, says }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `seat` (planner, builder or reviewer) with `slots` (0 to 8, the planner 0 or 1) sets how much that seat may do at once, 0 pausing it, on the trail at once; `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`",
1919 description: "List a repository's crew, or add or remove an agent. Plans first; admin token.",
2020 inputSchema: {
2121 type: "object",
src/tui/screens/dashboard.ts +2 −2
@@ -18,7 +18,7 @@ import { DEFAULT_WEIGHTS, frontWeights, type FrontWeights } from "../../core/fro
1818 import { exploreCount, exploreFiltered, exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, whyOf, type ExploreRow } from "../../actions/explore.ts";
1919 import { protectionNotice } from "../../kernel/protection.ts";
2020 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
21 import { governorSeen } from "../../crew/governed.ts";
21 import { governorLine, governorSeen } from "../../crew/governed.ts";
2222 import { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts";
2323 import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts";
2424 import { forgeHealth } from "../../kernel/guard.ts";
@@ -390,7 +390,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
390390 repo.autonomy === "autonomous" ? plannerHold(f.app, repo, now) : null;
391391 if (hold !== null) return [[repo.id, holdWords(hold)] as const];
392392 const governor = governorSeen(f.app, f.user, repo, now);
393 return governor?.held === true ? [[repo.id, governor.why] as const] : [];
393 return governor?.held === true ? [[repo.id, governorLine(governor)] as const] : [];
394394 }),
395395 );
396396 alarms =
src/web/studio/app.ts +2 −4
@@ -30,6 +30,7 @@ import {
3030 } from "../../core/runs.ts";
3131 import { autonomyStatus } from "../../crew/autonomy.ts";
3232 import { governorSeen } from "../../crew/governed.ts";
33 import { governorRows } from "./governor_rows.ts";
3334 import { repoDir } from "../../git/repos.ts";
3435 import type { Ctx } from "../../http/context.ts";
3536 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
@@ -434,10 +435,7 @@ export async function studioApp(
434435 </dd>
435436 <dt>Slots</dt>
436437 <dd>${autonomy.active} of ${autonomy.slots} in use</dd>
437 ${governor === undefined
438 ? null
439 : html`<dt>Governor</dt>
440 <dd>${governor.why}</dd>`}
438 ${governorRows(governor)}
441439 ${autonomy.paused
442440 ? html`<dt>Paused</dt>
443441 <dd>${pausedWords(autonomy.paused)}</dd>`
src/web/studio/crew.ts +2 −4
@@ -32,6 +32,7 @@ import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { seatsOf, type Seat } from "../../crew/seats.ts";
3333 import { autonomyStatus } from "../../crew/autonomy.ts";
3434 import { governorSeen } from "../../crew/governed.ts";
35 import { governorRows } from "./governor_rows.ts";
3536 import { crewOffInPreview } from "../../crew/preview.ts";
3637 import type { Ctx } from "../../http/context.ts";
3738 import { repoBase } from "../chrome.ts";
@@ -354,10 +355,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html
354355 </dd>
355356 <dt>Slots</dt>
356357 <dd>${status.active} of ${status.slots} in use</dd>
357 ${governor === undefined
358 ? null
359 : html`<dt>Governor</dt>
360 <dd>${governor.why}</dd>`}
358 ${governorRows(governor)}
361359 ${status.paused
362360 ? html`<dt>Paused</dt>
363361 <dd>
src/web/studio/governor_rows.ts added +10
@@ -0,0 +1,10 @@
1 /** The crew card's Governor row (#11459): one row, shared by the repository page and the crew page. */
2
3 import type { GovernorSeen } from "../../crew/governed.ts";
4 import { governorLine } from "../../crew/governed.ts";
5 import { html, type Html } from "../html.ts";
6
7 /** The `<dt>/<dd>` pair for the governor, or null while the crew is off or the reader may not see it. */
8 export function governorRows(governor: GovernorSeen | undefined): Html | null {
9 return governor === undefined ? null : html`<dt>Governor</dt><dd>${governorLine(governor)}</dd>`;
10 }
test/crew-governed-unit.test.ts +27 −2
@@ -20,7 +20,7 @@ import { addSubscription, setGrants } from "../src/core/subscriptions.ts";
2020 import { recordUsage } from "../src/core/usage.ts";
2121 import { createUser } from "../src/core/users.ts";
2222 import { repoPace } from "../src/crew/autonomy.ts";
23 import { announceGovernor, governorFor, governorSeen } from "../src/crew/governed.ts";
23 import { announceGovernor, governorFor, governorLine, governorSeen } from "../src/crew/governed.ts";
2424 import { governedSlots } from "../src/crew/governor.ts";
2525 import { readRepoFlow } from "../src/flow/repo_flow.ts";
2626 import { id } from "../src/kernel/id.ts";
@@ -274,6 +274,31 @@ test("governorSeen gates the governor alike for every door: off crew or a reader
274274 expect(governorSeen(app, user, repo, NOW)).toBeUndefined(); // autonomy off
275275 const on: Repo = { ...repo, autonomy: "assisted" };
276276 const seen = governorSeen(app, user, on, NOW);
277 expect(seen).toEqual({ ...governorFor(app, on, NOW), ceiling: 4, held: true });
277 expect(seen).toEqual({ ...governorFor(app, on, NOW), ceiling: 4, held: true, says: "" });
278278 expect(governorSeen(app, undefined, on, NOW)).toBeUndefined();
279279 });
280
281 test("announceGovernor remembers a change: governorSeen says it after, and nothing before (#11459)", async () => {
282 const { app, repo: r0, pull } = await forge(4);
283 const repo = { ...r0, autonomy: "assisted" as const };
284 const actor = app.db.query<any, []>("SELECT * FROM user LIMIT 1").get();
285 const user = (await import("../src/core/users.ts")).getUserById(app.db, actor.id)!;
286 expect(announceGovernor(app, repo, NOW)).toBeUndefined(); // baseline
287 expect(governorSeen(app, user, repo, NOW)!.says).toBe("");
288 flood(pull);
289 announceGovernor(app, repo, later(1));
290 const seenNow = governorSeen(app, user, repo, later(1))!;
291 expect(seenNow.says).toStartWith("Builders 4 → ");
292 expect(seenNow.says).toContain(seenNow.why);
293 expect(governorLine(seenNow)).toBe(seenNow.says);
294 });
295
296 test("deleting a repository forgets its governor line (#11459)", async () => {
297 const { app, repo, pull } = await forge(4);
298 announceGovernor(app, repo, NOW);
299 flood(pull);
300 announceGovernor(app, repo, later(1));
301 expect(app.db.query("SELECT 1 FROM governor_last WHERE repo_id = ?1").get(repo.id)).not.toBeNull();
302 await (await import("../src/core/repos.ts")).deleteRepo(app.db, app.reposRoot, repo);
303 expect(app.db.query("SELECT 1 FROM governor_last WHERE repo_id = ?1").get(repo.id)).toBeNull();
304 });
test/governor-log.test.ts +35
@@ -34,3 +34,38 @@ describe("governorStep", () => {
3434 expect(governorSays(null)).toBe("");
3535 });
3636 });
37
38 describe("the remembered last change", () => {
39 const { Database } = require("bun:sqlite");
40 const { default: sql } = require("../src/core/migrations/20261111160000-governor-last.ts");
41 const { readLast, rememberGovernor } = require("../src/crew/governor_log.ts");
42 const fresh = () => { const db = new Database(":memory:"); db.exec(sql); return db; };
43 test("a baseline is stored but says nothing; a change is remembered and worded", () => {
44 const db = fresh();
45 expect(governorSays(rememberGovernor(db, "r", { slots: 6, why: "w" }, at(14, 0)))).toBe("");
46 expect(readLast(db, "r")).toEqual({ from: 6, to: 6, why: "w", at: at(14, 0) });
47 rememberGovernor(db, "r", { slots: 4, why: "9 opened vs 2 landed" }, at(14, 2));
48 expect(governorSays(readLast(db, "r"))).toBe("Builders 6 → 4 at 14:02: 9 opened vs 2 landed");
49 });
50 test("a restart keeps it: the same file opened twice remembers the change", () => {
51 const { mkdtempSync, rmSync } = require("node:fs");
52 const { tmpdir } = require("node:os");
53 const { join } = require("node:path");
54 const dir = mkdtempSync(join(tmpdir(), "forge-gov-"));
55 const file = join(dir, "x.db");
56 try {
57 const one = new Database(file);
58 one.exec(sql);
59 rememberGovernor(one, "r", { slots: 6, why: "w" }, at(14, 0));
60 rememberGovernor(one, "r", { slots: 4, why: "x" }, at(14, 2));
61 one.close();
62 const two = new Database(file);
63 rememberGovernor(two, "r", { slots: 4, why: "y" }, at(15, 0));
64 expect(readLast(two, "r")?.at).toBe(at(14, 2));
65 expect(governorSays(readLast(two, "r"))).toBe("Builders 6 → 4 at 14:02: x");
66 two.close();
67 } finally {
68 rmSync(dir, { recursive: true, force: true });
69 }
70 });
71 });
test/governor-rows.test.ts added +19
@@ -0,0 +1,19 @@
1 import { expect, test } from "bun:test";
2 import { governorLine, type GovernorSeen } from "../src/crew/governed.ts";
3 import { governorRows } from "../src/web/studio/governor_rows.ts";
4
5 const seen = (over: Partial<GovernorSeen>): GovernorSeen => ({ slots: 3, ceiling: 7, held: true, says: "", why: "Builders 3: busy", ...over });
6
7 test("the card's Governor row shows the last change once, and the reason alone when there is none (#11459)", () => {
8 const said = String(governorRows(seen({ says: "Builders 7 → 3 at 20:41: 9 opened vs 2 landed" })));
9 expect(said).toContain("<dt>Governor</dt>");
10 expect(said).toContain("Builders 7 → 3 at 20:41: 9 opened vs 2 landed");
11 expect(said).not.toContain("Builders 3: busy");
12 expect(said).not.toContain("Last");
13 expect(String(governorRows(seen({})))).toContain("Builders 3: busy");
14 expect(governorRows(undefined)).toBeNull();
15 });
16
17 test("after a release the line says back-to and the current reason", () => {
18 expect(governorLine(seen({ held: false, says: "Builders back to 7 at 21:30", why: "Builders 7: quiet" }))).toBe("Builders back to 7 at 21:30. Builders 7: quiet");
19 });
test/mcp-crew.test.ts +13 −1
@@ -132,13 +132,25 @@ test("the listing carries the governor's slots and reason for a writer while the
132132 app.db.query("UPDATE repo SET autonomy = 'assisted', autonomy_slots = 4 WHERE name = 'game'").run();
133133 try {
134134 const on = (await crew(admin, {})).value.governor;
135 expect(on).toEqual({ slots: 4, ceiling: 4, held: false, why: "Builders 4: a quiet hour, 0 of 0 open pulls moving" });
135 expect(on).toEqual({ slots: 4, ceiling: 4, held: false, says: "", why: "Builders 4: a quiet hour, 0 of 0 open pulls moving" });
136136 expect((await crew(bob, {})).value.governor).toBeNull(); // bob cannot write to alice/game
137137 } finally {
138138 app.db.query("UPDATE repo SET autonomy = 'off' WHERE name = 'game'").run();
139139 }
140140 });
141141
142 test("the listing's governor carries what it last did once the slots changed (#11459)", async () => {
143 app.db.query("UPDATE repo SET autonomy = 'assisted', autonomy_slots = 4 WHERE name = 'game'").run();
144 const repoId = (app.db.query("SELECT id FROM repo WHERE name = 'game'").get() as any).id;
145 app.db.query("INSERT INTO governor_last (repo_id, from_slots, to_slots, why, at) VALUES (?1, 4, 2, '9 opened vs 2 landed', ?2)").run(repoId, Date.UTC(2026, 9, 26, 20, 41));
146 try {
147 expect((await crew(admin, {})).value.governor.says).toBe("Builders 4 → 2 at 20:41: 9 opened vs 2 landed");
148 } finally {
149 app.db.query("DELETE FROM governor_last WHERE repo_id = ?1").run(repoId);
150 app.db.query("UPDATE repo SET autonomy = 'off' WHERE name = 'game'").run();
151 }
152 });
153
142154 test("the crew answer carries the three seats, empty ones named", async () => {
143155 const r: any = await mcpTool(servers.url, admin, "crew", { repo: "alice/game" });
144156 const team = r.value.seats;
test/tui-dashboard-governor.test.ts +11 −1
@@ -6,7 +6,7 @@ import { join } from "node:path";
66 import { createApp, type App as Forge } from "../src/app.ts";
77 import { createRepo, getRepo } from "../src/core/repos.ts";
88 import type { User } from "../src/core/users.ts";
9 import { governorSeen } from "../src/crew/governed.ts";
9 import { announceGovernor, governorSeen } from "../src/crew/governed.ts";
1010 import { id } from "../src/kernel/id.ts";
1111 import { createForgeApp } from "../src/tui/forge.ts";
1212 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -65,9 +65,19 @@ test("a flooded repository's row says the governor's reason; one at its ceiling
6565 }
6666 const g = governorSeen(forge, alice, repo, new Date())!;
6767 expect(g.held).toBe(true);
68 expect(g.says).toBe("");
6869 expect(g.why).toStartWith("Builders 4 → 1:");
6970 const screen = await firstScreen(alice);
7071 expect(screen).toContain(g.why);
7172 expect(screen).toContain("the calm one");
7273 expect(screen).not.toContain("the flooded one");
7374 });
75
76 test("once the governor has changed the slots the row says the line (#11459)", async () => {
77 const repo = getRepo(forge.db, "alice", "flooded")!;
78 forge.db.run("INSERT INTO governor_last (repo_id, from_slots, to_slots, why, at) VALUES (?1, 4, 4, 'w', 0)", [repo.id]);
79 announceGovernor(forge, repo, new Date());
80 const g = governorSeen(forge, alice, repo, new Date())!;
81 expect(g.says).toStartWith("Builders 4 → 1 at ");
82 expect(await firstScreen(alice)).toContain(g.says.slice(0, 60));
83 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs