Chief's PR ledger, dispatch guard, build board and quieter transcripts for motor-fix

hooks/register.js 39 lines1// The one hooks module (hooks.json): each part's hooks live in its own file and
2// are wired here, in the order contracts/denials.md gives. A hook may be
3// imported; `$` and `on` never cross a file as arguments, and each event
4// takes one hook without a matcher, so its parts share that hook's file.
5
6import { bandHook, paneHook } from './board.js';
7import {
8 bashHook,
9 contextHook,
10 endHook,
11 startHook,
12 submitHook,
13} from './chief-control.js';
14import { agentHook } from './dispatch-guard.js';
15import { CARD, PANE } from './lib/surface.js';
16import { cardHook, cardPaneHook } from './owner-card.js';
17import { resultHook } from './quiet-transcripts.js';
18import { measureHook } from './usage-guard.js';
19import { turnHook } from './worker-report.js';
20
21// A gating hook that fails lets the call through: the mod never blocks on its own error.
22const failOpen = (_$, e, next) => next(e);
23
24export function register(on) {
25 on('tool.call', { tool: 'Agent' }, agentHook).catch(failOpen);
26 on('session.start', startHook);
27 on('session.end', endHook);
28 on('tool.call', { tool: 'Bash' }, bashHook).catch(failOpen);
29 on('prompt.submit', submitHook).catch(failOpen);
30 on('prompt.context', contextHook).catch(failOpen);
31 on('ui.render', { component: 'AbovePrompt' }, bandHook);
32 on('ui.render', { component: 'Pane', requestId: PANE.id }, paneHook);
33 on('tool.call', { tool: 'AskUserQuestion' }, cardHook).catch(failOpen);
34 on('ui.render', { component: 'Pane', requestId: CARD.id }, cardPaneHook);
35 on('ui.render', { component: 'ToolResult' }, resultHook);
36 on('session.measure', measureHook);
37 on('turn.complete', turnHook);
38}
39hooks/board.js 146 lines1// Part 4, the build board: a pane and an above-prompt
2// band in Chief, one row per PR and four buttons, each running one repo
3// command as an argument list. Fast-forward main never pushes.
4import { role } from './chief-control.js';
5import { boardRow, holding } from './lib/ledger.js';
6import { canDraw } from './lib/surface.js';
7
8// The pane opens from Chief control's session.start: one hook per event.
9
10const TIMEOUT_MS = 5 * 60_000;
11
12// The last button's output, shown in the pane until the next press.
13let output = '';
14
15// Its own copy, as in chief-control.js: `$` does not cross a file.
16async function readRows($) {
17 const keys = (await $.store.keys()).filter((k) => k.startsWith('pr:'));
18 const rows = [];
19 for (const k of keys) rows.push(await $.store.get(k));
20 return rows.filter(Boolean).sort((a, b) => a.pr - b.pr);
21}
22
23async function context($) {
24 return {
25 agents: await $.agent.list(),
26 now: await $.clock.now(),
27 session: await $.session.id(),
28 };
29}
30
31export const rowText = (r) =>
32 `#${r.pr} ${r.stage} lap ${r.lap} ci ${r.ci} holder ${r.holder} age ${r.age}`;
33
34// Ready (QA), offered a tail for its current head (CI and the QA run both
35// passed), and nobody holds it.
36async function tailable($, r, ctx) {
37 if (r.stage !== 'QA' || r.ci !== 'pass' || holding(r, ctx)) return false;
38 return Boolean(await $.store.get(`offer:${r.pr}:${r.head ?? 'nohead'}`));
39}
40
41function show($, text) {
42 output = text.trim();
43 $.ui.invalidate('ui.render');
44}
45
46async function runShown($, argv) {
47 const r = await $.process.run(argv, {
48 cwd: role.main,
49 timeoutMs: TIMEOUT_MS,
50 });
51 show($, r.exitCode === 0 ? r.stdout : `exit ${r.exitCode}\n${r.stderr}`);
52 return r;
53}
54
55async function fastForward($) {
56 const r = await runShown($, [
57 'git',
58 '-C',
59 role.main,
60 'merge',
61 '--ff-only',
62 'origin/main',
63 ]);
64 await $.ui.toast(
65 r.exitCode === 0
66 ? 'motorfix-chief: main fast-forwarded'
67 : 'motorfix-chief: main not fast-forwarded',
68 );
69}
70
71async function tailReady($) {
72 const ctx = await context($);
73 const ready = [];
74 for (const r of await readRows($)) {
75 if (await tailable($, r, ctx)) ready.push(r);
76 }
77 if (!ready.length) {
78 await $.ui.toast('motorfix-chief: no ready PR to tail');
79 return;
80 }
81 const lines = ready.map((r) => `tail #${r.pr} after QA run ${r.qaRun}`);
82 await $.prompt.submit({
83 text: `motorfix-chief: tail these ready PRs, one task-runner each.\n${lines.join('\n')}`,
84 });
85}
86
87function buttons($, Button) {
88 return [
89 h(Button, {
90 key: 'status',
91 label: 'Status',
92 onPress: () =>
93 runShown($, ['node', '.claude/scripts/status.mjs']).catch(() => {}),
94 }),
95 h(Button, {
96 key: 'watch',
97 label: 'Watch pass',
98 onPress: () =>
99 runShown($, ['node', '.claude/scripts/watch.mjs']).catch(() => {}),
100 }),
101 h(Button, {
102 key: 'ff-main',
103 label: 'Fast-forward main',
104 onPress: () => fastForward($).catch(() => {}),
105 }),
106 h(Button, {
107 key: 'tail',
108 label: 'Tail ready PRs',
109 onPress: () => tailReady($).catch(() => {}),
110 }),
111 ];
112}
113
114async function rowsDrawn($, Text) {
115 const ctx = await context($);
116 return (await readRows($)).map((r) => h(Text, {}, rowText(boardRow(r, ctx))));
117}
118
119const drawing = (e) => role.chief && canDraw(e.surface, role.remote);
120
121export async function bandHook($, e, next) {
122 if (!drawing(e) || e.props.hasSurvey) return next(e);
123 const { Box, Button, Text } = $.ui.resolve(e);
124 const rows = await rowsDrawn($, Text);
125 if (!rows.length) return next(e);
126 return h(
127 Box,
128 { flexDirection: 'column' },
129 ...rows,
130 h(Box, { flexDirection: 'row', gap: 1 }, ...buttons($, Button)),
131 );
132}
133
134export async function paneHook($, e, next) {
135 if (!drawing(e)) return next(e);
136 const { Box, Button, Text } = $.ui.resolve(e);
137 const rows = await rowsDrawn($, Text);
138 return h(
139 Box,
140 { flexDirection: 'column' },
141 ...(rows.length ? rows : [h(Text, {}, 'No PR in the ledger.')]),
142 h(Box, { flexDirection: 'row', gap: 1 }, ...buttons($, Button)),
143 ...(output ? [h(Text, { dimColor: true }, output)] : []),
144 );
145}
146hooks/chief-control.js 287 lines1// Part 1, Chief Control: the ledger from Bash calls, the ledger in context after
2// a compaction, the 15-minute watch gate, the tail offer and the chief key, and
3// part 3's forward, which shares its prompt.submit hook.
4// Agent dispatches reach the ledger through the guard.
5import {
6 blank,
7 DEAD,
8 deadHolder,
9 FREED,
10 key,
11 ledgerBlock,
12 merge,
13} from './lib/ledger.js';
14import { bashCall, ciLines, qaRunLine, taskNotification } from './lib/parse.js';
15import { fromRevParse, REV_PARSE } from './lib/role.js';
16import { stageCall } from './lib/stage.js';
17import { canDraw, PANE } from './lib/surface.js';
18
19const TICK_MS = 15 * 60_000;
20const GATE_TIMEOUT_MS = 10 * 60_000;
21const WATCH = '.claude/scripts/watch.mjs';
22const CLOSED = new Set(['merged', 'closed']);
23
24// This session's role (the board reads it too) and its background ci-waits
25// (task id → PR and run).
26export const role = {
27 chief: false,
28 cwd: null,
29 main: null,
30 remote: null,
31 surface: null,
32 ticking: false,
33};
34const waits = new Map();
35// The ci-wait calls' tool_use_ids, so Quiet transcripts (part 8) knows their results.
36export const ciWaitCalls = new Set();
37// Part 5: the stage this turn moved the PR to, and the PR a command named.
38export const turn = { pr: undefined, stage: undefined };
39
40function noteStage(command, r) {
41 const change = !r.isError && stageCall(command);
42 if (change) Object.assign(turn, change);
43 return r;
44}
45
46// Each part keeps its own copy: the engine hands `$` only to the hooks of the
47// file that registered them, so a helper taking `$` is not shared across files.
48async function readRows($) {
49 const keys = (await $.store.keys()).filter((k) => k.startsWith('pr:'));
50 const rows = [];
51 for (const k of keys) rows.push(await $.store.get(k));
52 return rows.filter(Boolean);
53}
54
55async function patch($, pr, fields) {
56 const row = (await $.store.get(key(pr))) ?? blank(pr);
57 const next = merge(row, fields, await $.clock.now());
58 await $.store.set(key(pr), next);
59 return next;
60}
61
62// One offer per PR and head, once CI and the QA run both finished green.
63async function offer($, pr, run) {
64 const row = await patch($, pr, { ci: 'pass', qaRun: run });
65 const id = `offer:${pr}:${row.head ?? 'nohead'}`;
66 if (await $.store.get(id)) return [];
67 await $.store.set(id, {
68 answer: null,
69 offeredAt: await $.clock.now(),
70 runId: run,
71 });
72 return [
73 `motorfix-chief: CI and QA run ${run} finished for PR #${pr}. Tail it now: dispatch a task-runner with "tail #${pr} after QA run ${run}".`,
74 ];
75}
76
77async function finished($, wait, passed) {
78 if (passed && wait.run) return offer($, wait.pr, wait.run);
79 await patch($, wait.pr, { ci: passed ? 'pass' : 'fail' });
80 return [];
81}
82
83const withContext = (r, lines) =>
84 lines.length ? { ...r, context: [...(r.context ?? []), ...lines] } : r;
85
86async function afterDispatch($, call, r) {
87 const line = qaRunLine(r.result?.stdout ?? '');
88 if (line && !r.isError) await patch($, call.pr, line);
89 return r;
90}
91
92// ci-wait prints nothing when every check (and the QA run, with --run) passed.
93// ci-wait --run exits 0 and prints "QA run: <conclusion>" once the run ends,
94// and lists any check that did not pass: only no lines, or a successful QA run
95// alone, is green.
96const green = (stdout) =>
97 ciLines(stdout).every((l) => l.name === 'QA run' && l.state === 'success');
98
99const CI_WAIT = '.claude/scripts/pr-test/ci-wait.mjs';
100const RECHECK_MS = 2 * 60_000;
101
102// A background wait's output never reaches the plugin: on its exit-0
103// notification ci-wait is asked again, and answers at once since all finished.
104// Its answer: true (green), false (red), or undefined when it could not tell.
105async function recheck($, wait) {
106 const argv = ['node', CI_WAIT, String(wait.pr)];
107 if (wait.run) argv.push('--run', String(wait.run));
108 const r = await $.process.run(argv, {
109 cwd: role.main ?? undefined,
110 timeoutMs: RECHECK_MS,
111 });
112 return r.exitCode === 0 ? green(r.stdout) : undefined;
113}
114
115async function afterCiWait($, call, r) {
116 if (r.result?.backgroundTaskId) {
117 waits.set(r.result.backgroundTaskId, call);
118 return r;
119 }
120 const passed =
121 !r.isError && !r.result?.interrupted && green(r.result?.stdout ?? '');
122 return withContext(r, await finished($, call, passed));
123}
124
125export async function bashHook($, e, next) {
126 const call = bashCall(e.command ?? '');
127 if (!call) return noteStage(e.command ?? '', await next(e));
128 if (call.kind === 'dispatch') return afterDispatch($, call, await next(e));
129 if (e.tool_use_id) ciWaitCalls.add(e.tool_use_id);
130 await patch($, call.pr, {
131 ci: 'pending',
132 ...(call.run ? { qaRun: call.run } : {}),
133 });
134 return afterCiWait($, call, await next(e));
135}
136
137// Part 3: a helper's result that reached this session
138// while the runner that spawned it still runs goes to that runner instead.
139// Here, not in a file of its own: one prompt.submit hook per plugin.
140async function forward($, id, text) {
141 const agents = await $.agent.list();
142 const helper = agents.find((a) => a.id === id);
143 const runner = agents.find((a) => a.id === helper?.parentId);
144 if (!runner || DEAD.has(runner.status)) return undefined;
145 const sent = await $.session.send({ text, to: { agentId: runner.id } });
146 if (!sent?.isDelivered) return undefined;
147 await $.store.set(`forward:${id}`, {
148 at: await $.clock.now(),
149 to: runner.id,
150 });
151 await $.ui.toast(
152 `motorfix-chief: forwarded ${id} to its runner ${runner.id}`,
153 );
154 return { drop: `motorfix-chief forwarded it to ${runner.id}` };
155}
156
157export async function submitHook($, e, next) {
158 const note = taskNotification(e.text ?? '');
159 const sent = note?.id && !e.agentId && (await forward($, note.id, e.text));
160 if (sent) return sent;
161 const wait = note && waits.get(note.id);
162 if (!wait) return next(e);
163 waits.delete(note.id);
164 const passed =
165 note.exitCode === 0 ? await recheck($, wait).catch(() => undefined) : false;
166 if (passed === undefined) {
167 const again = wait.run ? `${wait.pr} --run ${wait.run}` : `${wait.pr}`;
168 await $.ui.toast(
169 `motorfix-chief: could not re-read CI for #${wait.pr}; run ci-wait.mjs ${again} again`,
170 );
171 return next(e);
172 }
173 const lines = await finished($, wait, passed);
174 return withContext(await next(e), lines);
175}
176
177// Refresh the ledger from watch.mjs, then gate only with two or more active worktrees.
178async function refresh($) {
179 const r = await $.process.run(['node', WATCH, '--json'], {
180 cwd: role.main,
181 timeoutMs: 120_000,
182 });
183 if (r.exitCode !== 0) return 0;
184 const rows = JSON.parse(r.stdout).rows ?? [];
185 const gone = rows
186 .filter((w) => w.pr?.number && CLOSED.has(w.pr.state))
187 .map((w) => w.pr.number);
188 if (gone.length) {
189 // A merged or closed PR leaves the ledger with every tail offer it had.
190 const offers = (await $.store.keys()).filter((k) => k.startsWith('offer:'));
191 for (const pr of gone) {
192 await $.store.delete(key(pr));
193 for (const k of offers.filter((o) => o.startsWith(`offer:${pr}:`)))
194 await $.store.delete(k);
195 }
196 }
197 return rows.filter(
198 (w) =>
199 !w.main &&
200 ((w.pr && !CLOSED.has(w.pr.state)) || (w.holder && w.holder !== 'none')),
201 ).length;
202}
203
204// The host refuses a plugin prompt that begins with "/" (it would run as the
205// person's command), so the wake names the skill instead of typing it.
206export const wakeText = (stdout) =>
207 `motorfix-chief watch gate: run /speckit-watch for these items.\n${stdout.trim()}`;
208
209async function gate($) {
210 const r = await $.process.run(['node', WATCH, '--gate'], {
211 cwd: role.main,
212 timeoutMs: GATE_TIMEOUT_MS,
213 });
214 if (r.exitCode === 2) await $.prompt.submit({ text: wakeText(r.stdout) });
215 else if (r.exitCode !== 0)
216 await $.ui.toast(`motorfix-chief: watch gate exit ${r.exitCode}`);
217}
218
219async function tick($) {
220 if (role.ticking) return;
221 role.ticking = true;
222 try {
223 if ((await refresh($)) >= 2) await gate($);
224 } catch (err) {
225 await $.ui.toast(
226 `motorfix-chief: watch gate failed (${err?.message ?? err})`,
227 );
228 } finally {
229 role.ticking = false;
230 }
231}
232
233async function onStart($, e) {
234 role.remote = (await $.env.get('CLAUDE_CODE_REMOTE')) ?? null;
235 role.surface = e.surface ?? null;
236 role.cwd = e.cwd ?? null;
237 const r = await $.process.run(REV_PARSE, { cwd: e.cwd, timeoutMs: 10_000 });
238 Object.assign(role, fromRevParse(r.exitCode === 0 ? r.stdout : ''));
239 if (!role.chief) return;
240 await $.store.set('chief', {
241 sessionId: await $.session.id(),
242 startedAt: await $.clock.now(),
243 });
244 if (role.remote !== 'true') $.clock.every(TICK_MS, () => tick($));
245 // Part 4: the board's pane; one session.start hook per plugin.
246 if (canDraw(e.surface, role.remote)) await $.ui.open(PANE).catch(() => {});
247}
248
249async function onEnd($) {
250 if (!role.chief) return;
251 const mine = await $.store.get('chief');
252 if (mine?.sessionId === (await $.session.id())) await $.store.delete('chief');
253}
254
255export async function contextHook($, e, next) {
256 const r = await next(e);
257 if (!role.chief) return r;
258 const ctx = {
259 agents: await $.agent.list(),
260 now: await $.clock.now(),
261 session: await $.session.id(),
262 };
263 const rows = [];
264 for (const row of await readRows($)) {
265 if (!deadHolder(row, ctx)) {
266 rows.push(row);
267 continue;
268 }
269 const freed = merge(row, FREED, ctx.now);
270 await $.store.set(key(row.pr), freed);
271 rows.push(freed);
272 }
273 const text = ledgerBlock(rows, ctx);
274 return { ...r, blocks: [...r.blocks, { name: 'motorfixLedger', text }] };
275}
276
277export async function startHook($, e, next) {
278 const r = await next(e);
279 await onStart($, e);
280 return r;
281}
282
283export async function endHook($, e, next) {
284 await onEnd($);
285 return next(e);
286}
287hooks/dispatch-guard.js 139 lines1// Part 2, the dispatch guard (contracts/denials.md): one agent per PR, and a
2// story run, tail or watch fix only as task-runner. A deny is the model's only
3// answer; nothing here approves anything.
4import {
5 blank,
6 deadHolder,
7 FREED,
8 holding,
9 key,
10 merge,
11 PENDING,
12} from './lib/ledger.js';
13import { needsTaskRunner, prTokens, worktreeTokens } from './lib/parse.js';
14
15export const GENERAL_DENY =
16 'motorfix-chief: this dispatch is a story run, tail or watch fix — task-runner is required (AGENTS.md step 4), never general-purpose.';
17
18const tree = (path) =>
19 path ? ` in ${path.slice(path.lastIndexOf('.worktrees/'))}` : '';
20
21function heldLine(row, ctx) {
22 if (row.holder === PENDING)
23 return `motorfix-chief: PR #${row.pr} is being dispatched by session ${row.holderSession} — one agent per PR; wait for that dispatch.`;
24 if (row.holderSession === ctx.session)
25 return `motorfix-chief: PR #${row.pr} is held by agent ${row.holder} (live, dispatched by this session)${tree(row.worktree)} — one agent per PR; wait for it or stop it first.`;
26 const mins = Math.floor((ctx.now - row.updatedAt) / 60_000);
27 return `motorfix-chief: PR #${row.pr} is held by agent ${row.holder} (dispatched by session ${row.holderSession}, ${mins} min ago) — one agent per PR; it frees itself at 110 min.`;
28}
29
30// The rows this prompt claims: each PR it names, and each row whose worktree it names.
31function claimed(prompt, rows) {
32 const prs = prTokens(prompt);
33 const trees = worktreeTokens(prompt);
34 const byTree = rows.filter((r) =>
35 trees.some((t) => r.worktree?.endsWith(`/.worktrees/${t}`)),
36 );
37 // Only an unambiguous worktree goes on a new row: with several, which is whose is unknown.
38 const worktree =
39 trees.length === 1
40 ? (prompt.match(/[^\s'"`]*\.worktrees\/[\w.-]*[\w-]/)?.[0] ?? null)
41 : null;
42 const wanted = new Set([...prs, ...byTree.map((r) => r.pr)]);
43 return [...wanted].map((pr) => {
44 const row = rows.find((r) => r.pr === pr);
45 return row ?? { ...blank(pr), worktree };
46 });
47}
48
49// One check-and-claim at a time in this session, so two dispatches at once
50// cannot both find the PR free.
51let queue = Promise.resolve();
52const serial = (fn) => {
53 const run = queue.then(fn, fn);
54 queue = run.catch(() => {});
55 return run;
56};
57
58// Its own copy, as in chief-control.js: `$` does not cross a file.
59async function readRows($) {
60 const keys = (await $.store.keys()).filter((k) => k.startsWith('pr:'));
61 const rows = [];
62 for (const k of keys) rows.push(await $.store.get(k));
63 return rows.filter(Boolean);
64}
65
66async function claim($, prompt) {
67 const rows = await readRows($);
68 const ctx = {
69 agents: await $.agent.list(),
70 now: await $.clock.now(),
71 session: await $.session.id(),
72 };
73 const targets = [];
74 for (const r of claimed(prompt, rows)) {
75 if (!deadHolder(r, ctx)) {
76 targets.push(r);
77 continue;
78 }
79 const freed = merge(r, FREED, ctx.now);
80 await $.store.set(key(r.pr), freed);
81 targets.push(freed);
82 }
83 const busy = targets.filter((r) => holding(r, ctx));
84 if (busy.length)
85 return { deny: busy.map((r) => heldLine(r, ctx)).join('\n') };
86 for (const r of targets)
87 await $.store.set(
88 key(r.pr),
89 merge(r, { holder: PENDING, holderSession: ctx.session }, ctx.now),
90 );
91 return { ctx, targets };
92}
93
94async function settle($, targets, ctx, r) {
95 const status = r?.result?.status;
96 const holder = status === 'async_launched' ? r.result.agentId : null;
97 for (const row of targets) {
98 const now = await $.clock.now();
99 await $.store.set(
100 key(row.pr),
101 merge(
102 (await $.store.get(key(row.pr))) ?? row,
103 { holder, holderSession: holder ? ctx.session : null },
104 now,
105 ),
106 );
107 }
108}
109
110async function dispatch($, e, next, prompt) {
111 // A ledger that cannot be read never blocks a dispatch.
112 const decided = await serial(() => claim($, prompt)).catch(() => null);
113 if (decided?.deny) return decided;
114 if (!decided?.targets.length) return next(e);
115 let r;
116 try {
117 r = await next(e);
118 } finally {
119 // A claim left pending would hold the PR for 110 minutes: say so.
120 await settle($, decided.targets, decided.ctx, r).catch(() =>
121 $.ui
122 .toast(
123 `motorfix-chief: could not record the holder of ${decided.targets.map((t) => `#${t.pr}`).join(', ')}; its pending claim holds for 110 min`,
124 )
125 .catch(() => {}),
126 );
127 }
128 return r;
129}
130
131// tool.call{tool=Agent}, listed first in register.js.
132export function agentHook($, e, next) {
133 const prompt = `${e.description ?? ''}\n${e.prompt ?? ''}`;
134 if (e.subagent_type === 'general-purpose' && needsTaskRunner(prompt))
135 return { deny: GENERAL_DENY };
136 if (e.subagent_type !== 'task-runner') return next(e);
137 return dispatch($, e, next, prompt);
138}
139hooks/lib/surface.js 10 lines1// Draw only in the terminal or the desktop app, never in a cloud session.
2export const canDraw = (surface, remote) =>
3 (surface === 'terminal' || surface === 'desktop') && remote !== 'true';
4
5// The board's pane (part 4), opened at Chief's start.
6export const PANE = { id: 'motorfix-board', title: 'MotorFix board' };
7
8// The owner card's pane (part 7), open while a command waits on the owner.
9export const CARD = { id: 'motorfix-card', title: 'Owner command' };
10hooks/owner-card.js 100 lines1// Part 7, the owner card: an AskUserQuestion from Chief
2// whose question holds exactly one fenced command becomes a pane with that
3// command as one chain, a Copy button, and a Done / Not done answer. Anything
4// else, a worker, a cloud session or another surface passes unchanged.
5import { role } from './chief-control.js';
6import { commandBlock } from './lib/parse.js';
7import { CARD, canDraw } from './lib/surface.js';
8
9export const ASK = 'Run the command in your terminal, then answer: is it done?';
10// A line ending in one of these continues on the next one.
11const CONTINUED = /(&&|\|\||\|)$/;
12
13// The card on show, while its ask waits on the owner.
14let shown = null;
15
16// One command per line becomes one chain: `a && b`, continuations kept.
17export function chain(block) {
18 const lines = block
19 .split('\n')
20 .map((l) => l.trim())
21 .filter(Boolean);
22 let out = '';
23 for (const line of lines) {
24 if (!out) out = line;
25 else if (out.endsWith('\\')) out = `${out.slice(0, -1).trimEnd()} ${line}`;
26 else if (CONTINUED.test(out)) out = `${out} ${line}`;
27 else out = `${out} && ${line}`;
28 }
29 return out;
30}
31
32function commandOf(e) {
33 const text = (e.questions ?? [])
34 .map((q) => `${q.question ?? ''}\n${q.description ?? ''}`)
35 .join('\n');
36 const block = commandBlock(text);
37 return block ? chain(block) : null;
38}
39
40async function answerOf($) {
41 try {
42 return (await $.ui.ask(ASK, ['Done', 'Not done'])) === 'Done'
43 ? 'Done'
44 : 'Not done';
45 } catch {
46 return 'Not done';
47 }
48}
49
50export async function cardHook($, e, next) {
51 if (!role.chief || !canDraw(role.surface, role.remote)) return next(e);
52 const command = commandOf(e);
53 if (!command) return next(e);
54 const card = { command, copied: false };
55 shown = card;
56 await $.ui.open(CARD).catch(() => {});
57 const label = await answerOf($);
58 shown = null;
59 await $.ui.close({ id: CARD.id }).catch(() => {});
60 const id = e.tool_use_id ?? String(await $.clock.now());
61 await $.store.set(`card:${id}`, {
62 answer: label === 'Done' ? 'done' : 'not done',
63 command,
64 copied: card.copied,
65 });
66 const answers = Object.fromEntries(
67 e.questions.map((q) => [q.question, label]),
68 );
69 return {
70 context: [
71 label === 'Done'
72 ? 'motorfix-chief: the owner answered "Done" to the command card.'
73 : 'motorfix-chief: the owner answered "Not done" to the command card; the command has not run.',
74 ],
75 result: { answers, questions: e.questions },
76 };
77}
78
79async function copy($, card, press) {
80 const r = await $.ui.copy({ surface: press?.surface, text: card.command });
81 card.copied = card.copied || Boolean(r?.isCopied);
82}
83
84export function cardPaneHook($, e, next) {
85 const card = shown;
86 if (!card) return next(e);
87 const { Box, Button, Code, Text } = $.ui.resolve(e);
88 return h(
89 Box,
90 { flexDirection: 'column', gap: 1 },
91 h(Text, {}, 'Run this in your terminal, then answer the question.'),
92 h(Code, { language: 'bash', source: card.command }),
93 h(Button, {
94 key: 'copy',
95 label: 'Copy',
96 onPress: (press) => copy($, card, press).catch(() => {}),
97 }),
98 );
99}
100hooks/quiet-transcripts.js 53 lines1// Part 8, quiet transcripts: display only, the model's text is
2// untouched. A ci-wait result draws one pill per check (one "green" for empty
3// output, nothing for the lone `PR QA: cancel`), and an Agent reply opening
4// with the four-line envelope folds to STATUS, PR and NEXT. Everything else,
5// and every surface but terminal and desktop, keeps the engine's own.
6import { ciWaitCalls, role } from './chief-control.js';
7import { ciLines, replyBlock } from './lib/parse.js';
8import { canDraw } from './lib/surface.js';
9
10const COLOR = { cancel: 'gray', fail: 'red', pending: 'yellow' };
11
12function pills($, e, stdout) {
13 const { Box, Text } = $.ui.resolve(e);
14 const raw = stdout.trim();
15 const checks = raw ? ciLines(raw) : [{ name: 'green', state: '' }];
16 const pill = (c) =>
17 h(
18 Text,
19 { color: COLOR[c.state], inverse: true },
20 ` ${[c.name, c.state].filter(Boolean).join(' ')} `,
21 );
22 return h(
23 Box,
24 { flexDirection: 'row', flexWrap: 'wrap', gap: 1 },
25 ...checks.map(pill),
26 );
27}
28
29function folded($, e, block) {
30 const { Box, Text } = $.ui.resolve(e);
31 return h(
32 Box,
33 { flexDirection: 'column' },
34 h(Text, {}, `STATUS: ${block.status}`),
35 h(Text, { dimColor: true }, `PR: ${block.pr}`),
36 h(Text, { dimColor: true }, `NEXT: ${block.next}`),
37 );
38}
39
40const agentText = (output) =>
41 (output?.content ?? []).map((c) => c.text ?? '').join('\n');
42
43export function resultHook($, e, next) {
44 const { isErrored, output, tool, tool_use_id } = e.props;
45 if (isErrored || !canDraw(e.surface, role.remote)) return next(e);
46 if (tool === 'Bash' && ciWaitCalls.has(tool_use_id)) {
47 if (output?.backgroundTaskId) return next(e);
48 return pills($, e, output?.stdout ?? '');
49 }
50 const block = tool === 'Agent' ? replyBlock(agentText(output)) : undefined;
51 return block ? folded($, e, block) : next(e);
52}
53hooks/usage-guard.js 47 lines1// Part 6, the usage-limit guard: the first time a rate-limit
2// window reaches 80 %, once per window kind and reset time across sessions,
3// ask whether to remap fable to Opus; only a yes runs `fable.mjs off`, as an
4// argument list in the main checkout. Drawn only where the mod draws.
5import { role } from './chief-control.js';
6import { canDraw } from './lib/surface.js';
7
8const THRESHOLD = 80;
9const REMAP = 'Remap fable to Opus';
10const FABLE_OFF = ['node', '.claude/scripts/fable.mjs', 'off'];
11
12async function answerOf($, limit) {
13 const question = `The ${limit.kind} usage window is at ${limit.percentUsed} % (resets ${limit.resetsAt ?? 'unknown'}). Remap fable to Opus with fable.mjs off?`;
14 try {
15 return (await $.ui.ask(question, [REMAP, 'Not now'])) === REMAP;
16 } catch {
17 return false;
18 }
19}
20
21async function warn($, limit) {
22 const id = `usage:${limit.kind}:${limit.resetsAt ?? 'unknown'}`;
23 if (await $.store.get(id)) return;
24 const row = { answer: null, percentUsed: limit.percentUsed };
25 await $.store.set(id, row);
26 const remap = await answerOf($, limit);
27 await $.store.set(id, { ...row, answer: remap ? 'remap' : 'declined' });
28 if (!remap) return;
29 const r = await $.process.run(FABLE_OFF, {
30 cwd: role.main,
31 timeoutMs: 30_000,
32 });
33 await $.ui.toast(
34 r.exitCode === 0
35 ? 'motorfix-chief: fable now maps to Opus for new sessions'
36 : `motorfix-chief: fable.mjs off failed (exit ${r.exitCode})`,
37 );
38}
39
40export async function measureHook($, e, next) {
41 const r = await next(e);
42 if (!canDraw(role.surface, role.remote) || !role.main) return r;
43 for (const limit of e.rateLimits ?? [])
44 if (limit.percentUsed >= THRESHOLD) await warn($, limit).catch(() => {});
45 return r;
46}
47hooks/worker-report.js 49 lines1// Part 5, the worker stage report: a worker session that moved its PR's
2// stage this turn sends Chief one line at the turn's end; an unreachable Chief
3// gets one dim transcript line and no retry. Chief control's Bash hook fills
4// `turn`.
5import { role, turn } from './chief-control.js';
6import { blank, key, merge } from './lib/ledger.js';
7
8const WATCH = '.claude/scripts/watch.mjs';
9
10// This worktree's row in watch.mjs: its PR and its head.
11async function treeRow($) {
12 const r = await $.process.run(['node', WATCH, '--json'], {
13 cwd: role.main,
14 timeoutMs: 120_000,
15 });
16 if (r.exitCode !== 0) return undefined;
17 return (JSON.parse(r.stdout).rows ?? []).find((w) => w.path === role.cwd);
18}
19
20async function deliver($, text) {
21 const chief = await $.store.get('chief');
22 const r = chief
23 ? await $.session.send({ text, to: { sessionId: chief.sessionId } })
24 : null;
25 if (!r?.isDelivered)
26 $.ui.log(`motorfix-chief: Chief not reachable; ${text} not sent`);
27}
28
29async function report($, stage, named) {
30 const tree = await treeRow($).catch(() => undefined);
31 const pr = named ?? tree?.pr?.number;
32 if (!pr) return;
33 const sha7 = tree?.head ? tree.head.slice(0, 7) : 'unknown';
34 const row = (await $.store.get(key(pr))) ?? blank(pr);
35 if (row.stage === stage) return;
36 await $.store.set(key(pr), merge(row, { stage }, await $.clock.now()));
37 await deliver($, `#${pr} ${row.stage ?? '–'} → ${stage} ${sha7}`);
38}
39
40export async function turnHook($, e, next) {
41 const r = await next(e);
42 const { pr, stage } = turn;
43 if (e.agentId || role.chief || !stage) return r;
44 turn.stage = undefined;
45 turn.pr = undefined;
46 await report($, stage, pr).catch(() => {});
47 return r;
48}
49hooks/lib/ledger.js 87 lines1// The ledger's rows and the rules over them (data-model.md, contracts/store-keys.md).
2// Pure: callers pass the session id, the agent list and the time.
3
4export const HOLD_MS = 110 * 60_000;
5export const LAP_CAP = 10;
6// The guard's claim, written before its Agent call goes through.
7export const PENDING = 'pending';
8export const MARKER = '<!-- motorfix-chief ledger -->';
9export const DEAD = new Set(['completed', 'failed', 'killed']);
10
11export const key = (pr) => `pr:${pr}`;
12
13export const blank = (pr) => ({
14 ci: null,
15 head: null,
16 holder: null,
17 holderSession: null,
18 lap: 0,
19 pr,
20 qaRun: null,
21 stage: null,
22 updatedAt: 0,
23 worktree: null,
24});
25
26export const merge = (row, patch, now) => ({
27 ...row,
28 ...patch,
29 lap: Math.min(LAP_CAP, patch.lap ?? row.lap),
30 updatedAt: now,
31});
32
33// In the dispatching session the agent list decides; a claim, or a row
34// from another session, holds until it is 110 minutes old.
35export function holding(row, { session, agents, now }) {
36 if (!row?.holder) return false;
37 if (row.holder === PENDING || row.holderSession !== session)
38 return now - row.updatedAt < HOLD_MS;
39 const agent = agents.find((a) => a.id === row.holder);
40 return Boolean(agent) && !DEAD.has(agent.status);
41}
42
43// A holder this session dispatched whose agent has ended or is gone: the row
44// is freed when read.
45export function deadHolder(row, { session, agents }) {
46 if (!row?.holder || row.holder === PENDING || row.holderSession !== session)
47 return false;
48 const agent = agents.find((a) => a.id === row.holder);
49 return !agent || DEAD.has(agent.status);
50}
51
52export const FREED = { holder: null, holderSession: null };
53
54export const ageText = (ms) =>
55 ms < 2 * 60 * 60_000
56 ? `${Math.floor(ms / 60_000)}m`
57 : `${Math.floor(ms / 3_600_000)}h`;
58
59const shortTree = (path) =>
60 path ? path.slice(path.lastIndexOf('.worktrees/')) : '–';
61const dash = (value) => value ?? '–';
62
63export function ledgerBlock(rows, ctx) {
64 if (!rows.length) return `${MARKER}\nNo PR is held.`;
65 const line = (r) => {
66 const holder = r.holder
67 ? `${r.holder} (${holding(r, ctx) ? 'live' : 'free'})`
68 : '–';
69 return `#${r.pr} ${dash(r.stage)} lap ${r.lap}/${LAP_CAP} run ${dash(r.qaRun)} ci ${dash(r.ci)} head ${dash(r.head)} holder ${holder} worktree ${shortTree(r.worktree)} age ${ageText(ctx.now - r.updatedAt)}`;
70 };
71 return [MARKER, ...[...rows].sort((a, b) => a.pr - b.pr).map(line)].join(
72 '\n',
73 );
74}
75
76const CI_STATES = new Set(['pass', 'fail', 'pending']);
77
78// A value outside the column's set is drawn as its default.
79export const boardRow = (r, ctx) => ({
80 age: ageText(ctx.now - r.updatedAt),
81 ci: CI_STATES.has(r.ci) ? r.ci : 'none',
82 holder: holding(r, ctx) ? r.holder : 'none',
83 lap: `${Math.min(LAP_CAP, r.lap ?? 0)}/${LAP_CAP}`,
84 pr: r.pr,
85 stage: r.stage ?? 'none',
86});
87hooks/lib/parse.js 75 lines1// Pure readers of the text the mod sees: dispatch prompts, Bash commands and
2// their output, agent replies and notifications. No `$` here.
3
4const unique = (list) => [...new Set(list)];
5
6// "PR #12", "PR 12", "tail #12", ".../pull/12": a bare issue number "#34" is not a PR.
7export const prTokens = (text) =>
8 unique(
9 [
10 ...text.matchAll(/\bPR\s*#?(\d+)\b|\btail\s+#(\d+)\b|\/pull\/(\d+)\b/gi),
11 ].map((m) => Number(m[1] ?? m[2] ?? m[3])),
12 );
13
14export const worktreeTokens = (text) =>
15 unique([...text.matchAll(/\.worktrees\/([\w.-]*[\w-])/g)].map((m) => m[1]));
16
17const WATCH_FIX = /\b(resume|tail|merge-main|rerun-qa|fix-ci|merge)\b/;
18
19// A story run, a tail or a /speckit-watch fix: the dispatches AGENTS.md gives to task-runner.
20export const needsTaskRunner = (text) =>
21 /\/speckit-auto\b|\btail #\d|\btail\.md\b|\bhandoff\.md\b/.test(text) ||
22 (/\/speckit-watch\b/.test(text) && WATCH_FIX.test(text));
23
24export function bashCall(command) {
25 const m = command.match(
26 /pr-test\/(dispatch|ci-wait)\.mjs\s+(\d+)(?:\s+--run\s+(\d+))?/,
27 );
28 if (!m) return undefined;
29 return { kind: m[1], pr: Number(m[2]), run: m[3] ? Number(m[3]) : null };
30}
31
32export function qaRunLine(stdout) {
33 const m = stdout.match(/QA run: (\d+) · head ([0-9a-f]{7,40}) · lap (\d+)/);
34 return m
35 ? { head: m[2].slice(0, 7), lap: Number(m[3]), qaRun: Number(m[1]) }
36 : undefined;
37}
38
39export function replyBlock(text) {
40 const m = text
41 .replace(/\r\n/g, '\n')
42 .match(/^STATUS: (.*)\nPR: (.*)\nNEXT: (.*)\nFILES: (.*)/);
43 return m ? { files: m[4], next: m[3], pr: m[2], status: m[1] } : undefined;
44}
45
46// ci-wait.mjs prints one "<check>: <state>" line per check that did not pass.
47export const ciLines = (stdout) =>
48 stdout
49 .split('\n')
50 .map((line) => line.trim())
51 .filter((line) => line && line !== 'PR QA: cancel')
52 .map((line) => {
53 const at = line.lastIndexOf(': ');
54 return at < 0
55 ? { name: line, state: '' }
56 : { name: line.slice(0, at), state: line.slice(at + 2) };
57 });
58
59export function commandBlock(question) {
60 const blocks = [...question.matchAll(/```[\w-]*\n([\s\S]*?)```/g)];
61 return blocks.length === 1 ? blocks[0][1].trim() : undefined;
62}
63
64export function taskNotification(text) {
65 if (!text.includes('<task-notification>')) return undefined;
66 const tag = (name) =>
67 text.match(new RegExp(`<${name}>([^<]*)</${name}>`))?.[1];
68 const code = text.match(/exit code (\d+)/);
69 return {
70 exitCode: code ? Number(code[1]) : null,
71 id: tag('task-id'),
72 status: tag('status'),
73 };
74}
75hooks/lib/role.js 21 lines1// Who this session is: Chief runs in the main checkout, whose
2// git dir is the common dir; a worktree's git dir sits under it. Each part
3// runs `git rev-parse` itself at session.start (`$` never crosses an import)
4// and reads its output here.
5export const REV_PARSE = [
6 'git',
7 'rev-parse',
8 '--path-format=absolute',
9 '--git-dir',
10 '--git-common-dir',
11];
12
13export function fromRevParse(stdout) {
14 const [gitDir, commonDir] = stdout.trim().split('\n');
15 if (!gitDir || !commonDir) return { chief: false, main: null };
16 return {
17 chief: gitDir === commonDir,
18 main: commonDir.replace(/\/\.git\/?$/, ''),
19 };
20}
21