Public: anyone can read this; the crew can change it.
Talk's hourly check-in names the releases cut since the last one and the pillars that moved #11470
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11464-talk-s-hourly-check-in-names-the-release → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +195
−6
docs/features/talk-captain.md +3 −1
| @@ -8,4 +8,6 @@ Talk is where the forge's owner steers it. What the owner used to do in a Claude | ||
| 8 | 8 | - **Resume.** A thread resumes its Claude session (#10457). When the last turn left the context 85% full, the next turn starts a fresh session from the thread's stored record and the memory, and the thread says so (`CONTEXT_RENEWED_LINE`). |
| 9 | 9 | - **Terminal** (`tui/screens/captain.ts`, palette › Captain, owner only). The rules, then the three routines, in one list. `a` adds a rule; `enter` edits the rule or the routine's schedule under the cursor; `d` deletes a rule; `space` turns a routine on or off; `p` posts a routine now; `r` reads again. A schedule is typed as minutes, `45` for every 45 minutes (15 to 1440), or `at 7` (also `daily 7`) for daily at 07:00 UTC. |
| 10 | 10 | |
| 11 | Not here yet: the check-in names health, red trunk, asks, crew holds, grokbot pulls and merges; it does not yet report releases, pillar movement or child forges stuck. | |
| 11 | The check-in also names the releases cut since the last one and the pillars that merged work (`crew/routine_facts_story.ts`), in one clause of its third line, since the last check-in (`lastAt`; the git changelog is cached by head oid, so this costs a `rev-parse` between releases); it never reaches the phone for that alone. | |
| 12 | ||
| 13 | Not here yet: child forges stuck. |
src/crew/routine_facts_story.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** | |
| 2 | * What shipped since the last check-in (#11464): the release versions cut and | |
| 3 | * the pillars whose merged count rose. Read from what the changelog and the | |
| 4 | * pillar map already read; `compose` words them, nothing here writes prose. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { publicForgeRepo } from "../core/forge_repo.ts"; | |
| 9 | import { mergedByPillar, pillarsOf, servedBy } from "../core/pillars.ts"; | |
| 10 | import { readChangelog } from "../release/changelog.ts"; | |
| 11 | import { repoDir } from "../git/repos.ts"; | |
| 12 | ||
| 13 | export interface Story { | |
| 14 | /** Versions cut since the last check-in, oldest first. */ | |
| 15 | readonly releases: readonly string[]; | |
| 16 | /** Pillars that merged work since then, with how many pulls. */ | |
| 17 | readonly pillarsMoved: readonly { readonly pillar: string; readonly merged: number }[]; | |
| 18 | } | |
| 19 | ||
| 20 | export const NO_STORY: Story = { releases: [], pillarsMoved: [] }; | |
| 21 | ||
| 22 | /** Pure: releases newer than `sinceMs`, oldest first. */ | |
| 23 | export function releasesSince(entries: readonly { version: string; date: string }[], sinceMs: number): string[] { | |
| 24 | return entries | |
| 25 | .filter((e) => Date.parse(e.date) > sinceMs) | |
| 26 | .map((e) => e.version) | |
| 27 | .reverse(); | |
| 28 | } | |
| 29 | ||
| 30 | /** The story since `sinceMs`; empty when the forge has no public source repository. */ | |
| 31 | export async function storyFacts(app: App, sinceMs: number): Promise<Story> { | |
| 32 | const repo = publicForgeRepo(app); | |
| 33 | if (repo === undefined) return NO_STORY; | |
| 34 | const entries = await readChangelog(repoDir(app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch); | |
| 35 | const set = pillarsOf(app.db, repo); | |
| 36 | const moved: { pillar: string; merged: number }[] = []; | |
| 37 | if (set.pillars.length > 0) { | |
| 38 | for (const m of mergedByPillar(app.db, repo.id, set, servedBy(app.db, set), new Date(sinceMs).toISOString())) { | |
| 39 | if (m.pillar !== null && m.merged > 0) moved.push({ pillar: m.pillar, merged: m.merged }); | |
| 40 | } | |
| 41 | moved.sort((x, y) => y.merged - x.merged); | |
| 42 | } | |
| 43 | return { releases: releasesSince(entries, sinceMs), pillarsMoved: moved }; | |
| 44 | } |
src/crew/routine_report.ts +17 −3
| @@ -12,10 +12,11 @@ import { redLines } from "./notify.ts"; | ||
| 12 | 12 | import { askLinesFor } from "../web/studio/asks.ts"; |
| 13 | 13 | import type { RoutineKind } from "../core/talk_routines.ts"; |
| 14 | 14 | import type { User } from "../core/users.ts"; |
| 15 | import { storyFacts, type Story } from "./routine_facts_story.ts"; | |
| 15 | 16 | |
| 16 | 17 | export const LINES_MAX = 3; |
| 17 | 18 | |
| 18 | export interface Facts { | |
| 19 | export interface Facts extends Story { | |
| 19 | 20 | /** The forge's own words for what is wrong, empty when healthy. */ |
| 20 | 21 | readonly health: readonly string[]; |
| 21 | 22 | readonly redMain: readonly string[]; |
| @@ -41,6 +42,17 @@ export interface Report { | ||
| 41 | 42 | } |
| 42 | 43 | |
| 43 | 44 | const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`; |
| 45 | /** The one short clause for what shipped, or nothing when nothing moved. */ | |
| 46 | export function storyText(f: Story): string { | |
| 47 | const parts: string[] = []; | |
| 48 | if (f.releases.length > 0) { | |
| 49 | const shown = f.releases.slice(-3).join(", "); | |
| 50 | parts.push(`released ${f.releases.length > 3 ? `${f.releases.length} (to ${f.releases.at(-1)})` : shown}`); | |
| 51 | } | |
| 52 | if (f.pillarsMoved.length > 0) parts.push(`moved: ${f.pillarsMoved.slice(0, 3).map((p) => `${p.pillar} ${p.merged}`).join(", ")}`); | |
| 53 | return parts.join("; "); | |
| 54 | } | |
| 55 | ||
| 44 | 56 | const holdsText = (holds: Readonly<Record<string, number>>): string => { |
| 45 | 57 | const names = Object.entries(holds).sort(([a], [b]) => a.localeCompare(b)); |
| 46 | 58 | return names.length === 0 ? "the crew holds nothing" : `crew: ${names.map(([n, c]) => `${n} ${c}`).join(", ")}`; |
| @@ -53,7 +65,7 @@ export function compose(kind: RoutineKind, f: Facts): Report { | ||
| 53 | 65 | const lines = [ |
| 54 | 66 | trouble.length === 0 ? "Forge healthy, trunk green." : `Needs you: ${trouble.slice(0, 2).join("; ")}.`, |
| 55 | 67 | `${plural(f.asks, "ask")} for you, ${plural(f.grokbotPulls, "grokbot pull")} open, ${plural(f.mergedLastHour, "merge")} in the last hour.`, |
| 56 | `${holdsText(f.holds)}${f.stuckRuns > 0 ? `; ${plural(f.stuckRuns, "run")} stuck` : ""}.`, | |
| 68 | `${holdsText(f.holds)}${f.stuckRuns > 0 ? `; ${plural(f.stuckRuns, "run")} stuck` : ""}${storyText(f) === "" ? "" : `; ${storyText(f)}`}.`, | |
| 57 | 69 | ]; |
| 58 | 70 | return { lines, needs: trouble.length > 0 || f.stuckRuns > 0 }; |
| 59 | 71 | } |
| @@ -75,7 +87,7 @@ export function compose(kind: RoutineKind, f: Facts): Report { | ||
| 75 | 87 | const DAY = 86_400_000; |
| 76 | 88 | |
| 77 | 89 | /** Read the forge as `user`; admins only, since the lines name other people's work. */ |
| 78 | export function facts(app: App, user: User, now: number): Facts { | |
| 90 | export async function facts(app: App, user: User, now: number, sinceMs = now - 3_600_000): Promise<Facts> { | |
| 79 | 91 | const db = app.db; |
| 80 | 92 | const iso = (ms: number): string => new Date(ms).toISOString(); |
| 81 | 93 | const vitals = forgeVitals(app); |
| @@ -88,7 +100,9 @@ export function facts(app: App, user: User, now: number): Facts { | ||
| 88 | 100 | holds[r.name] = r.n; |
| 89 | 101 | const count = (sql: string, ...args: string[]): number => db.query<{ n: number }, string[]>(sql).get(...args)?.n ?? 0; |
| 90 | 102 | const accounts = newAccountLines(db, user, now); |
| 103 | const story = await storyFacts(app, sinceMs); | |
| 91 | 104 | return { |
| 105 | ...story, | |
| 92 | 106 | health: vitals.ok ? [] : vitals.needs.slice(0, 3), |
| 93 | 107 | redMain: redLines(app, user, now).map((l) => l.line), |
| 94 | 108 | asks: askLinesFor(db, user).length, |
src/crew/routines.ts +1 −1
| @@ -41,7 +41,7 @@ export interface Posted { | ||
| 41 | 41 | export async function runRoutine(app: App, user: User, kind: RoutineKind, now: Date, send: Sender = post): Promise<Posted> { |
| 42 | 42 | const row = listRoutines(app.db, user.id).find((r) => r.kind === kind)!; |
| 43 | 43 | const thread = captainThread(app, user.id, row.conversationId); |
| 44 | const report = compose(kind, facts(app, user, now.getTime())); | |
| 44 | const report = compose(kind, await facts(app, user, now.getTime(), row.lastAt === null ? undefined : Date.parse(row.lastAt))); | |
The window comes from |
||
| 45 | 45 | appendTurn(app.db, user.id, thread.id, { role: "agent", text: routineText(kind, report) }); |
| 46 | 46 | // Marked before the push: a dead endpoint must not make the routine run again. |
| 47 | 47 | markRan(app.db, user.id, kind, thread.id, now); |
test/talk-captain.test.ts +18 −1
| @@ -15,6 +15,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 15 | 15 | import { addMemory, editMemory, listMemory, memoryBrief, removeMemory, rulesOf, seedMemory, STARTING_RULES } from "../src/core/talk_memory.ts"; |
| 16 | 16 | import { due, listRoutines, ROUTINE_KINDS, setRoutine } from "../src/core/talk_routines.ts"; |
| 17 | 17 | import { compose, LINES_MAX, type Facts } from "../src/crew/routine_report.ts"; |
| 18 | import { releasesSince } from "../src/crew/routine_facts_story.ts"; | |
| 18 | 19 | import { runDue, runRoutine } from "../src/crew/routines.ts"; |
| 19 | 20 | import { setNotifyUrl } from "../src/crew/notify.ts"; |
| 20 | 21 | import { conversationBrief } from "../src/crew/conversation_brief.ts"; |
| @@ -49,7 +50,7 @@ afterAll(async () => { | ||
| 49 | 50 | await rm(dir, { recursive: true, force: true }); |
| 50 | 51 | }); |
| 51 | 52 | |
| 52 | const quiet: Facts = { health: [], redMain: [], asks: 0, holds: {}, grokbotPulls: 0, mergedLastHour: 0, newPeople: [], waiting: 0, stuckRuns: 0, mergedToday: 0, openedToday: 0 }; | |
| 53 | const quiet: Facts = { health: [], redMain: [], asks: 0, holds: {}, grokbotPulls: 0, mergedLastHour: 0, newPeople: [], waiting: 0, stuckRuns: 0, mergedToday: 0, openedToday: 0, releases: [], pillarsMoved: [] }; | |
| 53 | 54 | |
| 54 | 55 | describe("memory", () => { |
| 55 | 56 | test("is the person's own, bounded, and in the brief", () => { |
| @@ -132,6 +133,22 @@ describe("schedules", () => { | ||
| 132 | 133 | }); |
| 133 | 134 | |
| 134 | 135 | describe("what a routine says", () => { |
| 136 | test("the check-in names releases and pillars that moved, in one line, and never needs you for it", () => { | |
| 137 | const told = compose("checkin", { ...quiet, releases: ["0.7.9", "0.7.10"], pillarsMoved: [{ pillar: "Trust", merged: 2 }] }); | |
| 138 | expect(told.lines.filter((l) => l.includes("0.7.10"))).toHaveLength(1); | |
| 139 | expect(told.lines.join(" ")).toContain("released 0.7.9, 0.7.10; moved: Trust 2"); | |
| 140 | expect(told.lines.length).toBeLessThanOrEqual(LINES_MAX); | |
| 141 | expect(told.needs).toBe(false); | |
| 142 | const many = compose("checkin", { ...quiet, releases: ["1", "2", "3", "4", "5"], pillarsMoved: [] }); | |
| 143 | expect(many.lines.join(" ")).toContain("released 5 (to 5)"); | |
| 144 | expect(many.lines).toHaveLength(3); | |
| 145 | expect(compose("checkin", quiet).lines.join(" ")).not.toMatch(/released|moved/); | |
| 146 | }); | |
| 147 | test("releases since an instant, oldest first", () => { | |
| 148 | const entries = [{ version: "0.7.3", date: "2026-11-10T11:00:00Z" }, { version: "0.7.2", date: "2026-11-10T10:00:00Z" }, { version: "0.7.1", date: "2026-11-10T08:00:00Z" }]; | |
| 149 | expect(releasesSince(entries, Date.parse("2026-11-10T09:00:00Z"))).toEqual(["0.7.2", "0.7.3"]); | |
| 150 | expect(releasesSince(entries, Date.parse("2026-11-11T00:00:00Z"))).toEqual([]); | |
| 151 | }); | |
| 135 | 152 | test("every report is at most three lines, and only trouble needs the person", () => { |
| 136 | 153 | for (const kind of ["checkin", "friends", "paper"] as const) { |
| 137 | 154 | expect(compose(kind, quiet).lines.length).toBeLessThanOrEqual(LINES_MAX); |
test/talk-checkin-story.test.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * The check-in's story (#11464): the releases cut and the pillars that merged | |
| 3 | * work since the last check-in, gathered from a real forge. The window is the | |
| 4 | * routine's own `lastAt`, so a second run does not repeat the first. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { listTurns } from "../src/core/conversations.ts"; | |
| 16 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { runRoutine } from "../src/crew/routines.ts"; | |
| 19 | import { storyFacts } from "../src/crew/routine_facts_story.ts"; | |
| 20 | import { listRoutines } from "../src/core/talk_routines.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | let dir: string; | |
| 26 | let app: App; | |
| 27 | let alice: User; | |
| 28 | let repo: Repo; | |
| 29 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 30 | const T = Date.now(); | |
| 31 | const H = 3_600_000; | |
| 32 | ||
| 33 | const issue = (title: string) => { | |
| 34 | const o = openIssue(app, alice, fresh(), { title }); | |
| 35 | if (!o.ok) throw new Error(String(o.error)); | |
| 36 | return o.value; | |
| 37 | }; | |
| 38 | const land = async (name: string, closes: number) => { | |
| 39 | const b = await commitToBranch(app, alice, fresh(), { branch: `f/${name}`, base: "main", message: name, files: [{ path: `${name}.md`, content: name }] }); | |
| 40 | if (!b.ok) throw new Error(JSON.stringify(b.error)); | |
| 41 | const p = await openPull(app, alice, fresh(), { title: `Land ${name}`, body: `Closes #${closes}`, baseBranch: "main", headBranch: `f/${name}` }); | |
| 42 | if (!p.ok) throw new Error(String(p.error)); | |
| 43 | const m = await mergePull(app, alice, fresh(), p.value, "squash"); | |
| 44 | if (!m.ok) throw new Error(JSON.stringify(m.error)); | |
| 45 | return p.value.id; | |
| 46 | }; | |
| 47 | const release = async (version: string) => { | |
| 48 | const r = await commitToBranch(app, alice, fresh(), { message: `Release ${version}: Something (#1)`, files: [{ path: `v${version}.txt`, content: version }] }); | |
| 49 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 50 | }; | |
| 51 | ||
| 52 | beforeAll(async () => { | |
| 53 | dir = await mkdtemp(join(tmpdir(), "forge-story-")); | |
| 54 | app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/engine" }); | |
| 55 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 56 | if (!u.ok) throw new Error(u.error); | |
| 57 | alice = u.value; | |
| 58 | const made = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | repo = made.value; | |
| 61 | const seed = await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "README.md", content: "# e\n" }] }); | |
| 62 | if (!seed.ok) throw new Error(JSON.stringify(seed.error)); | |
| 63 | const trust = issue("P0 Trust: agents contained"); | |
| 64 | const a = issue("Sandbox"); | |
| 65 | const b = issue("Review"); | |
| 66 | for (const i of [a, b]) if (!linkIssue(app, alice, fresh(), trust, i).ok) throw new Error("link"); | |
| 67 | const early = await land("early", a.number); | |
| 68 | const late = await land("late", b.number); | |
| 69 | const pin = (id: string, at: number) => app.db.query("UPDATE pull SET merged_at = ?1 WHERE issue_id = ?2").run(new Date(at).toISOString(), id); | |
| 70 | pin(early, T - 5 * H); | |
| 71 | pin(late, T - 60_000); | |
| 72 | await release("0.1.1"); | |
| 73 | await release("0.1.2"); | |
| 74 | }, 60_000); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | app.close(); | |
| 78 | await rm(dir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("storyFacts", () => { | |
| 82 | test("counts pulls merged inside the window by pillar, and not the one before it", async () => { | |
| 83 | const s = await storyFacts(app, T - H); | |
| 84 | expect(s.pillarsMoved).toEqual([{ pillar: "P0 Trust", merged: 1 }]); | |
| 85 | expect((await storyFacts(app, T - 6 * H)).pillarsMoved).toEqual([{ pillar: "P0 Trust", merged: 2 }]); | |
| 86 | expect((await storyFacts(app, T + H)).pillarsMoved).toEqual([]); | |
| 87 | }); | |
| 88 | test("names the releases cut after the window opened, oldest first", async () => { | |
| 89 | expect((await storyFacts(app, T - H)).releases).toEqual(["0.1.1", "0.1.2"]); | |
| 90 | expect((await storyFacts(app, T + H)).releases).toEqual([]); | |
| 91 | }); | |
| 92 | }); | |
| 93 | ||
| 94 | describe("runRoutine's window is the last check-in", () => { | |
| 95 | test("the second run does not repeat the first's releases and pillars", async () => { | |
| 96 | const first = new Date(T + 60_000); | |
| 97 | const one = await runRoutine(app, alice, "checkin", first, async () => true); | |
| 98 | const turnsOf = (id: string) => { | |
| 99 | const t = listTurns(app.db, alice.id, id); | |
| 100 | if (!t.ok) throw new Error(t.error); | |
| 101 | return t.value; | |
| 102 | }; | |
| 103 | const text = (id: string) => turnsOf(id).map((t) => t.text).join("\n"); | |
| 104 | expect(text(one.conversationId)).toContain("released 0.1.1, 0.1.2; moved: P0 Trust 1"); | |
| 105 | expect(listRoutines(app.db, alice.id).find((r) => r.kind === "checkin")!.lastAt).toBe(first.toISOString()); | |
| 106 | const two = await runRoutine(app, alice, "checkin", new Date(T + 120_000), async () => true); | |
| 107 | const turns = turnsOf(two.conversationId); | |
| 108 | const last = turns[turns.length - 1]!.text; | |
| 109 | expect(last).toContain("Forge check-in"); | |
| 110 | expect(last).not.toMatch(/released|moved/); | |
| 111 | }); | |
| 112 | }); |
Untested: nothing proves pillarsMoved is filled from mergedByPillar with the right
since. Add a db-backed test (seed merged pulls in a pillar, one before the window, one inside).