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

Merged opened this

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

Merged

scout/forge:scout/11464-talk-s-hourly-check-in-names-the-release → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/11464-talk-s-hourly-check-in-names-the-release was merged into main as 310a56b.

Review

Sign in to review this change.
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
88 - **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`).
99 - **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.
1010
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);

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).

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";
1212 import { askLinesFor } from "../web/studio/asks.ts";
1313 import type { RoutineKind } from "../core/talk_routines.ts";
1414 import type { User } from "../core/users.ts";
15 import { storyFacts, type Story } from "./routine_facts_story.ts";
1516
1617 export const LINES_MAX = 3;
1718
18 export interface Facts {
19 export interface Facts extends Story {
1920 /** The forge's own words for what is wrong, empty when healthy. */
2021 readonly health: readonly string[];
2122 readonly redMain: readonly string[];
@@ -41,6 +42,17 @@ export interface Report {
4142 }
4243
4344 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
4456 const holdsText = (holds: Readonly<Record<string, number>>): string => {
4557 const names = Object.entries(holds).sort(([a], [b]) => a.localeCompare(b));
4658 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 {
5365 const lines = [
5466 trouble.length === 0 ? "Forge healthy, trunk green." : `Needs you: ${trouble.slice(0, 2).join("; ")}.`,
5567 `${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)}`}.`,
5769 ];
5870 return { lines, needs: trouble.length > 0 || f.stuckRuns > 0 };
5971 }
@@ -75,7 +87,7 @@ export function compose(kind: RoutineKind, f: Facts): Report {
7587 const DAY = 86_400_000;
7688
7789 /** 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> {
7991 const db = app.db;
8092 const iso = (ms: number): string => new Date(ms).toISOString();
8193 const vitals = forgeVitals(app);
@@ -88,7 +100,9 @@ export function facts(app: App, user: User, now: number): Facts {
88100 holds[r.name] = r.n;
89101 const count = (sql: string, ...args: string[]): number => db.query<{ n: number }, string[]>(sql).get(...args)?.n ?? 0;
90102 const accounts = newAccountLines(db, user, now);
103 const story = await storyFacts(app, sinceMs);
91104 return {
105 ...story,
92106 health: vitals.ok ? [] : vitals.needs.slice(0, 3),
93107 redMain: redLines(app, user, now).map((l) => l.line),
94108 asks: askLinesFor(db, user).length,
src/crew/routines.ts +1 −1
@@ -41,7 +41,7 @@ export interface Posted {
4141 export async function runRoutine(app: App, user: User, kind: RoutineKind, now: Date, send: Sender = post): Promise<Posted> {
4242 const row = listRoutines(app.db, user.id).find((r) => r.kind === kind)!;
4343 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 row.lastAt, with no test. Add one that runs the check-in twice and shows the second does not repeat the first's releases and pillars.

4545 appendTurn(app.db, user.id, thread.id, { role: "agent", text: routineText(kind, report) });
4646 // Marked before the push: a dead endpoint must not make the routine run again.
4747 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";
1515 import { addMemory, editMemory, listMemory, memoryBrief, removeMemory, rulesOf, seedMemory, STARTING_RULES } from "../src/core/talk_memory.ts";
1616 import { due, listRoutines, ROUTINE_KINDS, setRoutine } from "../src/core/talk_routines.ts";
1717 import { compose, LINES_MAX, type Facts } from "../src/crew/routine_report.ts";
18 import { releasesSince } from "../src/crew/routine_facts_story.ts";
1819 import { runDue, runRoutine } from "../src/crew/routines.ts";
1920 import { setNotifyUrl } from "../src/crew/notify.ts";
2021 import { conversationBrief } from "../src/crew/conversation_brief.ts";
@@ -49,7 +50,7 @@ afterAll(async () => {
4950 await rm(dir, { recursive: true, force: true });
5051 });
5152
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: [] };
5354
5455 describe("memory", () => {
5556 test("is the person's own, bounded, and in the brief", () => {
@@ -132,6 +133,22 @@ describe("schedules", () => {
132133 });
133134
134135 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 });
135152 test("every report is at most three lines, and only trouble needs the person", () => {
136153 for (const kind of ["checkin", "friends", "paper"] as const) {
137154 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs