@mmerterden/multi-agent-pipeline 20.28.0 → 20.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +14 -1
  3. package/docs/facts.json +1 -1
  4. package/install/claude.mjs +112 -0
  5. package/install/templates/claude-hooks.json +30 -1
  6. package/manifest.json +53 -42
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  9. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  10. package/pipeline/contract/types/index.d.ts +1 -1
  11. package/pipeline/lib/hook-registration.mjs +252 -0
  12. package/pipeline/lib/prefs-value.mjs +44 -0
  13. package/pipeline/lib/run-completion.mjs +148 -0
  14. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  15. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  16. package/pipeline/multi-agent-refs/phases/operations.md +17 -0
  17. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  18. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  19. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
  21. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  22. package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
  23. package/pipeline/preferences-template.json +7 -0
  24. package/pipeline/rules/figma-pipeline.md +2 -0
  25. package/pipeline/schemas/agent-state.schema.json +20 -5
  26. package/pipeline/schemas/phases.json +3 -2
  27. package/pipeline/schemas/phases.schema.json +5 -1
  28. package/pipeline/schemas/prefs.schema.json +37 -0
  29. package/pipeline/schemas/runs-index.schema.json +1 -1
  30. package/pipeline/scripts/_active-run.mjs +158 -0
  31. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  32. package/pipeline/scripts/_guard_rules.py +605 -0
  33. package/pipeline/scripts/agent-guard.py +118 -7
  34. package/pipeline/scripts/agent-guard.sh +50 -3
  35. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  36. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  37. package/pipeline/scripts/confirm-command.mjs +70 -0
  38. package/pipeline/scripts/doctor.mjs +54 -0
  39. package/pipeline/scripts/evidence-gate.mjs +8 -4
  40. package/pipeline/scripts/gate-ledger.mjs +163 -7
  41. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  42. package/pipeline/scripts/pre-commit-check.sh +12 -0
  43. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  44. package/pipeline/scripts/run-scope-guard.sh +23 -0
  45. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  46. package/pipeline/scripts/run-stop-guard.sh +25 -0
  47. package/pipeline/scripts/uninstall.mjs +1 -0
  48. package/pipeline/scripts/validate-state.mjs +26 -0
  49. package/pipeline/scripts/write-state.mjs +114 -0
  50. package/pipeline/skills/.skill-manifest.json +1 -1
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * run-stop-guard.mjs - a session driving a pipeline run does not end its turn
4
+ * mid-phase without saying why.
5
+ *
6
+ * Claude Code Stop hook. When the session that is driving an active run tries
7
+ * to stop while the run is `in_progress`, below its mode's last phase, with no
8
+ * `haltReason` and no `waitingFor`, it answers
9
+ * `{"decision":"block","reason":...}`. The reason names the phase to finish
10
+ * and the two legal ways out: finish it, or record a halt (operations.md,
11
+ * "Halt visibility").
12
+ *
13
+ * Which run: _active-run.mjs findActiveRun, and only a run bound to this
14
+ * session by `sessionId` (MULTI_AGENT_SESSION_ID) or `hostSessionId`. A run
15
+ * found only because its worktree is the session's cwd never holds a stop: an
16
+ * old local-mode run has the project root as its worktree and would trap
17
+ * every later session opened there. No bound run: exit 0, no output.
18
+ *
19
+ * The stop is let through, with no output, whenever the session has a reason
20
+ * to stop that the state does not record:
21
+ * - the turn ends waiting for the user: `last_assistant_message` ends in a
22
+ * question or asks for a reply (the pipeline's plain-text waits: plan
23
+ * approval, commit, PR, a local-test handoff);
24
+ * - background tasks or scheduled crons are running, so the run is driven
25
+ * elsewhere and this session is waiting;
26
+ * - prefs.global.stopGuard.enabled is false.
27
+ *
28
+ * SubagentStop is never held: a subagent finishes its piece and the session
29
+ * that dispatched it decides what comes next.
30
+ *
31
+ * Repeat ceiling: the host sets `stop_hook_active` while it continues because
32
+ * of a Stop hook, and overrides a hook that blocks too many times in a row.
33
+ * This hook counts its own consecutive blocks per run in `.stop-guard.json`
34
+ * next to the state. After REPEAT_LIMIT blocks it stops blocking, writes the
35
+ * halt record itself (`status: paused`, `haltReason: "<phase>:stop-guard: ..."`)
36
+ * and tells the user through `systemMessage`. write-state.mjs clears that
37
+ * halt the moment the run advances a phase, so it never outlives the run
38
+ * moving on. A stop with `stop_hook_active` false is a new turn and starts the
39
+ * count again.
40
+ *
41
+ * This is a completion aid, not a security gate: any error (unparseable
42
+ * payload, unreadable state, a failed write) exits 0 with no decision and a
43
+ * line in <log root>/hook-failures.log.
44
+ *
45
+ * @module pipeline/scripts/run-stop-guard
46
+ */
47
+
48
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
49
+ import { join } from "node:path";
50
+
51
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
52
+ import { globalPref } from "../lib/prefs-value.mjs";
53
+ import {
54
+ finalPhaseOf,
55
+ findActiveRun,
56
+ logHookFailure,
57
+ modeOf,
58
+ phaseName,
59
+ runDirOf,
60
+ } from "./_active-run.mjs";
61
+
62
+ export const REPEAT_LIMIT = 3;
63
+ export const STOP_GUARD_HALT = "stop-guard:";
64
+ const COUNTER_FILE = ".stop-guard.json";
65
+ const HOOK = "run-stop-guard";
66
+ const SESSION_BOUND = new Set(["session", "host-session"]);
67
+
68
+ // A reply the turn asks for, in the closing text: a question mark (ASCII or
69
+ // full-width) at the end of a line, or a request addressed to the user. A
70
+ // request is an imperative that opens a sentence ("Confirm the plan.") or a
71
+ // second-person phrase ("can you", "tell me"); a reply verb inside a
72
+ // statement ("Next I will confirm the build") is the model's own plan and
73
+ // does not count, nor does one used as a label ("Answer: the cache was stale").
74
+ const QUESTION_END = /[??]\s*(?:\(.{0,40}\))?\s*$/;
75
+ const REPLY_VERB =
76
+ "(?:reply|respond|answer|confirm|choose|pick|approve|send|write your|tell me|let me know)";
77
+ const ASKS_FOR_REPLY = new RegExp(
78
+ [
79
+ `(?:^|[.!:;]\\s+)(?:please\\s+)?${REPLY_VERB}\\b(?!\\s*:)`,
80
+ `\\bplease\\s+${REPLY_VERB}\\b(?!\\s*:)`,
81
+ "\\b(?:can|could|would|will) you\\b",
82
+ "\\b(?:do|would) you (?:want|like|prefer)\\b",
83
+ "\\b(?:let me know|tell me)\\b",
84
+ "\\b(?:reply|respond|answer) with\\b",
85
+ "\\(\\s*(?:yes|y)\\s*\\/\\s*(?:no|n)\\s*\\)",
86
+ ].join("|"),
87
+ "im",
88
+ );
89
+
90
+ /**
91
+ * True when the closing assistant text waits for the user.
92
+ *
93
+ * @param {unknown} message last_assistant_message
94
+ */
95
+ export function asksTheUser(message) {
96
+ if (typeof message !== "string" || !message.trim()) return false;
97
+ const lines = message
98
+ .trim()
99
+ .split("\n")
100
+ .map((l) => l.replace(/[*_`>#]+/g, "").trim())
101
+ .filter(Boolean);
102
+ const tail = lines.slice(-3);
103
+ if (tail.some((l) => QUESTION_END.test(l))) return true;
104
+ return ASKS_FOR_REPLY.test(tail.join("\n"));
105
+ }
106
+
107
+ /**
108
+ * Why the run may stop now, or null when it is mid-phase and must not.
109
+ *
110
+ * @param {object} state
111
+ * @returns {string|null}
112
+ */
113
+ export function stopAllowedBecause(state) {
114
+ if (!state || typeof state !== "object") return "no state";
115
+ if (state.status !== "in_progress") return `status is ${state.status ?? "unset"}`;
116
+ if (state.haltReason) return "a halt is recorded";
117
+ if (state.waitingFor) return `waiting for ${state.waitingFor}`;
118
+ const final = finalPhaseOf(state);
119
+ if (final === null) return `mode ${modeOf(state)} is not declared in phases.json`;
120
+ const cur = Number(state.currentPhase);
121
+ if (!Number.isInteger(cur)) return "currentPhase is not recorded";
122
+ if (cur >= final) return "the final phase is reached";
123
+ return null;
124
+ }
125
+
126
+ /** The block reason: where the run is and the two ways out. */
127
+ export function blockReason(state, statePath) {
128
+ const cur = Number(state.currentPhase);
129
+ const final = finalPhaseOf(state);
130
+ const id = state.taskId || "this run";
131
+ return [
132
+ `Pipeline run ${id} (mode ${modeOf(state)}) is at Phase ${cur} (${phaseName(cur)}) of ${final}, status in_progress, and no halt or wait is recorded.`,
133
+ `Now finish Phase ${cur} (${phaseName(cur)}) and go on as its document says.`,
134
+ `If you are waiting for the user, ask the question so the turn ends on it, or record the wait: printf '{"waitingFor":"attended-reply"}' | node "$HOME/.claude/scripts/write-state.mjs" "${statePath}".`,
135
+ `If the run cannot go on, record the halt before stopping: printf '{"status":"paused","haltReason":"${cur}:<cause>"}' | node "$HOME/.claude/scripts/write-state.mjs" "${statePath}" (operations.md, Halt visibility).`,
136
+ ].join(" ");
137
+ }
138
+
139
+ function readCounter(dir) {
140
+ try {
141
+ const doc = JSON.parse(readFileSync(join(dir, COUNTER_FILE), "utf8"));
142
+ return Number.isInteger(doc?.count) ? doc.count : 0;
143
+ } catch {
144
+ return 0;
145
+ }
146
+ }
147
+
148
+ function writeCounter(dir, count) {
149
+ writeFileSync(
150
+ join(dir, COUNTER_FILE),
151
+ `${JSON.stringify({ count, at: new Date().toISOString() })}\n`,
152
+ );
153
+ }
154
+
155
+ function clearCounter(dir) {
156
+ rmSync(join(dir, COUNTER_FILE), { force: true });
157
+ }
158
+
159
+ async function recordHalt(statePath, state, blocks) {
160
+ const { updateState } = await import("./gate-ledger.mjs");
161
+ const cur = Number(state.currentPhase);
162
+ const cause = `${STOP_GUARD_HALT} the guard blocked ${blocks} consecutive stops before Phase ${cur} (${phaseName(cur)}) finished`;
163
+ await updateState(statePath, (s) => ({
164
+ ...s,
165
+ status: "paused",
166
+ haltReason: `${cur}:${cause}`,
167
+ }));
168
+ return cause;
169
+ }
170
+
171
+ const nonEmpty = (v) => Array.isArray(v) && v.length > 0;
172
+
173
+ /**
174
+ * The hook's answer for one payload.
175
+ *
176
+ * @param {object} payload
177
+ * @param {Record<string,string|undefined>} [env]
178
+ * @returns {Promise<object|null>} the JSON to print, or null for no output
179
+ */
180
+ export async function decide(payload, env = process.env) {
181
+ if (globalPref("stopGuard.enabled", true, env) === false) return null;
182
+ if (!payload || typeof payload !== "object") return null;
183
+ const event = payload.hook_event_name || "Stop";
184
+ if (event !== "Stop") return null;
185
+ if (nonEmpty(payload.background_tasks) || nonEmpty(payload.session_crons)) return null;
186
+ if (asksTheUser(payload.last_assistant_message)) return null;
187
+ const run = findActiveRun({
188
+ sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
189
+ cwd: typeof payload.cwd === "string" ? payload.cwd : null,
190
+ env,
191
+ });
192
+ if (!run || !SESSION_BOUND.has(run.matchedBy)) {
193
+ if (!run && findActiveRun.lastUnreadable > 0) {
194
+ logHookFailure(
195
+ HOOK,
196
+ `${findActiveRun.lastUnreadable} run state file(s) did not parse; allowed the stop`,
197
+ env,
198
+ );
199
+ }
200
+ return null;
201
+ }
202
+ const dir = runDirOf(run.statePath);
203
+ if (stopAllowedBecause(run.state) !== null) {
204
+ if (existsSync(join(dir, COUNTER_FILE))) clearCounter(dir);
205
+ return null;
206
+ }
207
+
208
+ const prior = payload.stop_hook_active === true ? readCounter(dir) : 0;
209
+ if (prior >= REPEAT_LIMIT) {
210
+ const cause = await recordHalt(run.statePath, run.state, prior);
211
+ clearCounter(dir);
212
+ return {
213
+ systemMessage: `run-stop-guard: recorded a halt on ${run.state.taskId || run.statePath} (${cause}). Resume it with /multi-agent:resume.`,
214
+ };
215
+ }
216
+ writeCounter(dir, prior + 1);
217
+ return { decision: "block", reason: blockReason(run.state, run.statePath) };
218
+ }
219
+
220
+ async function main() {
221
+ let raw;
222
+ try {
223
+ raw = readFileSync(0, "utf8");
224
+ } catch {
225
+ raw = "";
226
+ }
227
+ let payload;
228
+ try {
229
+ payload = JSON.parse(raw);
230
+ } catch {
231
+ logHookFailure(HOOK, "the hook payload is not JSON; allowed the stop");
232
+ return;
233
+ }
234
+ try {
235
+ const out = await decide(payload);
236
+ if (out) process.stdout.write(`${JSON.stringify(out)}\n`);
237
+ } catch (err) {
238
+ logHookFailure(HOOK, `${err?.message || err}; allowed the stop`);
239
+ }
240
+ }
241
+
242
+ const onFatal = (err) => logHookFailure(HOOK, `${err?.message || err}; allowed the stop`);
243
+
244
+ // Exit 0 on every path: nothing above sets a non-zero code, and a failure
245
+ // is logged and allowed.
246
+ if (invokedDirectly(import.meta.url)) main().catch(onFatal);
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # run-stop-guard.sh - Stop hook for the multi-agent pipeline.
3
+ #
4
+ # A session driving a pipeline run that tries to end its turn mid-run, with no
5
+ # halt recorded, is told to continue or record the halt. The decision is in
6
+ # run-stop-guard.mjs; this wrapper exists because every hook the pipeline
7
+ # registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
8
+ #
9
+ # Contract (Claude Code Stop hook):
10
+ # - Reads the hook JSON on stdin.
11
+ # - Prints {"decision":"block","reason":...} to keep the session going, or
12
+ # {"systemMessage":...} when it gives up and records the halt itself, or
13
+ # nothing.
14
+ # - Exits 0 on every path. A missing node or any internal error means no
15
+ # output: this is a completion aid, not a security gate.
16
+
17
+ set -u
18
+
19
+ HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
20
+ CORE="$HERE/run-stop-guard.mjs"
21
+ [ -f "$CORE" ] || exit 0
22
+ command -v node >/dev/null 2>&1 || exit 0
23
+
24
+ node "$CORE" 2>/dev/null || true
25
+ exit 0
@@ -1009,6 +1009,7 @@ export async function main() {
1009
1009
  );
1010
1010
  }
1011
1011
  rmIfExists(join(CLAUDE, ".pipeline-version"));
1012
+ rmIfExists(join(CLAUDE, "multi-agent-hook-manifest.json"));
1012
1013
  // NOTE: ~/.claude/rules/ is USER-OWNED. install lays baseline rules down
1013
1014
  // write-if-missing and NEVER overwrites them (install/claude.mjs
1014
1015
  // installRules, skipExisting) so users can evolve their global rules
@@ -16,6 +16,7 @@
16
16
  // - root is not an object
17
17
  // - currentPhase is missing or out of the 0..5 range (resume keys on it)
18
18
  // - phases is present but not an object, or an entry is not an object
19
+ // - waitingFor names a step resume cannot re-enter
19
20
  // Phase status strings are NOT enum-checked: real writers use "completed"
20
21
  // alongside the schema's "done", and an odd status does not make a resume
21
22
  // unsafe (resume keys on currentPhase, not phase status). Zero deps, same
@@ -32,6 +33,14 @@
32
33
 
33
34
  import { readFileSync } from "node:fs";
34
35
 
36
+ const WAITING_FOR = new Set([
37
+ "maturity",
38
+ "user-channels-choice",
39
+ "local-test",
40
+ "attended-reply",
41
+ "question",
42
+ ]);
43
+
35
44
  function readInput() {
36
45
  const arg = process.argv[2];
37
46
  if (!arg) {
@@ -129,6 +138,23 @@ async function main() {
129
138
  }
130
139
  }
131
140
 
141
+ // waitingFor is the first field resume reads: a value it has no row for, or
142
+ // a question with nothing to re-ask, leaves the run unable to re-enter.
143
+ if (s.waitingFor !== undefined && s.waitingFor !== null) {
144
+ if (!WAITING_FOR.has(s.waitingFor)) {
145
+ errors.push(
146
+ `waitingFor must be one of ${[...WAITING_FOR].join(", ")} or null, got ${JSON.stringify(s.waitingFor)}`,
147
+ );
148
+ } else if (
149
+ s.waitingFor === "question" &&
150
+ (typeof s.pendingQuestion?.stepId !== "string" || !s.pendingQuestion.stepId)
151
+ ) {
152
+ errors.push(
153
+ 'waitingFor "question" needs a pendingQuestion with a stepId; a typed wait records "attended-reply"',
154
+ );
155
+ }
156
+ }
157
+
132
158
  if (errors.length > 0) {
133
159
  emit({ ok: false, code: 1, errors });
134
160
  process.exit(1);
@@ -32,6 +32,9 @@
32
32
  * 4 - the lock was taken from us mid-write; nothing was written, retry
33
33
  * 3 - I/O error (disk full, permission denied, parent dir missing)
34
34
  * 5 - --if-rev did not match; the record moved, nothing was written
35
+ * 6 - the write moves the run into `status: complete` without the evidence
36
+ * lib/run-completion.mjs requires (last phase of the mode, Phase 4
37
+ * evidence, a plan-coverage verdict); nothing was written
35
38
  *
36
39
  * Tunables (env):
37
40
  * WRITE_STATE_LOCK_TIMEOUT_MS acquire window before exit 2 (default 15000)
@@ -67,6 +70,7 @@ import { dirname, basename } from "path";
67
70
  import { Buffer } from "node:buffer";
68
71
  import { runMain } from "../lib/fatal.mjs";
69
72
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
73
+ import { completionGaps } from "../lib/run-completion.mjs";
70
74
 
71
75
  /**
72
76
  * @typedef {{ replace: boolean, path: string, ifRev: number|null }} ParsedArgs
@@ -320,6 +324,104 @@ function undeclaredKeys(payload, current) {
320
324
  );
321
325
  }
322
326
 
327
+ const COMPLETE = new Set(["complete", "completed"]);
328
+ const STOP_GUARD_HALT = /^\d+:stop-guard:/;
329
+
330
+ /**
331
+ * Lift the halt run-stop-guard.mjs records after its repeat ceiling once the
332
+ * run advances a phase. That halt says only "the session kept stopping"; a
333
+ * later phase transition is the run going on, and leaving the halt in place
334
+ * would show a moving run as paused and make every later Stop let it through.
335
+ * A halt any other step recorded, or a write that sets status or haltReason
336
+ * itself, is left alone.
337
+ *
338
+ * @param {object} next
339
+ * @param {object} current
340
+ * @param {unknown} payload
341
+ */
342
+ export function clearStopGuardHalt(next, current, payload) {
343
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
344
+ if (Object.hasOwn(payload, "status") || Object.hasOwn(payload, "haltReason")) return;
345
+ if (typeof current?.haltReason !== "string" || !STOP_GUARD_HALT.test(current.haltReason)) return;
346
+ const was = Number(current.currentPhase);
347
+ const now = Number(payload.currentPhase);
348
+ if (!Number.isInteger(now) || !(now > (Number.isInteger(was) ? was : -1))) return;
349
+ next.haltReason = null;
350
+ next.status = "in_progress";
351
+ }
352
+
353
+ /**
354
+ * Clear a wait the run has moved past. A write that advances `currentPhase`
355
+ * without setting `waitingFor` itself ends any wait recorded in the earlier
356
+ * phase, so a clear the step forgot cannot keep the run marked as waiting
357
+ * (and the stop guard off) for the rest of the run. `question` is left to
358
+ * answer-question.mjs, which clears it together with its pendingQuestion.
359
+ *
360
+ * @param {object} next
361
+ * @param {object} current
362
+ * @param {unknown} payload
363
+ */
364
+ export function clearPassedWait(next, current, payload) {
365
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
366
+ if (Object.hasOwn(payload, "waitingFor")) return;
367
+ if (typeof current?.waitingFor !== "string" || current.waitingFor === "question") return;
368
+ const was = Number(current.currentPhase);
369
+ const now = Number(payload.currentPhase);
370
+ if (!Number.isInteger(now) || !(now > (Number.isInteger(was) ? was : -1))) return;
371
+ next.waitingFor = null;
372
+ }
373
+
374
+ /**
375
+ * The refusal message for a write that moves the run into `complete` without
376
+ * the evidence lib/run-completion.mjs asks for, or null.
377
+ *
378
+ * Only the transition is judged: a merge that sets `status: complete`, or a
379
+ * replace whose document is complete over one that was not. A later write to
380
+ * an already complete run that does not touch `status` goes through, so the
381
+ * report steps after the transition keep working.
382
+ *
383
+ * @param {object} next
384
+ * @param {unknown} payload
385
+ * @param {boolean} replace
386
+ * @param {string|null} diskStatus
387
+ * @returns {string|null}
388
+ */
389
+ export function completionRefusal(next, payload, replace, diskStatus) {
390
+ if (!COMPLETE.has(next?.status)) return null;
391
+ const entering = replace
392
+ ? !COMPLETE.has(diskStatus)
393
+ : Boolean(payload && typeof payload === "object" && COMPLETE.has(payload.status));
394
+ if (!entering) return null;
395
+ const gaps = completionGaps(next);
396
+ if (!gaps.length) return null;
397
+ return `write-state: refusing status "${next.status}": ${gaps.join("; ")}. Nothing was written. Finish the missing step, or record a halt (status "paused" with a haltReason).`;
398
+ }
399
+
400
+ /**
401
+ * Bind the run to the host session that drives it.
402
+ *
403
+ * `hostSessionId` is the Claude Code session (CLAUDE_CODE_SESSION_ID in the
404
+ * Bash environment) that made the run's last phase transition. The Stop hook
405
+ * (run-stop-guard.mjs) and the run-scoped PreToolUse guards read it to find
406
+ * the run a hook call belongs to. Stamped only when the write creates the
407
+ * document or sets `currentPhase`: those are the driver's writes, while a
408
+ * second session editing a running task (`/multi-agent:steer`) sets neither
409
+ * and so does not take the run over.
410
+ *
411
+ * @param {object} next
412
+ * @param {unknown} payload
413
+ * @param {boolean} existed
414
+ * @param {Record<string,string|undefined>} [env]
415
+ */
416
+ export function stampHostSession(next, payload, existed, env = process.env) {
417
+ const sid = env.CLAUDE_CODE_SESSION_ID;
418
+ if (typeof sid !== "string" || !sid.trim()) return;
419
+ const transition =
420
+ payload && typeof payload === "object" && Object.hasOwn(payload, "currentPhase");
421
+ if (existed && !transition) return;
422
+ next.hostSessionId = sid.trim();
423
+ }
424
+
323
425
  /**
324
426
  * @param {object} base
325
427
  * @param {object} patch
@@ -397,6 +499,8 @@ async function main() {
397
499
  }
398
500
  }
399
501
  next = deepMerge(current, payload);
502
+ clearStopGuardHalt(next, current, payload);
503
+ clearPassedWait(next, current, payload);
400
504
  }
401
505
 
402
506
  // A state document is an object. Anything else cannot carry `rev`, and
@@ -415,9 +519,11 @@ async function main() {
415
519
  // would tell the next reader that nothing changed.
416
520
  let priorRev = Number.isInteger(next.rev) ? next.rev : 0;
417
521
  let diskRev = null;
522
+ let diskStatus = null;
418
523
  if (existsSync(path)) {
419
524
  try {
420
525
  const parsed = JSON.parse(readFileSync(path, "utf-8"));
526
+ diskStatus = parsed?.status ?? null;
421
527
  if (Number.isInteger(parsed?.rev)) {
422
528
  diskRev = parsed.rev;
423
529
  priorRev = Math.max(priorRev, parsed.rev);
@@ -448,7 +554,15 @@ async function main() {
448
554
  }
449
555
  }
450
556
 
557
+ const refusal = completionRefusal(next, payload, replace, diskStatus);
558
+ if (refusal) {
559
+ console.error(refusal);
560
+ releaseLock(path);
561
+ process.exit(6);
562
+ }
563
+
451
564
  next.rev = priorRev + 1;
565
+ stampHostSession(next, payload, fileExistedBefore);
452
566
 
453
567
  const unknown = undeclaredKeys(payload, replace ? {} : current);
454
568
  if (unknown.length) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-10-24T11:39:37Z",
3
+ "generatedAt": "2026-10-24T14:34:39Z",
4
4
  "skillCount": 234,
5
5
  "entries": [
6
6
  {