SLOPSHOPPER

motorfix-chief

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

newpanebandrowsguardtoast
A shopper browsing a rack in a slop shop
Source 13 files
hooks/register.js 39 lines
1// 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}
39
hooks/board.js 146 lines
1// 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}
146
hooks/chief-control.js 287 lines
1// 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}
287
hooks/dispatch-guard.js 139 lines
1// 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}
139
hooks/lib/surface.js 10 lines
1// 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' };
10
hooks/owner-card.js 100 lines
1// 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}
100
hooks/quiet-transcripts.js 53 lines
1// 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}
53
hooks/usage-guard.js 47 lines
1// 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}
47
hooks/worker-report.js 49 lines
1// 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}
49
hooks/lib/ledger.js 87 lines
1// 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});
87
hooks/lib/parse.js 75 lines
1// 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}
75
hooks/lib/role.js 21 lines
1// 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