@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.
- package/CHANGELOG.md +13 -0
- package/README.md +14 -1
- package/docs/facts.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +53 -42
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/contract/types/index.d.ts +1 -1
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/phases/operations.md +17 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +37 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/doctor.mjs +54 -0
- package/pipeline/scripts/evidence-gate.mjs +8 -4
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/uninstall.mjs +1 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- 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) {
|