@dtlabs/ai-toolkit 0.13.0 → 0.14.1

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.
@@ -0,0 +1,3729 @@
1
+ 'use strict';
2
+
3
+ // Task-executor module skeleton (FTR-018, INFRA-TASK-BE-03).
4
+ //
5
+ // Public interface for the deterministic task executor: the seven commands
6
+ // from the CLI contract (FTR-018-Tech-Spec.md section 10) — execute
7
+ // (backing the `start` CLI command), status, diagnose, stop, reconcile,
8
+ // resume, replan.
9
+ //
10
+ // This module wires the real state/ledger modules (store.js,
11
+ // INFRA-TASK-BE-01; execution-ledger.js finalizeActivity, INFRA-TASK-BE-02)
12
+ // together with placeholder modules for the pieces that later Work
13
+ // Breakdown tasks implement (ownership.js, plan.js, claude-process.js,
14
+ // git.js — each documented with the task that will replace its stub body).
15
+ // Every public function below currently throws NOT_IMPLEMENTED pointing at
16
+ // the task that implements its real body: this task (INFRA-TASK-BE-03)
17
+ // only establishes the module skeleton and public interface, per its
18
+ // Work Breakdown outcome.
19
+ //
20
+ // dispatchTaskAttempt (US-03-TASK-BE-03), runVerifications (US-04-TASK-BE-01),
21
+ // runReview (US-04-TASK-BE-02) and finalizeTaskCheckpoint (US-05-TASK-BE-05),
22
+ // all added below, are the exceptions: each is a new addition, not one of
23
+ // the seven skeleton commands above, and each has a real body. Together they
24
+ // are the single-task dispatch/verify/review/checkpoint primitives the real
25
+ // run loop (execute(), US-07-TASK-BE-01, still NOT_IMPLEMENTED) will call
26
+ // once per ready task — see each function's own doc comment for its design.
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+ const { spawn, spawnSync } = require('child_process');
32
+
33
+ const store = require('./store');
34
+ const ownership = require('./ownership');
35
+ const plan = require('./plan');
36
+ const claudeProcess = require('./claude-process');
37
+ const git = require('./git');
38
+ const ledger = require('../execution-ledger');
39
+ const agentRegistry = require('../agent-registry');
40
+
41
+ const IS_WINDOWS = process.platform === 'win32';
42
+
43
+ // ── worker-liveness process tagging (US-06-TASK-BE-02) ──────────────────────
44
+ //
45
+ // Generalizes the E-02 evidence-spike's PROVEN "intent-first + reconcile-by-
46
+ // tag" technique (see internal_docs/features/FTR-018-deterministic-task-
47
+ // execution-checkpoints-and-resume/evidence/E-02-process-supervision/
48
+ // harness-registration-window.js): every dispatched worker's argv carries a
49
+ // unique, discoverable tag; a coordinator that crashes and later comes back
50
+ // (reconcile/resume) can positively confirm whether that exact worker is
51
+ // still alive by querying the OS for a live process whose command line
52
+ // contains the tag — never by trusting its own possibly-stale in-memory
53
+ // belief about what it dispatched. dispatchTaskAttempt (US-03-TASK-BE-03,
54
+ // below) is the writer side: it generates the tag and durably persists it
55
+ // into attempt.processIdentity BEFORE spawning. _findLiveTaggedProcess below
56
+ // is the reader side, used by reconcile/resume's evidence evaluation
57
+ // (US-06-TASK-BE-02) to answer "is the worker that owns this tag still
58
+ // running right now".
59
+ const PROCESS_TAG_PREFIX = 'ai-toolkit-task';
60
+
61
+ function _buildProcessTag(runId, taskId, attemptNumber) {
62
+ return PROCESS_TAG_PREFIX + ':' + runId + ':' + taskId + ':' + attemptNumber;
63
+ }
64
+
65
+ // Default basename of the real, qualified Claude CLI executable this module
66
+ // spawns in production (Windows: claude.exe) — the actual spawned executable
67
+ // dispatchTaskAttempt invokes, NOT node.exe (the harness spike's own fake
68
+ // workers were plain Node scripts, which is why its filter used
69
+ // "Name='node.exe'"; this module's real, production spawn target is the
70
+ // Claude CLI itself, so the filter must match ITS basename instead).
71
+ // Callers (reconcile/resume/evaluateTaskEvidence) may override this default
72
+ // via an explicit exeBasename — tests do so unconditionally, since they
73
+ // spawn a real, short-lived Node child process (never a real claude.exe) as
74
+ // the tagged "worker" under test, per this task's own testing constraint.
75
+ const DEFAULT_TAGGED_PROCESS_EXE_BASENAME = 'claude.exe';
76
+
77
+ // Real, positive-evidence-only liveness query for a tagged process, using
78
+ // the exact E-02-qualified Win32_Process/CIM technique proven in the Gate-1
79
+ // evidence spike, adapted from `Name='node.exe'` to the caller-supplied
80
+ // `exeBasename` (the real executable dispatchTaskAttempt actually spawned):
81
+ // Get-CimInstance Win32_Process -Filter "Name='<exeBasename>'" |
82
+ // Where-Object { $_.CommandLine -like '*<tag>*' } |
83
+ // Select-Object -ExpandProperty ProcessId
84
+ // Same shell:false-at-the-Node-level, single-PowerShell-argv-string spawn
85
+ // convention already used by ownership.js's own _queryProcessStartTimeSync
86
+ // and claude-process.js's tree-kill helper (the PowerShell command string
87
+ // itself is unavoidably one argument; nothing here is built by concatenating
88
+ // untrusted input into a shell command outside that one, already-established
89
+ // pattern).
90
+ //
91
+ // Windows-only (Gate-1 E-02 qualification: "no implicit cross-platform
92
+ // guarantee") — mirrors ownership.js's checkOwnerLiveness discipline exactly:
93
+ // only ever returns positive evidence, never coerces an inconclusive result
94
+ // into either extreme.
95
+ //
96
+ // Returns exactly one of:
97
+ // { status: 'found-alive', pids: number[], reason } — one or more
98
+ // live processes named `exeBasename` whose command line contains `tag`.
99
+ // { status: 'confirmed-not-found', pids: [], reason } — the OS
100
+ // positively confirms no such process exists (positive absence
101
+ // evidence, not merely "we didn't look").
102
+ // { status: 'unknown', pids: [], reason } — non-Windows,
103
+ // invalid input, or the OS query itself failed/returned an untrusted
104
+ // result. Callers MUST treat this exactly like 'found-alive' for any
105
+ // "is it safe to touch this attempt" decision — never as evidence of
106
+ // death.
107
+ function _findLiveTaggedProcess(tag, exeBasename) {
108
+ if (!IS_WINDOWS) {
109
+ return {
110
+ status: 'unknown',
111
+ pids: [],
112
+ reason:
113
+ 'process liveness checking is only qualified on Windows (E-02 evidence spike); ' +
114
+ 'this platform ("' + process.platform + '") has no qualified liveness mechanism yet',
115
+ };
116
+ }
117
+ if (typeof tag !== 'string' || tag.length === 0) {
118
+ return { status: 'unknown', pids: [], reason: '_findLiveTaggedProcess: tag must be a non-empty string' };
119
+ }
120
+ if (typeof exeBasename !== 'string' || exeBasename.length === 0) {
121
+ return { status: 'unknown', pids: [], reason: '_findLiveTaggedProcess: exeBasename must be a non-empty string' };
122
+ }
123
+
124
+ let result;
125
+ try {
126
+ result = spawnSync(
127
+ 'powershell',
128
+ [
129
+ '-NoProfile',
130
+ '-NonInteractive',
131
+ '-Command',
132
+ "Get-CimInstance Win32_Process -Filter \"Name='" + exeBasename + "'\" | " +
133
+ "Where-Object { $_.CommandLine -like '*" + tag + "*' } | " +
134
+ 'Select-Object -ExpandProperty ProcessId',
135
+ ],
136
+ { encoding: 'utf8', windowsHide: true }
137
+ );
138
+ } catch (_) {
139
+ return { status: 'unknown', pids: [], reason: '_findLiveTaggedProcess: failed to spawn the OS process query' };
140
+ }
141
+
142
+ if (!result || result.error || result.status !== 0 || typeof result.stdout !== 'string') {
143
+ return {
144
+ status: 'unknown',
145
+ pids: [],
146
+ reason: '_findLiveTaggedProcess: the OS process query failed or returned an untrusted result',
147
+ };
148
+ }
149
+
150
+ const pids = result.stdout
151
+ .split(/\r?\n/)
152
+ .map(function (s) { return s.trim(); })
153
+ .filter(Boolean)
154
+ .map(Number)
155
+ .filter(function (n) { return Number.isInteger(n) && n > 0; });
156
+
157
+ if (pids.length > 0) {
158
+ return {
159
+ status: 'found-alive',
160
+ pids: pids,
161
+ reason:
162
+ 'found ' + pids.length + ' live process(es) named "' + exeBasename + '" whose command line ' +
163
+ 'matches tag "' + tag + '" (confirmed via OS process query)',
164
+ };
165
+ }
166
+ return {
167
+ status: 'confirmed-not-found',
168
+ pids: [],
169
+ reason:
170
+ 'no live process named "' + exeBasename + '" with a command line matching tag "' + tag + '" was found ' +
171
+ '(positive absence evidence, confirmed via OS process query)',
172
+ };
173
+ }
174
+
175
+ // Tree-kill for stop()'s 'immediate' mode, given only a bare PID discovered
176
+ // via _findLiveTaggedProcess — NOT claude-process.js's own
177
+ // _killProcessTreeSync(child), which requires a live ChildProcess handle this
178
+ // process spawned itself. stop() has no such handle: the worker it is
179
+ // terminating may belong to a completely different coordinator process (the
180
+ // one that originally called dispatchTaskAttempt), discovered only through
181
+ // the OS-level tag query. Same E-02-qualified technique otherwise
182
+ // (`taskkill /PID <pid> /T /F` on Windows; best-effort SIGKILL by PID,
183
+ // reported 'unknown', elsewhere) — duplicated rather than exported from
184
+ // claude-process.js for the same reason _killVerificationProcessTreeSync
185
+ // above already duplicates it: a different trust/lifetime domain, kept
186
+ // independently readable.
187
+ function _killProcessTreeByPidSync(pid) {
188
+ if (IS_WINDOWS) {
189
+ const res = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
190
+ stdio: 'ignore',
191
+ windowsHide: true,
192
+ });
193
+ return !res.error && res.status === 0;
194
+ }
195
+ try {
196
+ process.kill(pid, 'SIGKILL');
197
+ } catch (_) {
198
+ // best-effort only
199
+ }
200
+ return 'unknown';
201
+ }
202
+
203
+ function _sleep(ms) {
204
+ return new Promise(function (resolve) { setTimeout(resolve, ms); });
205
+ }
206
+
207
+ // Bounded poll for stop()'s 'immediate' mode: after issuing the kill above,
208
+ // re-queries _findLiveTaggedProcess up to `maxAttempts` times (a handful of
209
+ // short waits, never an unbounded loop) until the OS positively confirms the
210
+ // tagged process is gone. Returns true ONLY on an explicit
211
+ // 'confirmed-not-found' result — 'found-alive' (kill did not yet take
212
+ // effect) and 'unknown' (inconclusive OS query) both resolve to false, per
213
+ // this module's existing discipline of never coercing an inconclusive result
214
+ // into a positive claim (mirrors _findLiveTaggedProcess's own contract).
215
+ async function _awaitConfirmedTermination(tag, exeBasename, opts) {
216
+ opts = opts || {};
217
+ const maxAttempts = Number.isInteger(opts.maxAttempts) && opts.maxAttempts > 0 ? opts.maxAttempts : 5;
218
+ const intervalMs = Number.isInteger(opts.intervalMs) && opts.intervalMs > 0 ? opts.intervalMs : 200;
219
+
220
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
221
+ const check = _findLiveTaggedProcess(tag, exeBasename);
222
+ if (check.status === 'confirmed-not-found') {
223
+ return true;
224
+ }
225
+ if (attempt < maxAttempts - 1) {
226
+ await _sleep(intervalMs);
227
+ }
228
+ }
229
+ return false;
230
+ }
231
+
232
+ // Bounded per-stream output ceiling for verification commands (10 MiB).
233
+ // Unlike claude-process.js's spawnClaudeAgent (which kills the process tree
234
+ // on overflow, because an LLM agent's output is open-ended and unbounded-by-
235
+ // design), verification commands are finite, deterministic invocations
236
+ // (npm test, grep, test -f, ...) — bounding here only guards memory against
237
+ // a pathological command, not runaway control loss. So on overflow this
238
+ // module simply stops buffering further chunks and marks the stream
239
+ // truncated; it lets the command run to its real exit code rather than
240
+ // killing it, which keeps the recorded exitCode meaningful.
241
+ const DEFAULT_MAX_VERIFICATION_OUTPUT_BYTES = 10 * 1024 * 1024;
242
+
243
+ function _notImplemented(fnName, task) {
244
+ const err = new Error(
245
+ fnName + ' is NOT_IMPLEMENTED — see Work Breakdown task ' + task
246
+ );
247
+ err.code = 'NOT_IMPLEMENTED';
248
+ err.task = task;
249
+ throw err;
250
+ }
251
+
252
+ function _err(code, message) {
253
+ const e = new Error(message);
254
+ e.code = code;
255
+ return e;
256
+ }
257
+
258
+ function _requireNonEmptyString(value, label) {
259
+ if (typeof value !== 'string' || value.length === 0) {
260
+ throw _err('DISPATCH_VALIDATION_ERROR', label + ' must be a non-empty string');
261
+ }
262
+ }
263
+
264
+ // Unlike _requireNonEmptyString, an empty string is valid here (a no-op
265
+ // task's diff is legitimately empty per Tech-Spec section 8: "No-op tasks:
266
+ // require verifications and review proving outcome at the current
267
+ // baseline"). Only non-string values (missing/undefined/wrong type) fail.
268
+ function _requireString(value, label) {
269
+ if (typeof value !== 'string') {
270
+ throw _err('DISPATCH_VALIDATION_ERROR', label + ' must be a string');
271
+ }
272
+ }
273
+
274
+ // Canonical agent ID for the review-solution agent — the same string
275
+ // lib/agent-registry.js's AGENT_TYPE_TO_CANONICAL_ID['review-solution'] and
276
+ // its AGENT_RECORDS entry both use to resolve/dispatch this agent everywhere
277
+ // else in the toolkit. runReview hardcodes it (rather than accepting it as a
278
+ // caller-supplied parameter like dispatchTaskAttempt's generic args.agentId)
279
+ // because this function is specific to one agent, not a generic dispatch
280
+ // primitive for any domain agent.
281
+ const REVIEW_AGENT_ID = 'gaia.agent.review.solution';
282
+
283
+ // Deterministic default prompt built from the plan task's own fields (id,
284
+ // title, outcome, domain, dependsOn, acceptanceCriteria, verificationCommands
285
+ // — see plan.js's parseMarkdown/mapPhases for this shape). This task's scope
286
+ // is dispatch orchestration, not prompt authoring; the prompt is assembled
287
+ // here (rather than requiring every caller to build one) purely so
288
+ // dispatchTaskAttempt is callable with only the plan task object, per its
289
+ // documented signature.
290
+ function _buildTaskPrompt(task) {
291
+ const lines = [
292
+ 'Task ID: ' + task.id,
293
+ 'Title: ' + (task.title || ''),
294
+ 'Domain: ' + (task.domain || ''),
295
+ 'Outcome: ' + (task.outcome || ''),
296
+ ];
297
+ if (Array.isArray(task.dependsOn) && task.dependsOn.length > 0) {
298
+ lines.push('Depends on: ' + task.dependsOn.join(', '));
299
+ }
300
+ if (Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length > 0) {
301
+ lines.push('Acceptance criteria: ' + task.acceptanceCriteria.join(', '));
302
+ }
303
+ if (Array.isArray(task.verificationCommands) && task.verificationCommands.length > 0) {
304
+ lines.push('Verification commands:');
305
+ task.verificationCommands.forEach(function (cmd) { lines.push(' ' + cmd); });
306
+ }
307
+ return lines.join('\n') + '\n';
308
+ }
309
+
310
+ /**
311
+ * Dispatches exactly one implementation agent invocation for one task
312
+ * attempt (US-03-TASK-BE-03; completes US-03 "dispatch one implementation
313
+ * agent per task per attempt"). This is the single-task dispatch primitive
314
+ * the real run loop (US-07-TASK-BE-01, execute()) will call once per ready
315
+ * task — it does not itself iterate the ready queue, and it does not
316
+ * acquire/reclaim the repo-wide execution lease (that is the run-start/
317
+ * resume responsibility of US-07/US-06); it only CHECKS that the caller
318
+ * already holds it.
319
+ *
320
+ * Ordering guarantees (fail-closed at every step, in order):
321
+ * 1. Ownership check — ownership.readLease(executionRoot) must return a
322
+ * lease whose runId matches `runId`. No lease, or a lease held by a
323
+ * different runId, throws NO_LOCK_HELD before anything else happens.
324
+ * 2. Identity verification — claude-process.verifyAgentIdentity({
325
+ * projectDir, agentId }) must succeed. Any failure (AGENT_NOT_VERIFIED,
326
+ * DEFINITION_HASH_MISMATCH) propagates unmodified — dispatch never
327
+ * swallows or rewraps it.
328
+ * 3. Persist-before-invoke (AC-01) — the attempt is recorded via
329
+ * store.js's addAttempt (brand-new attempt: stage 'prepared', written,
330
+ * then updated to 'dispatching', written again) or updateAttempt alone
331
+ * (an attempt numbered `attemptNumber` already exists in state — e.g. a
332
+ * resumed dispatch: stage moves directly to 'dispatching'), and TWO
333
+ * ledger activities for this attempt are opened via execution-ledger.js's
334
+ * open()/computeOperationId — both complete BEFORE spawnClaudeAgent is
335
+ * ever called:
336
+ * - 'implementation' (kind 'implementation'): this one dispatch
337
+ * invocation. Closed as soon as its result is durably received
338
+ * (US-04-TASK-BE-03's persistVerificationReviewOutcome path, via
339
+ * runVerifications/runReview — not by this function).
340
+ * - 'task' (kind 'task'): spans the WHOLE per-task-attempt lifecycle
341
+ * (Tech-Spec section 6: "kinds: task, implementation,
342
+ * verification-<index>, review ... The task activity carries null
343
+ * token usage (reason: control-only) and closes after checkpoint
344
+ * and, for N>1, verified integration"). Opened here (dispatch is the
345
+ * first point at which this attempt becomes active) but ONLY closed
346
+ * by finalizeTaskCheckpoint (US-05-TASK-BE-05, below) — never by
347
+ * this function.
348
+ * 4. Only once 1-3 have succeeded: claude-process.spawnClaudeAgent(...) is
349
+ * called with the verified identity's nativeName wired in as the
350
+ * --agent selector and the task's prompt fed via stdin.
351
+ * 5. Returns a structured result combining the raw spawn result with the
352
+ * run/task/attempt identifiers used. No verification/review/checkpoint
353
+ * happens here (US-04/US-05) — scope ends at "dispatch happened and its
354
+ * raw result was captured".
355
+ *
356
+ * Ledger placement: the per-run ledger file lives alongside the run's other
357
+ * artifacts, at store._executionPaths(executionRoot, runId).runDir, using
358
+ * `runId` itself as the file-name prefix (i.e.
359
+ * <executionRoot>/runs/<runId>/<runId>-token-ledger.json) — this is
360
+ * deliberately separate from any feature-delivery-pipeline ledger (e.g.
361
+ * FTR-018-token-ledger.json), which tracks the cost of building the
362
+ * executor, not the cost of tasks the executor itself dispatches at
363
+ * runtime. The ledger agent keys follow Tech-Spec section 6 exactly:
364
+ * `executor:<runId>:<taskId>:implementation` and
365
+ * `executor:<runId>:<taskId>:task`.
366
+ *
367
+ * @param {object} args
368
+ * @param {string} args.executionRoot - Absolute path to
369
+ * <common-git-dir>/ai-toolkit/execution.
370
+ * @param {string} args.runId - UUID of the run that must currently hold the
371
+ * repo-wide execution lease.
372
+ * @param {object} args.task - Plan task object (plan.js shape): at least
373
+ * `id`; `title`/`outcome`/`domain`/`dependsOn`/`acceptanceCriteria`/
374
+ * `verificationCommands` are used to build the default prompt when
375
+ * `args.prompt` is not supplied.
376
+ * @param {number} args.attemptNumber - Positive integer attempt number for
377
+ * this dispatch. Must equal the task's next attempt number
378
+ * (task.attempts.length + 1 in current state) when no attempt numbered
379
+ * `attemptNumber` already exists, or match an existing attempt's number
380
+ * for a resumed dispatch.
381
+ * @param {string} args.claudePath - Absolute path to the qualified Claude
382
+ * CLI executable (forwarded to spawnClaudeAgent).
383
+ * @param {string} args.agentId - Canonical agent ID to verify and dispatch
384
+ * (forwarded to verifyAgentIdentity; its resolved nativeName is what is
385
+ * actually passed as the --agent selector).
386
+ * @param {string} args.projectDir - Project root used both for identity
387
+ * verification (--project) and as the spawned process's cwd.
388
+ * @param {number} [args.taskTimeoutMs] - Optional per-task timeout budget,
389
+ * forwarded to spawnClaudeAgent.
390
+ * @param {string} [args.prompt] - Explicit prompt override. When omitted, a
391
+ * deterministic default is built from `args.task`'s own fields.
392
+ * @param {string[]} [args.spawnArgs] - Test-only override of the full CLI
393
+ * args array forwarded to spawnClaudeAgent. Defaults to the production
394
+ * shape (`--print --output-format json --agent <nativeName>`). Tests use
395
+ * this to substitute the fake-claude-cli fixture's own invocation shape
396
+ * (see tests/fixtures/fake-claude-cli.js) while still exercising a real
397
+ * subprocess spawn end-to-end. Either way (default or override), the
398
+ * attempt's processIdentity tag (US-06-TASK-BE-02) is appended as one
399
+ * extra trailing argv element before spawning — see the inline comment at
400
+ * the actual spawnArgs assignment below for the documented residual risk
401
+ * this carries against the real (not-yet-exercised) Claude CLI.
402
+ * @returns {Promise<{
403
+ * runId: string, taskId: string, attemptNumber: number,
404
+ * agentId: string, nativeAgentName: string,
405
+ * ledgerOperationId: string, processIdentity: string, spawnResult: object
406
+ * }>}
407
+ * @throws {Error} NO_LOCK_HELD, AGENT_NOT_VERIFIED, DEFINITION_HASH_MISMATCH,
408
+ * ATTEMPT_NUMBER_MISMATCH, STATE_NOT_FOUND, STATE_TASK_NOT_FOUND, or any
409
+ * error surfaced by spawnClaudeAgent (e.g. CLAUDE_EXECUTABLE_NOT_FOUND,
410
+ * CLAUDE_SPAWN_FAILED).
411
+ */
412
+ async function dispatchTaskAttempt(args) {
413
+ args = args || {};
414
+
415
+ _requireNonEmptyString(args.executionRoot, 'dispatchTaskAttempt: args.executionRoot');
416
+ _requireNonEmptyString(args.runId, 'dispatchTaskAttempt: args.runId');
417
+ _requireNonEmptyString(args.claudePath, 'dispatchTaskAttempt: args.claudePath');
418
+ _requireNonEmptyString(args.agentId, 'dispatchTaskAttempt: args.agentId');
419
+ _requireNonEmptyString(args.projectDir, 'dispatchTaskAttempt: args.projectDir');
420
+ if (args.task == null || typeof args.task !== 'object') {
421
+ throw _err('DISPATCH_VALIDATION_ERROR', 'dispatchTaskAttempt: args.task must be an object');
422
+ }
423
+ _requireNonEmptyString(args.task.id, 'dispatchTaskAttempt: args.task.id');
424
+ if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
425
+ throw _err('DISPATCH_VALIDATION_ERROR', 'dispatchTaskAttempt: args.attemptNumber must be a positive integer');
426
+ }
427
+
428
+ const executionRoot = args.executionRoot;
429
+ const runId = args.runId;
430
+ const task = args.task;
431
+ const attemptNumber = args.attemptNumber;
432
+
433
+ // 1. Ownership check — never acquire/reclaim here, only verify the caller
434
+ // already holds the lease it claims to hold.
435
+ const lease = ownership.readLease(executionRoot);
436
+ if (!lease || lease.runId !== runId) {
437
+ throw _err(
438
+ 'NO_LOCK_HELD',
439
+ 'dispatchTaskAttempt: no repo-wide execution lease held by runId "' + runId + '" at ' +
440
+ executionRoot + (lease ? ' (currently held by runId "' + lease.runId + '")' : ' (no lease held)')
441
+ );
442
+ }
443
+
444
+ // 2. Identity verification — propagate failures unmodified.
445
+ const identity = claudeProcess.verifyAgentIdentity({
446
+ projectDir: args.projectDir,
447
+ agentId: args.agentId,
448
+ });
449
+
450
+ // 3. Persist-before-invoke: attempt identity, then ledger activity.
451
+ //
452
+ // processIdentity (US-06-TASK-BE-02): a unique, discoverable per-attempt
453
+ // tag (see the "worker-liveness process tagging" file comment above
454
+ // IS_WINDOWS) is generated here and written into the attempt's
455
+ // processIdentity field in the SAME updateAttempt/writeState call that
456
+ // advances the stage to 'dispatching' — i.e. durably on disk BEFORE
457
+ // spawnClaudeAgent is ever invoked below. This ordering is the whole
458
+ // point: a coordinator crash between this write and the actual spawn (or
459
+ // between the spawn and any later write) still leaves a discoverable,
460
+ // already-persisted trail that reconcile/resume's evidence evaluation can
461
+ // positively confirm alive or dead later, with no dependency on any
462
+ // in-memory state the crashed coordinator held.
463
+ const processIdentity = _buildProcessTag(runId, task.id, attemptNumber);
464
+
465
+ let state = store.readState(executionRoot, runId);
466
+ const stateTaskEntry = state.tasks[task.id];
467
+ const existingAttempt = stateTaskEntry
468
+ ? stateTaskEntry.attempts.find(function (a) { return a.number === attemptNumber; })
469
+ : undefined;
470
+
471
+ if (existingAttempt) {
472
+ state = state.updateAttempt(task.id, attemptNumber, { stage: 'dispatching', processIdentity: processIdentity });
473
+ state = store.writeState(executionRoot, runId, state);
474
+ } else {
475
+ const expectedNextNumber = stateTaskEntry ? stateTaskEntry.attempts.length + 1 : 1;
476
+ if (attemptNumber !== expectedNextNumber) {
477
+ throw _err(
478
+ 'ATTEMPT_NUMBER_MISMATCH',
479
+ 'dispatchTaskAttempt: task "' + task.id + '" expected next attempt number ' +
480
+ expectedNextNumber + ', got ' + attemptNumber
481
+ );
482
+ }
483
+ state = state.addAttempt(task.id, { stage: 'prepared' });
484
+ state = store.writeState(executionRoot, runId, state);
485
+ state = state.updateAttempt(task.id, attemptNumber, { stage: 'dispatching', processIdentity: processIdentity });
486
+ state = store.writeState(executionRoot, runId, state);
487
+ }
488
+
489
+ const ledgerPaths = store._executionPaths(executionRoot, runId);
490
+ const ledgerAgentKey = 'executor:' + runId + ':' + task.id + ':implementation';
491
+ const ledgerMetadata = {
492
+ agentId: identity.agentId,
493
+ nativeAgentName: identity.nativeName,
494
+ platform: process.platform,
495
+ toolkitVersion: identity.toolkitVersion,
496
+ resolutionScope: identity.scope,
497
+ definitionHash: identity.sha256,
498
+ };
499
+ const ledgerOpenResult = ledger.open(
500
+ ledgerPaths.runDir,
501
+ runId,
502
+ ledgerAgentKey,
503
+ 'implementation',
504
+ null,
505
+ attemptNumber,
506
+ ledgerMetadata
507
+ );
508
+
509
+ // Task-kind ledger activity (Tech-Spec section 6) — opened together with
510
+ // 'implementation' above, but spans the whole task-attempt lifecycle: it
511
+ // is deliberately left open here and only ever closed by
512
+ // finalizeTaskCheckpoint (US-05-TASK-BE-05) once the checkpoint SHA has
513
+ // been registered and verified reachable on the feature branch. Opened
514
+ // AFTER 'implementation' so ledgerOpenResult (and every existing caller
515
+ // that reads the first ledger entry written for this attempt) keeps
516
+ // referring to the 'implementation' activity unchanged.
517
+ const ledgerTaskAgentKey = 'executor:' + runId + ':' + task.id + ':task';
518
+ ledger.open(
519
+ ledgerPaths.runDir,
520
+ runId,
521
+ ledgerTaskAgentKey,
522
+ 'task',
523
+ null,
524
+ attemptNumber,
525
+ ledgerMetadata
526
+ );
527
+
528
+ // 4. Only now: dispatch the verified agent.
529
+ const prompt = typeof args.prompt === 'string' ? args.prompt : _buildTaskPrompt(task);
530
+ const baseSpawnArgs = Array.isArray(args.spawnArgs)
531
+ ? args.spawnArgs
532
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName];
533
+ // Append the same processIdentity tag already persisted above as one extra
534
+ // trailing argv element, so it appears verbatim in the spawned process's
535
+ // own command line — this is what makes _findLiveTaggedProcess's later OS
536
+ // query able to find it (see the file-level "worker-liveness process
537
+ // tagging" comment above IS_WINDOWS).
538
+ //
539
+ // RESIDUAL RISK (documented, not blocking): this appends an extra argv
540
+ // element to whatever args the real Claude CLI would otherwise receive.
541
+ // Nothing in this codebase's E-00/E-01/E-02 evidence dossier (see
542
+ // FTR-018-Tech-Spec.md section 3's evidence table) documents a claude.exe
543
+ // CLI flag reference that conclusively confirms an unrecognized trailing
544
+ // positional argv element is safe/ignored by the real executable — every
545
+ // spawn in this test suite (and in this codebase to date) targets
546
+ // tests/fixtures/fake-claude-cli.js, never real claude.exe. The
547
+ // real-Claude-runtime test (US-09-TASK-TEST-02) is separately gated and
548
+ // not yet authorized, so this cannot be empirically validated here. Flagged
549
+ // for verification once that real-runtime test is authorized.
550
+ const spawnArgs = baseSpawnArgs.concat([processIdentity]);
551
+ const spawnResult = await claudeProcess.spawnClaudeAgent({
552
+ claudePath: args.claudePath,
553
+ args: spawnArgs,
554
+ prompt: prompt,
555
+ cwd: args.projectDir,
556
+ taskTimeoutMs: args.taskTimeoutMs,
557
+ });
558
+
559
+ // 5. Raw capture only — no verification/review/checkpoint here.
560
+ return {
561
+ runId: runId,
562
+ taskId: task.id,
563
+ attemptNumber: attemptNumber,
564
+ agentId: identity.agentId,
565
+ nativeAgentName: identity.nativeName,
566
+ ledgerOperationId: ledgerOpenResult.operation_id,
567
+ processIdentity: processIdentity,
568
+ spawnResult: spawnResult,
569
+ };
570
+ }
571
+
572
+ // Deterministic review prompt: task context, the exact diff bytes (verbatim,
573
+ // never re-escaped or reformatted — Tech-Spec section 8: "unexpected tracked
574
+ // changes invalidate the reviewed diff", so the review agent must see
575
+ // exactly what will be checkpointed), and the review criteria the diff must
576
+ // satisfy. There is no separate "review criteria" field in the plan.js task
577
+ // shape (see plan.js's parseMarkdown/mapPhases) — Tech-Spec section 7's
578
+ // binding rule is "review passed=true with no blocking finding" evaluated
579
+ // against the task's own approved acceptance/verification contract, so the
580
+ // task's acceptanceCriteria and verificationCommands are exactly what this
581
+ // function feeds as the criteria the diff must satisfy.
582
+ function _buildReviewPrompt(task, diff) {
583
+ const lines = [
584
+ 'Task ID: ' + task.id,
585
+ 'Title: ' + (task.title || ''),
586
+ 'Domain: ' + (task.domain || ''),
587
+ 'Outcome: ' + (task.outcome || ''),
588
+ ];
589
+ if (task.groupingRationale) {
590
+ lines.push('Grouping rationale: ' + task.groupingRationale);
591
+ }
592
+ if (Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length > 0) {
593
+ lines.push('Acceptance criteria: ' + task.acceptanceCriteria.join(', '));
594
+ }
595
+ lines.push('');
596
+ lines.push('Review criteria (the diff below must satisfy ALL of these):');
597
+ if (Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length > 0) {
598
+ task.acceptanceCriteria.forEach(function (ac) { lines.push(' - Acceptance criterion: ' + ac); });
599
+ }
600
+ if (Array.isArray(task.verificationCommands) && task.verificationCommands.length > 0) {
601
+ task.verificationCommands.forEach(function (cmd) { lines.push(' - Verification command must pass: ' + cmd); });
602
+ }
603
+ if (
604
+ (!Array.isArray(task.acceptanceCriteria) || task.acceptanceCriteria.length === 0) &&
605
+ (!Array.isArray(task.verificationCommands) || task.verificationCommands.length === 0)
606
+ ) {
607
+ lines.push(' (none declared on the task)');
608
+ }
609
+ lines.push('');
610
+ lines.push('--- BEGIN DIFF (exact, verbatim) ---');
611
+ lines.push(diff);
612
+ lines.push('--- END DIFF ---');
613
+ return lines.join('\n') + '\n';
614
+ }
615
+
616
+ // Parses whatever structured verdict it can out of the review-solution
617
+ // agent's reply. That agent's own definition (src/claude/agents/
618
+ // gaia-review-solution.md, "## Output") specifies a fixed human-readable text
619
+ // template — "Verdict: PASS / FAIL", "CRITICAL (blocks merge): ..." — not a
620
+ // JSON contract; there is no machine schema published for it. This function
621
+ // is therefore a best-effort pattern match over that documented template,
622
+ // applied to the CLI's own `result.result` field — spawnClaudeAgent's parsed
623
+ // --output-format json envelope; see tests/fixtures/fake-claude-cli.js's
624
+ // 'echo-json' mode for the shape it mimics: { is_error, result: <agent reply
625
+ // text> }. When the reply does not match the template (missing/malformed
626
+ // envelope, or free text that doesn't follow it), `passed` is reported as
627
+ // `null` rather than guessed — this function never fabricates a pass.
628
+ // Authoritative pass/fail enforcement against the Tech-Spec's binding rule
629
+ // ("review passed=true with no blocking finding") is intentionally left to
630
+ // US-04-TASK-BE-03 (closing / combined verification+review outcome
631
+ // persistence), which is free to treat a non-matching or null reply as a
632
+ // failure — this function only reports what it can read.
633
+ function _extractReviewVerdict(spawnResult) {
634
+ const text = spawnResult && spawnResult.result && typeof spawnResult.result.result === 'string'
635
+ ? spawnResult.result.result
636
+ : null;
637
+
638
+ if (text === null) {
639
+ return { passed: null, criticalFindings: [] };
640
+ }
641
+
642
+ const verdictMatch = text.match(/Verdict:\s*(PASS|FAIL)/i);
643
+ const passed = verdictMatch ? verdictMatch[1].toUpperCase() === 'PASS' : null;
644
+
645
+ const criticalFindings = [];
646
+ const criticalSectionMatch = text.match(
647
+ /CRITICAL \(blocks merge\):([\s\S]*?)(?:\n\s*\n\s*WARNING \(should fix\):|\n\s*\n\s*INFO \(improvements\):|$)/i
648
+ );
649
+ if (criticalSectionMatch) {
650
+ criticalSectionMatch[1].split('\n').forEach(function (line) {
651
+ const trimmed = line.trim();
652
+ if (trimmed.length > 0 && trimmed.toLowerCase() !== 'none') {
653
+ criticalFindings.push(trimmed);
654
+ }
655
+ });
656
+ }
657
+
658
+ return { passed: passed, criticalFindings: criticalFindings };
659
+ }
660
+
661
+ /**
662
+ * Dispatches exactly one review-solution agent invocation for one task
663
+ * attempt (US-04-TASK-BE-02; completes the "dispatch review agent with diff
664
+ * context" half of US-04). Mirrors dispatchTaskAttempt's fail-closed
665
+ * ordering discipline exactly, adapted for the review-solution agent instead
666
+ * of a developer agent:
667
+ * 1. Ownership check — ownership.readLease(executionRoot) must return a
668
+ * lease whose runId matches `runId`. No lease, or a lease held by a
669
+ * different runId, throws NO_LOCK_HELD before anything else happens —
670
+ * no agent (implementation or review) is ever dispatched without the
671
+ * caller demonstrably holding the repo-wide execution lease it claims.
672
+ * 2. Identity verification — claude-process.verifyAgentIdentity({
673
+ * projectDir, agentId: REVIEW_AGENT_ID }) must succeed (Tech-Spec
674
+ * section 7: "Before EACH implementation and review invocation:
675
+ * operational resolveAgent..."). Any failure (AGENT_NOT_VERIFIED,
676
+ * DEFINITION_HASH_MISMATCH) propagates unmodified.
677
+ * 3. Persist-before-invoke — the ledger 'review' activity for this attempt
678
+ * is opened via execution-ledger.js's open()/computeOperationId BEFORE
679
+ * spawnClaudeAgent is ever called. Unlike dispatchTaskAttempt, this does
680
+ * NOT add/update a state attempt: the attempt already exists from the
681
+ * implementation stage, and recording its 'reviewed' stage or any
682
+ * pass/fail outcome is US-04-TASK-BE-03's job (closing / combined
683
+ * verification+review outcome persistence), not this function's — this
684
+ * function only opens the ledger activity.
685
+ * 4. Only once 1-3 have succeeded: claude-process.spawnClaudeAgent(...) is
686
+ * called with the verified identity's nativeName wired in as the
687
+ * --agent selector, and a prompt containing the task's context, the
688
+ * exact diff (verbatim, via stdin — never argv), and the review
689
+ * criteria (the task's own acceptanceCriteria/verificationCommands).
690
+ * 5. Returns a structured result combining the raw spawn result with a
691
+ * best-effort parsed verdict (see _extractReviewVerdict) and the
692
+ * run/task/attempt identifiers used. No outcome persistence happens
693
+ * here — scope ends at "review dispatched and its raw result captured".
694
+ *
695
+ * Ledger placement: same per-run ledger file dispatchTaskAttempt uses
696
+ * (<executionRoot>/runs/<runId>/<runId>-token-ledger.json). The ledger agent
697
+ * key follows Tech-Spec section 6 exactly: `executor:<runId>:<taskId>:review`
698
+ * (kind `review`, per section 6's kinds list: task, implementation,
699
+ * verification-<index>, review).
700
+ *
701
+ * @param {object} args
702
+ * @param {string} args.executionRoot - Absolute path to
703
+ * <common-git-dir>/ai-toolkit/execution.
704
+ * @param {string} args.runId - UUID of the run that must currently hold the
705
+ * repo-wide execution lease.
706
+ * @param {object} args.task - Plan task object (plan.js shape): at least
707
+ * `id`; `title`/`outcome`/`domain`/`groupingRationale`/`acceptanceCriteria`/
708
+ * `verificationCommands` are used to build the review prompt.
709
+ * @param {number} args.attemptNumber - Positive integer attempt number this
710
+ * review belongs to (the attempt already dispatched/verified earlier).
711
+ * @param {string} args.claudePath - Absolute path to the qualified Claude
712
+ * CLI executable (forwarded to spawnClaudeAgent).
713
+ * @param {string} args.projectDir - Project root used both for identity
714
+ * verification (--project) and as the spawned process's cwd.
715
+ * @param {string} args.diff - Exact diff text (e.g. `git diff` output) the
716
+ * review must evaluate against the review criteria. This function does not
717
+ * compute the diff itself — that is the caller's responsibility. An empty
718
+ * string is valid (a no-op task's diff is legitimately empty).
719
+ * @param {number} [args.taskTimeoutMs] - Optional per-task timeout budget,
720
+ * forwarded to spawnClaudeAgent.
721
+ * @param {string[]} [args.spawnArgs] - Test-only override of the full CLI
722
+ * args array forwarded to spawnClaudeAgent, exactly like dispatchTaskAttempt's
723
+ * own args.spawnArgs (see tests/fixtures/fake-claude-cli.js).
724
+ * @returns {Promise<{
725
+ * runId: string, taskId: string, attemptNumber: number,
726
+ * agentId: string, nativeAgentName: string,
727
+ * ledgerOperationId: string, spawnResult: object,
728
+ * reviewPassed: (boolean|null), criticalFindings: string[]
729
+ * }>}
730
+ * @throws {Error} NO_LOCK_HELD, AGENT_NOT_VERIFIED, DEFINITION_HASH_MISMATCH,
731
+ * DISPATCH_VALIDATION_ERROR, or any error surfaced by spawnClaudeAgent
732
+ * (e.g. CLAUDE_EXECUTABLE_NOT_FOUND, CLAUDE_SPAWN_FAILED).
733
+ */
734
+ async function runReview(args) {
735
+ args = args || {};
736
+
737
+ _requireNonEmptyString(args.executionRoot, 'runReview: args.executionRoot');
738
+ _requireNonEmptyString(args.runId, 'runReview: args.runId');
739
+ _requireNonEmptyString(args.claudePath, 'runReview: args.claudePath');
740
+ _requireNonEmptyString(args.projectDir, 'runReview: args.projectDir');
741
+ if (args.task == null || typeof args.task !== 'object') {
742
+ throw _err('DISPATCH_VALIDATION_ERROR', 'runReview: args.task must be an object');
743
+ }
744
+ _requireNonEmptyString(args.task.id, 'runReview: args.task.id');
745
+ if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
746
+ throw _err('DISPATCH_VALIDATION_ERROR', 'runReview: args.attemptNumber must be a positive integer');
747
+ }
748
+ _requireString(args.diff, 'runReview: args.diff');
749
+
750
+ const executionRoot = args.executionRoot;
751
+ const runId = args.runId;
752
+ const task = args.task;
753
+ const attemptNumber = args.attemptNumber;
754
+ const diff = args.diff;
755
+
756
+ // 1. Ownership check — never acquire/reclaim here, only verify the caller
757
+ // already holds it (same precondition dispatchTaskAttempt enforces).
758
+ const lease = ownership.readLease(executionRoot);
759
+ if (!lease || lease.runId !== runId) {
760
+ throw _err(
761
+ 'NO_LOCK_HELD',
762
+ 'runReview: no repo-wide execution lease held by runId "' + runId + '" at ' +
763
+ executionRoot + (lease ? ' (currently held by runId "' + lease.runId + '")' : ' (no lease held)')
764
+ );
765
+ }
766
+
767
+ // 2. Identity verification — propagate failures unmodified.
768
+ const identity = claudeProcess.verifyAgentIdentity({
769
+ projectDir: args.projectDir,
770
+ agentId: REVIEW_AGENT_ID,
771
+ });
772
+
773
+ // 3. Persist-before-invoke: open the ledger 'review' activity only (no
774
+ // state attempt mutation — see doc comment above).
775
+ const ledgerPaths = store._executionPaths(executionRoot, runId);
776
+ const ledgerAgentKey = 'executor:' + runId + ':' + task.id + ':review';
777
+ const ledgerMetadata = {
778
+ agentId: identity.agentId,
779
+ nativeAgentName: identity.nativeName,
780
+ platform: process.platform,
781
+ toolkitVersion: identity.toolkitVersion,
782
+ resolutionScope: identity.scope,
783
+ definitionHash: identity.sha256,
784
+ };
785
+ const ledgerOpenResult = ledger.open(
786
+ ledgerPaths.runDir,
787
+ runId,
788
+ ledgerAgentKey,
789
+ 'review',
790
+ null,
791
+ attemptNumber,
792
+ ledgerMetadata
793
+ );
794
+
795
+ // 4. Only now: dispatch the verified review agent.
796
+ const prompt = _buildReviewPrompt(task, diff);
797
+ const spawnArgs = Array.isArray(args.spawnArgs)
798
+ ? args.spawnArgs
799
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName];
800
+ const spawnResult = await claudeProcess.spawnClaudeAgent({
801
+ claudePath: args.claudePath,
802
+ args: spawnArgs,
803
+ prompt: prompt,
804
+ cwd: args.projectDir,
805
+ taskTimeoutMs: args.taskTimeoutMs,
806
+ });
807
+
808
+ // 5. Raw capture + best-effort verdict extraction — no outcome persistence
809
+ // here (US-04-TASK-BE-03 owns closing this out).
810
+ const verdict = _extractReviewVerdict(spawnResult);
811
+
812
+ return {
813
+ runId: runId,
814
+ taskId: task.id,
815
+ attemptNumber: attemptNumber,
816
+ agentId: identity.agentId,
817
+ nativeAgentName: identity.nativeName,
818
+ ledgerOperationId: ledgerOpenResult.operation_id,
819
+ spawnResult: spawnResult,
820
+ reviewPassed: verdict.passed,
821
+ criticalFindings: verdict.criticalFindings,
822
+ };
823
+ }
824
+
825
+ // Tree-kill for a verification command that exceeded its optional
826
+ // commandTimeoutMs. Duplicates (rather than imports) claude-process.js's
827
+ // E-02-qualified `taskkill /PID <pid> /T /F` technique: that helper is not
828
+ // exported (claude-process.js's public surface is intentionally limited to
829
+ // spawnClaudeAgent/verifyAgentIdentity), and verification commands are a
830
+ // different trust/lifetime domain from Claude subprocess supervision, so
831
+ // duplicating this small helper here keeps the two modules independently
832
+ // readable rather than forcing a cross-module export for one shared branch.
833
+ function _killVerificationProcessTreeSync(child) {
834
+ if (IS_WINDOWS) {
835
+ const res = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
836
+ stdio: 'ignore',
837
+ windowsHide: true,
838
+ });
839
+ return !res.error && res.status === 0;
840
+ }
841
+ try {
842
+ child.kill('SIGKILL');
843
+ } catch (_) {
844
+ // best-effort only
845
+ }
846
+ return 'unknown';
847
+ }
848
+
849
+ // Runs one verification command line as a real Bash command (see
850
+ // runVerifications' doc comment for why Bash + shell:false, rather than
851
+ // shell:true, is the correct choice here). Resolves — never rejects — with a
852
+ // result object describing exactly what happened, including the case where
853
+ // the command could not even be spawned (e.g. no `bash` on PATH): that case
854
+ // is reported via `spawnError` rather than thrown, so a missing shell is
855
+ // handled the same way as any other command failure (AC: "the code should
856
+ // not crash uncaught").
857
+ function _runVerificationCommand(command, cwd, timeoutMs) {
858
+ return new Promise((resolve) => {
859
+ const startedAt = new Date();
860
+ const startedHr = process.hrtime.bigint();
861
+ let settled = false;
862
+ let timedOut = false;
863
+ let timeoutHandle = null;
864
+
865
+ function clearTimer() {
866
+ if (timeoutHandle) {
867
+ clearTimeout(timeoutHandle);
868
+ timeoutHandle = null;
869
+ }
870
+ }
871
+
872
+ function finish(fields) {
873
+ if (settled) return;
874
+ settled = true;
875
+ clearTimer();
876
+ const endedAt = new Date();
877
+ const durationMs = Number(process.hrtime.bigint() - startedHr) / 1e6;
878
+ resolve(Object.assign(
879
+ {
880
+ command: command,
881
+ startedAt: startedAt.toISOString(),
882
+ endedAt: endedAt.toISOString(),
883
+ durationMs: durationMs,
884
+ exitCode: null,
885
+ signal: null,
886
+ stdout: '',
887
+ stderr: '',
888
+ stdoutTruncated: false,
889
+ stderrTruncated: false,
890
+ timedOut: timedOut,
891
+ spawnError: null,
892
+ },
893
+ fields
894
+ ));
895
+ }
896
+
897
+ let child;
898
+ try {
899
+ child = spawn('bash', ['-c', command], {
900
+ cwd: cwd,
901
+ shell: false, // never true: shell:true on Windows silently runs cmd.exe, not Bash
902
+ stdio: ['ignore', 'pipe', 'pipe'],
903
+ windowsHide: true,
904
+ });
905
+ } catch (err) {
906
+ finish({ spawnError: 'failed to spawn bash: ' + err.message });
907
+ return;
908
+ }
909
+
910
+ const stdoutChunks = [];
911
+ const stderrChunks = [];
912
+ let stdoutBytes = 0;
913
+ let stderrBytes = 0;
914
+ let stdoutTruncated = false;
915
+ let stderrTruncated = false;
916
+
917
+ child.stdout.on('data', (chunk) => {
918
+ if (stdoutTruncated) return;
919
+ stdoutBytes += chunk.length;
920
+ if (stdoutBytes > DEFAULT_MAX_VERIFICATION_OUTPUT_BYTES) {
921
+ stdoutTruncated = true;
922
+ return;
923
+ }
924
+ stdoutChunks.push(chunk);
925
+ });
926
+
927
+ child.stderr.on('data', (chunk) => {
928
+ if (stderrTruncated) return;
929
+ stderrBytes += chunk.length;
930
+ if (stderrBytes > DEFAULT_MAX_VERIFICATION_OUTPUT_BYTES) {
931
+ stderrTruncated = true;
932
+ return;
933
+ }
934
+ stderrChunks.push(chunk);
935
+ });
936
+
937
+ child.on('error', (err) => {
938
+ // Covers "bash not found on PATH" (ENOENT) and other un-spawnable
939
+ // cases — Tech-Spec section 8's "unsupported shell fails preflight"
940
+ // is satisfied here: the very first verification command fails
941
+ // closed with a clear reason instead of crashing the coordinator.
942
+ finish({ spawnError: 'bash command failed to run: ' + err.message });
943
+ });
944
+
945
+ child.on('close', (code, signal) => {
946
+ finish({
947
+ exitCode: code === undefined ? null : code,
948
+ signal: signal === undefined ? null : signal,
949
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
950
+ stderr: Buffer.concat(stderrChunks).toString('utf8'),
951
+ stdoutTruncated: stdoutTruncated,
952
+ stderrTruncated: stderrTruncated,
953
+ });
954
+ });
955
+
956
+ if (timeoutMs !== null && timeoutMs !== undefined) {
957
+ timeoutHandle = setTimeout(() => {
958
+ if (settled) return;
959
+ timedOut = true;
960
+ _killVerificationProcessTreeSync(child);
961
+ }, timeoutMs);
962
+ }
963
+ });
964
+ }
965
+
966
+ /**
967
+ * Executes one task attempt's APPROVED verification commands — the fenced
968
+ * command blocks parsed losslessly out of the Work Breakdown by plan.js's
969
+ * parseMarkdown (task.verificationCommands) — as real shell command lines
970
+ * (US-04-TASK-BE-01). Tech-Spec section 8: "Verification commands come from
971
+ * approved fenced blocks, preserve exact bytes and declared shell semantics
972
+ * ... Execute independently and require each exit 0; generic npm test
973
+ * success cannot hide an individual failure."
974
+ *
975
+ * Why Bash + shell:false here, unlike claude-process.js's spawnClaudeAgent
976
+ * (shell:false, argv array, no shell at all): spawnClaudeAgent spawns an LLM
977
+ * agent whose prompt content is potentially adversarial natural-language
978
+ * input, so it deliberately avoids a shell entirely to rule out any
979
+ * metacharacter-injection risk. Verification commands are the opposite trust
980
+ * case — they are authored by an approved planning agent, embedded verbatim
981
+ * in the Work Breakdown, and only ever reach this function after a human has
982
+ * approved them at Gate 2 (reviewed, static, versioned text, never raw
983
+ * end-user input). They also legitimately depend on real shell features the
984
+ * Work Breakdown already uses verbatim: pipes, `grep -E 'a|b'`, `test -f`,
985
+ * `||`, redirects like `2>/dev/null` (see this very feature's own Work
986
+ * Breakdown command `npm pack --dry-run 2>/dev/null | grep -E '...'`).
987
+ * Running these without a shell would silently break every command that
988
+ * isn't a single bare argv invocation.
989
+ *
990
+ * This function still spawns with shell:false and an explicit argv array —
991
+ * it just names `bash` as the executable and `-c <command>` as its argv,
992
+ * rather than passing Node's own shell:true (which on Windows launches
993
+ * cmd.exe, not Bash, and would silently reinterpret Unix-only syntax like
994
+ * `test -f`, single-quoted `grep` patterns, or `2>/dev/null`). This is
995
+ * exactly Tech-Spec section 8's requirement: "Existing Bash commands run
996
+ * under an explicitly available Bash, not silently converted to PowerShell
997
+ * ... Unsupported shell fails preflight" — spawning Bash directly is what
998
+ * makes that hold on every platform, Windows included. A missing/unspawnable
999
+ * Bash is not treated as a special preflight step; it surfaces as the first
1000
+ * command's `spawnError`, which fails that command exactly like a non-zero
1001
+ * exit (see _runVerificationCommand).
1002
+ *
1003
+ * Fail-fast: commands run strictly in the order given. The first command
1004
+ * that exits non-zero (or cannot be spawned at all) stops the loop —
1005
+ * subsequent commands are never run for this attempt. This matches the
1006
+ * outcome's "requires each command exit 0" and Tech-Spec's "require each
1007
+ * exit 0; generic npm test success cannot hide an individual failure."
1008
+ *
1009
+ * Persistence: every command's result — including a failing or unspawnable
1010
+ * one — is written as an immutable receipt via store.writeReceipt (the
1011
+ * existing INFRA-TASK-BE-01 primitive; no new persistence primitive was
1012
+ * needed here) BEFORE the loop moves on to the next command, using id
1013
+ * `<taskId>-attempt<attemptNumber>-verification-<index>`. This is a stronger
1014
+ * guarantee than the outcome's literal wording ("persists each result before
1015
+ * proceeding to review" — review is the separate US-04-TASK-BE-02 stage),
1016
+ * but per-command persist-as-you-go is the simplest correct design: it costs
1017
+ * nothing extra, and it means a crash between two verification commands
1018
+ * still leaves every already-completed command's evidence durable on disk,
1019
+ * not just whatever happened to finish before the later review stage began.
1020
+ *
1021
+ * Empty verificationCommands is a trivial pass (nothing to verify) — it is
1022
+ * never treated as a failure or as "unverified".
1023
+ *
1024
+ * Timeout: Tech-Spec section 8 does not mandate a timeout for verification
1025
+ * commands (unlike claude-process.js's taskTimeoutMs for agent dispatch), so
1026
+ * `args.commandTimeoutMs` is optional and defaults to no timeout — a caller
1027
+ * (the future run loop, US-07/US-08) may supply one if it wants to bound a
1028
+ * hanging verification command. When supplied and exceeded, the process
1029
+ * tree is killed using the same E-02-qualified technique claude-process.js
1030
+ * uses (see _killVerificationProcessTreeSync).
1031
+ *
1032
+ * @param {object} args
1033
+ * @param {string} args.executionRoot - Absolute path to
1034
+ * <common-git-dir>/ai-toolkit/execution (forwarded to store.writeReceipt).
1035
+ * @param {string} args.runId - UUID of the current run (forwarded to
1036
+ * store.writeReceipt).
1037
+ * @param {object} args.task - Plan task object (plan.js shape): at least
1038
+ * `id`; `verificationCommands` (array of exact command-line strings) is
1039
+ * the field this function executes. Missing/non-array is treated as no
1040
+ * commands to verify.
1041
+ * @param {number} args.attemptNumber - Positive integer attempt number this
1042
+ * verification run belongs to (used in receipt ids).
1043
+ * @param {string} [args.cwd] - Working directory each command runs in.
1044
+ * Defaults to `process.cwd()`.
1045
+ * @param {number} [args.commandTimeoutMs] - Optional per-command timeout
1046
+ * budget; see "Timeout" above. No default (no timeout) when omitted.
1047
+ * @returns {Promise<{
1048
+ * taskId: string, attemptNumber: number, passed: boolean,
1049
+ * results: Array<{ command: string, exitCode: ?number, stdout: string,
1050
+ * stderr: string, durationMs: number, passed: boolean }>
1051
+ * }>} `results` holds every command run up to and including the first
1052
+ * failure (or all commands, if every one passed).
1053
+ * @throws {Error} code DISPATCH_VALIDATION_ERROR on malformed input (the same
1054
+ * shared validation-error code _requireNonEmptyString already uses for
1055
+ * dispatchTaskAttempt's argument checks above); any
1056
+ * error store.writeReceipt itself throws (e.g. RECEIPT_CONFLICT if this
1057
+ * exact attempt/index was already recorded with different content)
1058
+ * propagates unmodified.
1059
+ */
1060
+ async function runVerifications(args) {
1061
+ args = args || {};
1062
+
1063
+ _requireNonEmptyString(args.executionRoot, 'runVerifications: args.executionRoot');
1064
+ _requireNonEmptyString(args.runId, 'runVerifications: args.runId');
1065
+ if (args.task == null || typeof args.task !== 'object') {
1066
+ throw _err('DISPATCH_VALIDATION_ERROR', 'runVerifications: args.task must be an object');
1067
+ }
1068
+ _requireNonEmptyString(args.task.id, 'runVerifications: args.task.id');
1069
+ if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
1070
+ throw _err('DISPATCH_VALIDATION_ERROR', 'runVerifications: args.attemptNumber must be a positive integer');
1071
+ }
1072
+
1073
+ const executionRoot = args.executionRoot;
1074
+ const runId = args.runId;
1075
+ const task = args.task;
1076
+ const attemptNumber = args.attemptNumber;
1077
+ const cwd = args.cwd === undefined ? process.cwd() : args.cwd;
1078
+ const commandTimeoutMs = args.commandTimeoutMs === undefined ? null : args.commandTimeoutMs;
1079
+
1080
+ const commands = Array.isArray(task.verificationCommands) ? task.verificationCommands : [];
1081
+
1082
+ const results = [];
1083
+ for (let index = 0; index < commands.length; index++) {
1084
+ const command = commands[index];
1085
+ const outcome = await _runVerificationCommand(command, cwd, commandTimeoutMs);
1086
+ const passed = outcome.spawnError === null && outcome.exitCode === 0;
1087
+
1088
+ const receiptId = task.id + '-attempt' + attemptNumber + '-verification-' + index;
1089
+ store.writeReceipt(executionRoot, runId, receiptId, {
1090
+ taskId: task.id,
1091
+ attemptNumber: attemptNumber,
1092
+ index: index,
1093
+ command: command,
1094
+ exitCode: outcome.exitCode,
1095
+ signal: outcome.signal,
1096
+ stdout: outcome.stdout,
1097
+ stderr: outcome.stderr,
1098
+ stdoutTruncated: outcome.stdoutTruncated,
1099
+ stderrTruncated: outcome.stderrTruncated,
1100
+ durationMs: outcome.durationMs,
1101
+ startedAt: outcome.startedAt,
1102
+ endedAt: outcome.endedAt,
1103
+ timedOut: outcome.timedOut,
1104
+ spawnError: outcome.spawnError,
1105
+ passed: passed,
1106
+ });
1107
+
1108
+ results.push({
1109
+ command: command,
1110
+ exitCode: outcome.exitCode,
1111
+ stdout: outcome.stdout,
1112
+ stderr: outcome.stderr,
1113
+ durationMs: outcome.durationMs,
1114
+ passed: passed,
1115
+ });
1116
+
1117
+ if (!passed) {
1118
+ return { taskId: task.id, attemptNumber: attemptNumber, passed: false, results: results };
1119
+ }
1120
+ }
1121
+
1122
+ return { taskId: task.id, attemptNumber: attemptNumber, passed: true, results: results };
1123
+ }
1124
+
1125
+ // 40-character hex SHA-1, case-insensitive — same pattern store.js's
1126
+ // registerCommitSHA and git.js's createTaskCommit already use; kept as its
1127
+ // own local constant here (no shared export exists for it) rather than
1128
+ // reaching into either module's private scope.
1129
+ const SHA_PATTERN = /^[0-9a-f]{40}$/i;
1130
+
1131
+ // Real git reachability check: is `sha` an ancestor of (i.e. reachable by
1132
+ // walking parent links from) whatever `ref` currently resolves to?
1133
+ //
1134
+ // `git merge-base --is-ancestor <sha> <ref>` is used rather than
1135
+ // `git rev-list <ref> | grep <sha>` (enumerate every reachable commit and
1136
+ // text-search it) for two reasons: (1) correctness — rev-list | grep is a
1137
+ // substring match that can false-positive on a SHA that is merely a prefix
1138
+ // of another, and requires shelling a pipe, which this codebase's git
1139
+ // helpers deliberately avoid (shell:false, argv array, no shell
1140
+ // interpolation — see git.js's _runGitSync); merge-base needs no pipe at
1141
+ // all, one argv-only spawnSync call. (2) efficiency — merge-base is the
1142
+ // git-native primitive for exactly this question and can use the commit
1143
+ // graph/generation numbers to answer in effectively O(log history) rather
1144
+ // than materializing and scanning the full reachable set, which matters
1145
+ // once a feature branch has a non-trivial history. Exit code contract per
1146
+ // git-merge-base(1): 0 = is an ancestor (reachable), 1 = is not, anything
1147
+ // else = the query itself was invalid (bad SHA/ref) and is surfaced as a
1148
+ // real error rather than silently treated as "not reachable".
1149
+ //
1150
+ // Duplicated here (not imported from git.js) because git.js has no exported
1151
+ // generic git-spawn helper — its own _runGitSync/_runGitOrThrow are module-
1152
+ // private, and exporting one purely for this one call would widen git.js's
1153
+ // public surface for a single caller. index.js already imports spawnSync
1154
+ // from child_process (dispatchTaskAttempt's sibling helpers use it too), so
1155
+ // this duplicates only the minimal spawn call, in the same shell:false,
1156
+ // argv-array, windowsHide style git.js itself uses.
1157
+ function _isCommitReachableOnRef(cwd, sha, ref) {
1158
+ const res = spawnSync('git', ['merge-base', '--is-ancestor', sha, ref], {
1159
+ cwd: cwd,
1160
+ shell: false,
1161
+ encoding: 'utf8',
1162
+ windowsHide: true,
1163
+ });
1164
+ if (res.error) {
1165
+ throw _err(
1166
+ 'GIT_SPAWN_FAILED',
1167
+ 'finalizeTaskCheckpoint: failed to spawn "git merge-base --is-ancestor ' + sha + ' ' + ref + '": ' +
1168
+ res.error.message
1169
+ );
1170
+ }
1171
+ if (res.status === 0) return true;
1172
+ if (res.status === 1) return false;
1173
+ throw _err(
1174
+ 'GIT_MERGE_BASE_FAILED',
1175
+ 'finalizeTaskCheckpoint: "git merge-base --is-ancestor ' + sha + ' ' + ref + '" in ' + cwd +
1176
+ ' failed unexpectedly (exit ' + res.status + '): ' + (res.stderr || '').trim()
1177
+ );
1178
+ }
1179
+
1180
+ /**
1181
+ * Finalizes one task attempt's checkpoint (US-05-TASK-BE-05; completes US-05
1182
+ * "checkpoint, stage, commit, and register SHA"). Runs only after
1183
+ * createTaskCommit (US-05-TASK-BE-03, git.js) has produced a commit and
1184
+ * store.js's registerCommitSHA (US-05-TASK-BE-04) has already durably
1185
+ * recorded it — this function does not commit or register anything itself,
1186
+ * it reconciles and closes out what was already recorded:
1187
+ *
1188
+ * 1. Read the attempt's registered SHA. The attempt must already be at
1189
+ * stage 'committed' (the stage registerCommitSHA leaves it at) — if it
1190
+ * is not, fails closed with TASK_NOT_COMMITTED before touching Git or
1191
+ * the ledger. This is the literal "commit/ledger reconciliation" guard:
1192
+ * a checkpoint can never be finalized on the strength of a chat/agent
1193
+ * self-report alone, only on a durably persisted, staged transition.
1194
+ * 2. Verify the registered SHA is REACHABLE on `taskRef` right now, via a
1195
+ * real `git merge-base --is-ancestor` call (see
1196
+ * _isCommitReachableOnRef above for why this call and not a
1197
+ * rev-list-and-grep). A trailer or a bare `git show` success is
1198
+ * explicitly insufficient (Tech-Spec section 8: "A trailer alone or
1199
+ * git show success is insufficient") — ancestry is the only thing this
1200
+ * function trusts. Fails closed with SHA_NOT_ON_BRANCH if the SHA is
1201
+ * not an ancestor of `taskRef`.
1202
+ * 3. Mark the task's overall status 'checkpointed' via store.js's
1203
+ * setTaskStatus (existing TASK_STATUSES enum value — not a new status).
1204
+ * 4. Close the ledger's 'task'-kind activity — the one dispatchTaskAttempt
1205
+ * (US-03-TASK-BE-03) opens at dispatch time and deliberately never
1206
+ * closes — via execution-ledger.js's finalizeActivity, status 'done',
1207
+ * tokens null with reason 'control-only' (Tech-Spec section 6: "The
1208
+ * task activity carries null token usage (reason: control-only)"),
1209
+ * using the exact same operation_id/agent-key dispatchTaskAttempt used
1210
+ * to open it (`executor:<runId>:<taskId>:task`, same attemptNumber).
1211
+ *
1212
+ * Scope boundary (explicit — do not extend past this without a later Work
1213
+ * Breakdown task): this function implements the N=1 case only, where
1214
+ * "closing after checkpoint" IS the terminal task-activity closure
1215
+ * (Tech-Spec section 6: "closes after checkpoint and, for N>1, verified
1216
+ * integration"; section 8: "N=1: task commit is on feature branch and
1217
+ * integratedSha=originalSha after verification"). For maxConcurrency N>1,
1218
+ * an attempt's commit lands on a private technical branch first and is only
1219
+ * later cherry-picked onto the feature branch by a separate, serialized
1220
+ * integration step (US-08-TASK-BE-03/04, not yet implemented) — closing the
1221
+ * task activity here, before that integration has happened and been
1222
+ * verified, would violate the Tech-Spec's own closing rule for N>1. This
1223
+ * function does not know or enforce N=1 vs N>1 itself (no execute()/run
1224
+ * loop exists yet to drive either path, per US-07/US-08); it is the future
1225
+ * N>1 call site's responsibility to call this function only once
1226
+ * integration is verified (or to route through a different, integration-
1227
+ * aware finalization step once US-08-TASK-BE-03/04 land), not to call it
1228
+ * straight after createTaskCommit's original commit the way the current N=1
1229
+ * call site does.
1230
+ *
1231
+ * Idempotency: calling this twice for the same already-checkpointed attempt
1232
+ * is safe. Step 1's precondition (attempt.stage === 'committed') still holds
1233
+ * on a second call — nothing in this function advances attempt.stage past
1234
+ * 'committed' — so the reachability check simply re-verifies (cheap, no
1235
+ * side effect), store.setTaskStatus('checkpointed') is a harmless repeat
1236
+ * assignment, and finalizeActivity's own replay contract (identical
1237
+ * status/tokens/reason/completedAt returns the existing entry unchanged; is
1238
+ * exact-operation_id-match, so a second call finds the entry finalizeActivity
1239
+ * would find are conflicting instead of duplicated) makes the ledger close
1240
+ * idempotent without any extra bookkeeping in this function.
1241
+ *
1242
+ * @param {object} args
1243
+ * @param {string} args.executionRoot - Absolute path to
1244
+ * <common-git-dir>/ai-toolkit/execution.
1245
+ * @param {string} args.runId - UUID of the run.
1246
+ * @param {string} args.taskId - Task ID whose attempt is being finalized.
1247
+ * @param {number} args.attemptNumber - Positive integer attempt number to
1248
+ * finalize (must match the attempt registerCommitSHA already advanced to
1249
+ * 'committed').
1250
+ * @param {string} args.projectDir - Absolute path to the Git worktree/repo
1251
+ * the reachability check runs in (the same repo createTaskCommit committed
1252
+ * into).
1253
+ * @param {string} args.taskRef - The feature/task ref the registered SHA
1254
+ * must be reachable from (N=1: the feature branch itself, per Tech-Spec
1255
+ * section 8).
1256
+ * @returns {{taskId: string, attemptNumber: number, taskStatus: 'checkpointed',
1257
+ * originalSha: string, taskRef: string, ledgerOperationId: string}}
1258
+ * @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
1259
+ * STATE_NOT_FOUND / STATE_TASK_NOT_FOUND / STATE_ATTEMPT_NOT_FOUND when the
1260
+ * target does not exist; TASK_NOT_COMMITTED when the attempt has not yet
1261
+ * reached stage 'committed'; SHA_NOT_ON_BRANCH when the registered SHA is
1262
+ * not reachable from `taskRef`; GIT_SPAWN_FAILED / GIT_MERGE_BASE_FAILED
1263
+ * for a git invocation problem; ACTIVITY_NOT_FOUND / TERMINAL_CONFLICT
1264
+ * propagated unmodified from execution-ledger.js's finalizeActivity.
1265
+ */
1266
+ function finalizeTaskCheckpoint(args) {
1267
+ args = args || {};
1268
+
1269
+ _requireNonEmptyString(args.executionRoot, 'finalizeTaskCheckpoint: args.executionRoot');
1270
+ _requireNonEmptyString(args.runId, 'finalizeTaskCheckpoint: args.runId');
1271
+ _requireNonEmptyString(args.taskId, 'finalizeTaskCheckpoint: args.taskId');
1272
+ _requireNonEmptyString(args.projectDir, 'finalizeTaskCheckpoint: args.projectDir');
1273
+ _requireNonEmptyString(args.taskRef, 'finalizeTaskCheckpoint: args.taskRef');
1274
+ if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
1275
+ throw _err('DISPATCH_VALIDATION_ERROR', 'finalizeTaskCheckpoint: args.attemptNumber must be a positive integer');
1276
+ }
1277
+
1278
+ const executionRoot = args.executionRoot;
1279
+ const runId = args.runId;
1280
+ const taskId = args.taskId;
1281
+ const attemptNumber = args.attemptNumber;
1282
+ const projectDir = args.projectDir;
1283
+ const taskRef = args.taskRef;
1284
+
1285
+ // 1. Read the registered SHA — the attempt must already be 'committed'.
1286
+ let state = store.readState(executionRoot, runId);
1287
+ const task = state.tasks[taskId];
1288
+ if (!task) {
1289
+ throw _err('STATE_TASK_NOT_FOUND', 'finalizeTaskCheckpoint: task "' + taskId + '" not found');
1290
+ }
1291
+ const attempt = task.attempts.find(function (a) { return a.number === attemptNumber; });
1292
+ if (!attempt) {
1293
+ throw _err(
1294
+ 'STATE_ATTEMPT_NOT_FOUND',
1295
+ 'finalizeTaskCheckpoint: attempt ' + attemptNumber + ' not found for task "' + taskId + '"'
1296
+ );
1297
+ }
1298
+ if (attempt.stage !== 'committed' || typeof attempt.originalSha !== 'string' || !SHA_PATTERN.test(attempt.originalSha)) {
1299
+ throw _err(
1300
+ 'TASK_NOT_COMMITTED',
1301
+ 'finalizeTaskCheckpoint: attempt ' + attemptNumber + ' of task "' + taskId + '" is at stage "' + attempt.stage +
1302
+ '" and cannot be checkpointed — a checkpoint may only be finalized once the commit SHA has been ' +
1303
+ 'registered (expected stage "committed" with a valid originalSha)'
1304
+ );
1305
+ }
1306
+ const commitSha = attempt.originalSha;
1307
+
1308
+ // 2. Real git reachability check — never trust the trailer/SHA alone.
1309
+ const reachable = _isCommitReachableOnRef(projectDir, commitSha, taskRef);
1310
+ if (!reachable) {
1311
+ throw _err(
1312
+ 'SHA_NOT_ON_BRANCH',
1313
+ 'finalizeTaskCheckpoint: registered commit ' + commitSha + ' for attempt ' + attemptNumber + ' of task "' +
1314
+ taskId + '" is not reachable from "' + taskRef + '" — refusing to finalize the checkpoint; nothing changed'
1315
+ );
1316
+ }
1317
+
1318
+ // 3. Mark the task's overall status 'checkpointed' (existing TASK_STATUSES
1319
+ // value — see store.js).
1320
+ state = state.setTaskStatus(taskId, 'checkpointed');
1321
+ state = store.writeState(executionRoot, runId, state);
1322
+
1323
+ // 4. Close the ledger's 'task'-kind activity dispatchTaskAttempt opened
1324
+ // and deliberately left open — same operation_id/agent-key/attempt it was
1325
+ // opened with.
1326
+ const ledgerPaths = store._executionPaths(executionRoot, runId);
1327
+ const ledgerTaskAgentKey = 'executor:' + runId + ':' + taskId + ':task';
1328
+ const finalizeResult = ledger.finalizeActivity(ledgerPaths.runDir, runId, ledgerTaskAgentKey, attemptNumber, {
1329
+ status: 'done',
1330
+ tokens: null,
1331
+ reason: 'control-only',
1332
+ });
1333
+
1334
+ return {
1335
+ taskId: taskId,
1336
+ attemptNumber: attemptNumber,
1337
+ taskStatus: 'checkpointed',
1338
+ originalSha: commitSha,
1339
+ taskRef: taskRef,
1340
+ ledgerOperationId: finalizeResult.operation_id,
1341
+ };
1342
+ }
1343
+
1344
+ // ── reconcile/resume evidence classification (US-06-TASK-BE-01/02) ─────────
1345
+ //
1346
+ // Classifies each task's current evidence against the Tech-Spec section 9
1347
+ // edge-cases table and applies an idempotent repair for the rows that have
1348
+ // one (registerCommitSHA, finalizeTaskCheckpoint).
1349
+ //
1350
+ // US-06-TASK-BE-02 completes the two rows US-06-TASK-BE-01 left as an
1351
+ // honest partial (see its own historical comment, preserved in git history):
1352
+ // - "Worker still live or unknown" / "Dead worker, partial diff": now a
1353
+ // REAL check, via the processIdentity tag dispatchTaskAttempt persists
1354
+ // before spawning and _findLiveTaggedProcess's OS-level query (see the
1355
+ // "worker-liveness process tagging" file comment above IS_WINDOWS) —
1356
+ // _classifyLiveOrDeadWorkerAttempt below. An attempt with no recorded
1357
+ // processIdentity (predates this feature, or dispatch never reached the
1358
+ // persist step) is still reported honestly as liveness-unknown — never
1359
+ // silently treated as dead, never replaced. Only a positive
1360
+ // confirmed-not-found result, together with the retry policy still
1361
+ // permitting a new attempt, ever reports 'dead-worker'.
1362
+ // - "Failed verification/review ... within retry policy": full retry-
1363
+ // policy attempt-count enforcement ("one initial implementation + one
1364
+ // automatic rework, two total per task ... a task beyond policy is
1365
+ // blocked: replan-required") is now applied — _classifyFailedAttempt
1366
+ // below.
1367
+ // Also NOT implemented: the Tech-Spec's full "Recovery of missing SHA"
1368
+ // search ("search reachable commits from recorded task/feature refs; match
1369
+ // exact trailers, parent, full tree and path inventory ... one exact
1370
+ // candidate is recoverable ... multiple/incompatible candidates block").
1371
+ // That is a full scan over every commit reachable from taskRef. This module
1372
+ // only inspects the CURRENT TIP of taskRef as the sole recovery candidate
1373
+ // (reusing finalizeTaskCheckpoint's own merge-base-adjacent, single-commit
1374
+ // inspection style) — a narrower, real, working check consistent with
1375
+ // createTaskCommit's own post-commit tree/parent/trailer reinspection
1376
+ // pattern (lib/task-executor/git.js), not a reachable-set-wide scan. A tip
1377
+ // that does not exactly match this attempt's persisted checkpoint intent is
1378
+ // reported as blocked (ambiguous), never guessed at.
1379
+ //
1380
+ // "Reconcile/resume require no live competing coordinator" (Tech-Spec
1381
+ // section 9) IS enforced, by _ensureNoLiveCompetingCoordinator below, called
1382
+ // at the very start of both reconcile() and resume() — before any repair
1383
+ // (registerCommitSHA/finalizeTaskCheckpoint). See that function's own doc
1384
+ // comment for the exact decision table. Neither function acquires or
1385
+ // reclaims the repo-wide execution lease itself — only verifies no OTHER
1386
+ // live coordinator holds it.
1387
+
1388
+ // Narrow, tip-only commit inspection — NOT a reachable-set scan (see file
1389
+ // comment above). Duplicates (rather than imports) the minimal tree/parent/
1390
+ // trailer parsing git.js's createTaskCommit already does privately, in the
1391
+ // same shell:false/argv-array/windowsHide spawn style already used
1392
+ // throughout this file (e.g. _isCommitReachableOnRef above). Returns null
1393
+ // (never throws) when `sha` cannot be read as a commit object.
1394
+ function _readCommitObjectSummary(cwd, sha) {
1395
+ const res = spawnSync('git', ['cat-file', '-p', sha], {
1396
+ cwd: cwd,
1397
+ shell: false,
1398
+ encoding: 'utf8',
1399
+ windowsHide: true,
1400
+ });
1401
+ if (res.error || res.status !== 0) return null;
1402
+
1403
+ const lines = res.stdout.split('\n');
1404
+ let tree = null;
1405
+ const parents = [];
1406
+ let i = 0;
1407
+ for (; i < lines.length; i += 1) {
1408
+ const line = lines[i];
1409
+ if (line === '') { i += 1; break; }
1410
+ if (line.indexOf('tree ') === 0) {
1411
+ tree = line.slice(5).trim();
1412
+ } else if (line.indexOf('parent ') === 0) {
1413
+ parents.push(line.slice(7).trim());
1414
+ }
1415
+ }
1416
+ const message = lines.slice(i).join('\n');
1417
+ const trimmed = message.replace(/\s+$/, '');
1418
+ const paragraphs = trimmed.split(/\n\n+/);
1419
+ const lastParagraph = paragraphs[paragraphs.length - 1] || '';
1420
+ const trailers = {};
1421
+ lastParagraph.split('\n').forEach(function (line) {
1422
+ const idx = line.indexOf(': ');
1423
+ if (idx === -1) return;
1424
+ trailers[line.slice(0, idx)] = line.slice(idx + 2);
1425
+ });
1426
+
1427
+ return { tree: tree, parents: parents, trailers: trailers };
1428
+ }
1429
+
1430
+ // "Corrupted/modified state" (AC-12, US-06-TASK-BE-02): a completed task
1431
+ // (checkpointed/integrated) or an already-'committed' attempt awaiting only
1432
+ // checkpoint finalization whose last dispatched attempt's TAGGED WORKER
1433
+ // PROCESS IS STILL ALIVE right now is an internally inconsistent evidence
1434
+ // set. dispatchTaskAttempt only ever returns once spawnClaudeAgent's promise
1435
+ // resolves — i.e. after that process has fully exited — so a genuinely
1436
+ // finished attempt's worker should already be gone; a live match here means
1437
+ // either the tag was reused/corrupted, or a stale/orphaned process is still
1438
+ // running under the identity this run believes is done and checkpointed.
1439
+ // Detected ONLY from POSITIVE liveness evidence (found-alive): 'unknown' and
1440
+ // 'confirmed-not-found' are the expected, non-corrupted outcomes for a
1441
+ // genuinely finished attempt's worker (already exited, or liveness simply
1442
+ // can't be checked on this platform) and are never themselves treated as
1443
+ // evidence of corruption — mirrors this whole file's "never guess" discipline.
1444
+ function _isCompletedAttemptWorkerUnexpectedlyAlive(ctx, attempt) {
1445
+ if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) {
1446
+ return false;
1447
+ }
1448
+ const liveness = _findLiveTaggedProcess(attempt.processIdentity, ctx.exeBasename);
1449
+ return liveness.status === 'found-alive';
1450
+ }
1451
+
1452
+ // "Integrated commit + valid intent and receipts" / "Completed task but
1453
+ // unreachable/mismatched commit" / "Corrupted/missing evidence" (for an
1454
+ // already-checkpointed/integrated task). Read-only — never repairs a
1455
+ // completed task; a task that already reached 'checkpointed'/'integrated'
1456
+ // with bad evidence is reported blocked, not silently reopened.
1457
+ function _classifyCompletedTask(ctx, taskId, attempt) {
1458
+ if (_isCompletedAttemptWorkerUnexpectedlyAlive(ctx, attempt)) {
1459
+ return { classification: 'blocked', action: 'corrupted-or-modified-state' };
1460
+ }
1461
+
1462
+ if (typeof attempt.originalSha !== 'string' || !SHA_PATTERN.test(attempt.originalSha)) {
1463
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1464
+ }
1465
+
1466
+ let reachable;
1467
+ try {
1468
+ reachable = _isCommitReachableOnRef(ctx.projectDir, attempt.originalSha, ctx.taskRef);
1469
+ } catch (_err) {
1470
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1471
+ }
1472
+ if (!reachable) {
1473
+ return { classification: 'blocked', action: 'unreachable-or-mismatched-commit' };
1474
+ }
1475
+
1476
+ if (!Array.isArray(attempt.intentIds) || attempt.intentIds.length === 0) {
1477
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1478
+ }
1479
+ try {
1480
+ attempt.intentIds.forEach(function (id) { store.readIntent(ctx.executionRoot, ctx.runId, id); });
1481
+ store.readReceipt(ctx.executionRoot, ctx.runId, taskId + '-attempt' + attempt.number + '-outcome');
1482
+ } catch (_err) {
1483
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1484
+ }
1485
+
1486
+ return { classification: 'up-to-date', action: 'keep-completion' };
1487
+ }
1488
+
1489
+ // "Integration commit/ref exists but state missing" (N=1 shape: attempt
1490
+ // already 'committed', task overall status not yet advanced past it).
1491
+ // Repair: finalizeTaskCheckpoint — an already-existing, already-idempotent
1492
+ // primitive (US-05-TASK-BE-05) — is invoked directly; this function does not
1493
+ // duplicate its reachability/ledger logic.
1494
+ function _classifyCommittedAttempt(ctx, taskId, attempt) {
1495
+ if (_isCompletedAttemptWorkerUnexpectedlyAlive(ctx, attempt)) {
1496
+ return { classification: 'blocked', action: 'corrupted-or-modified-state' };
1497
+ }
1498
+
1499
+ if (typeof attempt.originalSha !== 'string' || !SHA_PATTERN.test(attempt.originalSha)) {
1500
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1501
+ }
1502
+
1503
+ let reachable;
1504
+ try {
1505
+ reachable = _isCommitReachableOnRef(ctx.projectDir, attempt.originalSha, ctx.taskRef);
1506
+ } catch (_err) {
1507
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1508
+ }
1509
+ if (!reachable) {
1510
+ return { classification: 'blocked', action: 'unreachable-or-mismatched-commit' };
1511
+ }
1512
+
1513
+ try {
1514
+ finalizeTaskCheckpoint({
1515
+ executionRoot: ctx.executionRoot,
1516
+ runId: ctx.runId,
1517
+ taskId: taskId,
1518
+ attemptNumber: attempt.number,
1519
+ projectDir: ctx.projectDir,
1520
+ taskRef: ctx.taskRef,
1521
+ });
1522
+ } catch (err) {
1523
+ return { classification: 'blocked', action: 'checkpoint-finalization-failed:' + (err.code || 'UNKNOWN') };
1524
+ }
1525
+
1526
+ return { classification: 'reconciled', action: 'checkpoint-finalized', repairAction: 'finalize-checkpoint' };
1527
+ }
1528
+
1529
+ // "Commit exists but SHA not recorded" (attempt at 'checkpoint-prepared',
1530
+ // no originalSha registered yet) vs the case where no commit has landed at
1531
+ // all yet. See the file-level scope comment above: only taskRef's CURRENT
1532
+ // TIP is inspected as the sole recovery candidate — this is intentionally
1533
+ // narrower than the Tech-Spec's full reachable-commit-set scan.
1534
+ function _classifyCheckpointPreparedAttempt(ctx, taskId, attempt) {
1535
+ const checkpointIntentId = taskId + '-attempt' + attempt.number + '-checkpoint';
1536
+ const intentIds = Array.isArray(attempt.intentIds) ? attempt.intentIds : [];
1537
+ if (intentIds.indexOf(checkpointIntentId) === -1) {
1538
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1539
+ }
1540
+
1541
+ let intent;
1542
+ try {
1543
+ intent = store.readIntent(ctx.executionRoot, ctx.runId, checkpointIntentId);
1544
+ } catch (_err) {
1545
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1546
+ }
1547
+
1548
+ const tipRes = spawnSync('git', ['rev-parse', '--verify', ctx.taskRef + '^{commit}'], {
1549
+ cwd: ctx.projectDir,
1550
+ shell: false,
1551
+ encoding: 'utf8',
1552
+ windowsHide: true,
1553
+ });
1554
+ if (tipRes.error || tipRes.status !== 0) {
1555
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1556
+ }
1557
+ const tip = tipRes.stdout.trim();
1558
+
1559
+ if (tip === intent.parentSha) {
1560
+ // taskRef has not moved since the checkpoint intent was persisted — no
1561
+ // commit has landed yet. Reconcile never creates commits; this only
1562
+ // reports the classification.
1563
+ return { classification: 'checkpoint-prepared-no-commit', action: 'needs-commit-creation' };
1564
+ }
1565
+
1566
+ const candidate = _readCommitObjectSummary(ctx.projectDir, tip);
1567
+ const trailerKeys = intent.trailers ? Object.keys(intent.trailers) : [];
1568
+ const matches = candidate !== null &&
1569
+ candidate.parents.length === 1 &&
1570
+ candidate.parents[0] === intent.parentSha &&
1571
+ candidate.tree === intent.expectedTreeSha &&
1572
+ trailerKeys.length > 0 &&
1573
+ trailerKeys.every(function (key) { return candidate.trailers[key] === intent.trailers[key]; });
1574
+
1575
+ if (!matches) {
1576
+ // taskRef moved, but the tip does not exactly match this attempt's own
1577
+ // persisted intent — an ambiguous/incompatible candidate. Per Tech-Spec
1578
+ // ("multiple/incompatible candidates block"): block, never guess.
1579
+ return { classification: 'blocked', action: 'unreachable-or-mismatched-commit' };
1580
+ }
1581
+
1582
+ try {
1583
+ store.registerCommitSHA(ctx.executionRoot, ctx.runId, taskId, attempt.number, tip, { sequential: true });
1584
+ } catch (err) {
1585
+ return { classification: 'blocked', action: 'sha-registration-failed:' + (err.code || 'UNKNOWN') };
1586
+ }
1587
+
1588
+ return { classification: 'reconciled', action: 'commit-sha-registered', repairAction: 'register-commit-sha' };
1589
+ }
1590
+
1591
+ // "Worker still live or unknown" / "Dead worker, partial diff" (Tech-Spec
1592
+ // section 9, US-06-TASK-BE-02). Real check via the processIdentity tag
1593
+ // dispatchTaskAttempt persists before spawning (see the "worker-liveness
1594
+ // process tagging" file comment above IS_WINDOWS) and _findLiveTaggedProcess's
1595
+ // OS-level query.
1596
+ function _classifyLiveOrDeadWorkerAttempt(ctx, taskId, task, attempt) {
1597
+ if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) {
1598
+ // No tag recorded at all (an attempt that predates this feature, or one
1599
+ // whose dispatch never reached the persist-before-invoke step) — cannot
1600
+ // be checked. Same honest-partial result US-06-TASK-BE-01 already
1601
+ // reported unconditionally for every attempt at these stages.
1602
+ return { classification: 'worker-live-or-unknown', action: 'no-replacement-diagnose' };
1603
+ }
1604
+
1605
+ const liveness = _findLiveTaggedProcess(attempt.processIdentity, ctx.exeBasename);
1606
+
1607
+ if (liveness.status === 'found-alive') {
1608
+ // Worker confirmed live: never touch, never dispatch a replacement — the
1609
+ // same safe behavior US-06-TASK-BE-01 already applied unconditionally,
1610
+ // now backed by a real, positive OS confirmation instead of an assumption.
1611
+ return { classification: 'worker-live', action: 'no-replacement-diagnose-or-pause' };
1612
+ }
1613
+
1614
+ if (liveness.status === 'unknown') {
1615
+ // Inconclusive (non-Windows, or the OS query itself failed/untrusted) —
1616
+ // never coerce into either extreme; refuse to touch, exactly like a
1617
+ // confirmed-live worker.
1618
+ return { classification: 'worker-live-or-unknown', action: 'no-replacement-diagnose' };
1619
+ }
1620
+
1621
+ // confirmed-not-found: positive absence evidence — the tagged worker
1622
+ // process genuinely no longer exists. Subject to the retry policy
1623
+ // (Tech-Spec section 9: "one initial implementation + one automatic
1624
+ // rework, two total per task ... a task beyond policy is blocked:
1625
+ // replan-required") — this attempt itself already consumed one of the (at
1626
+ // most two) real implementation invocations task.attempts.length counts
1627
+ // (see _classifyFailedAttempt's own comment for why that count needs no
1628
+ // extra filtering).
1629
+ if (task.attempts.length >= 2) {
1630
+ return { classification: 'blocked', action: 'replan-required' };
1631
+ }
1632
+
1633
+ // "Dead worker, partial diff" -> "Preserve and attribute; new attempt
1634
+ // within retry policy". Whatever partial evidence this attempt already
1635
+ // recorded (a verification/review outcome, or even a commit that somehow
1636
+ // landed despite the worker dying) is preserved as-is — nothing here
1637
+ // discards or rewrites it; hasPartialEvidence is reported for visibility
1638
+ // only, the action is the same either way (a new attempt is needed, subject
1639
+ // to the retry policy just enforced above).
1640
+ const hasPartialEvidence =
1641
+ (Array.isArray(attempt.verificationRefs) && attempt.verificationRefs.length > 0) ||
1642
+ (Array.isArray(attempt.reviewRefs) && attempt.reviewRefs.length > 0) ||
1643
+ (typeof attempt.originalSha === 'string' && attempt.originalSha.length > 0);
1644
+
1645
+ return {
1646
+ classification: 'dead-worker',
1647
+ action: 'preserve-and-attribute-new-attempt-within-retry-policy',
1648
+ hasPartialEvidence: hasPartialEvidence,
1649
+ };
1650
+ }
1651
+
1652
+ // "Failed verification/review" (Tech-Spec section 9, US-06-TASK-BE-02): full
1653
+ // retry-policy attempt-count enforcement. "One initial implementation + one
1654
+ // automatic rework (two total per task). Review rejections and
1655
+ // implementation failures consume attempts when new implementation is
1656
+ // invoked. Resuming verification, ledger projection or an existing commit
1657
+ // does not. A task beyond policy is blocked: replan-required."
1658
+ //
1659
+ // task.attempts.length is exactly the count of real implementation
1660
+ // invocations recorded for this task: store.js's addAttempt is only ever
1661
+ // called from dispatchTaskAttempt (US-03-TASK-BE-03) — nothing in this
1662
+ // codebase calls it merely to resume verification, project the ledger, or
1663
+ // re-inspect an existing commit. So this count already matches the
1664
+ // Tech-Spec's own "resuming ... does not [consume attempts]" rule with no
1665
+ // extra filtering needed: a task whose attempts.length is already 2 has used
1666
+ // both the initial implementation and its one automatic rework, so a third
1667
+ // attempt is beyond policy.
1668
+ function _classifyFailedAttempt(task) {
1669
+ if (task.attempts.length >= 2) {
1670
+ return { classification: 'blocked', action: 'replan-required' };
1671
+ }
1672
+ return { classification: 'failed', action: 'needs-new-attempt-subject-to-retry-policy' };
1673
+ }
1674
+
1675
+ // Single per-task classification dispatch. Never touches a task that has
1676
+ // never been dispatched (status 'pending' with zero attempts) — reconcile
1677
+ // "does not dispatch" (Tech-Spec section 9).
1678
+ function _classifyTask(ctx, taskId, task) {
1679
+ if (task.status === 'skipped') {
1680
+ return { classification: 'skipped', action: 'none' };
1681
+ }
1682
+
1683
+ if (task.status === 'checkpointed' || task.status === 'integrated') {
1684
+ const attempt = task.attempts[task.attempts.length - 1];
1685
+ if (!attempt) {
1686
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1687
+ }
1688
+ return _classifyCompletedTask(ctx, taskId, attempt);
1689
+ }
1690
+
1691
+ if (task.status === 'blocked') {
1692
+ return { classification: 'blocked', action: 'already-blocked' };
1693
+ }
1694
+
1695
+ if (!Array.isArray(task.attempts) || task.attempts.length === 0) {
1696
+ return { classification: 'not-started', action: 'none' };
1697
+ }
1698
+
1699
+ const attempt = task.attempts[task.attempts.length - 1];
1700
+ switch (attempt.stage) {
1701
+ case 'committed':
1702
+ return _classifyCommittedAttempt(ctx, taskId, attempt);
1703
+ case 'checkpoint-prepared':
1704
+ return _classifyCheckpointPreparedAttempt(ctx, taskId, attempt);
1705
+ case 'reviewed':
1706
+ // "Review passed, no commit" -> recheck unchanged tree then finish
1707
+ // checkpoint. Recomputing/re-verifying the tree is out of scope here
1708
+ // (it needs the same inputs persistCheckpointIntent/stageTaskFiles
1709
+ // need, which reconcile is not given); this reports the classification
1710
+ // only.
1711
+ return { classification: 'reviewed-no-checkpoint', action: 'needs-checkpoint-completion' };
1712
+ case 'verified':
1713
+ // Real, reachable persisted stage only between the two writes
1714
+ // persistVerificationReviewOutcome makes when verification passes
1715
+ // (store.js) — evidence of a passed verification without a recorded
1716
+ // review outcome yet.
1717
+ return { classification: 'verified-no-review-outcome', action: 'needs-review' };
1718
+ case 'implementation-recorded':
1719
+ // "Implementation result persisted" -> continue verification, not
1720
+ // implementation.
1721
+ return { classification: 'implementation-recorded', action: 'needs-verification' };
1722
+ case 'prepared':
1723
+ case 'dispatching':
1724
+ case 'running':
1725
+ case 'interrupted':
1726
+ return _classifyLiveOrDeadWorkerAttempt(ctx, taskId, task, attempt);
1727
+ case 'failed':
1728
+ return _classifyFailedAttempt(task);
1729
+ default:
1730
+ return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
1731
+ }
1732
+ }
1733
+
1734
+ // ── execute() main loop (US-07-TASK-BE-01) ──────────────────────────────────
1735
+ //
1736
+ // Sequential (maxConcurrency=1) run loop: derives the executor's run identity
1737
+ // from the approved feature's own Work Breakdown, acquires the repo-wide
1738
+ // execution lease for a brand-new run, creates the initial run state, then
1739
+ // drives the ready queue to completion one task at a time. Each ready task's
1740
+ // slot is reserved (task status 'active') through dispatch -> verify ->
1741
+ // review -> checkpoint -> commit, and released only once the task reaches a
1742
+ // terminal status ('checkpointed' or 'blocked') — before the next ready task
1743
+ // is ever considered. N>1 concurrent slots is explicitly out of scope here
1744
+ // (US-08).
1745
+
1746
+ // One initial implementation + one automatic rework, two total per task
1747
+ // (Tech-Spec section 9) — the same retry-policy rule US-06-TASK-BE-02's
1748
+ // _classifyFailedAttempt/_classifyLiveOrDeadWorkerAttempt already enforce via
1749
+ // a literal `>= 2` check; named here so execute()'s own enforcement of the
1750
+ // identical rule reads as the same policy, not a coincidentally-matching
1751
+ // magic number.
1752
+ const MAX_ATTEMPTS_PER_TASK = 2;
1753
+
1754
+ function _isBeyondRetryPolicy(state, taskId) {
1755
+ return state.tasks[taskId].attempts.length >= MAX_ATTEMPTS_PER_TASK;
1756
+ }
1757
+
1758
+ // Resolves the absolute common Git directory for `projectDir` THROUGH Git
1759
+ // (Tech-Spec section 5: "Discover absolute worktree root and common Git
1760
+ // directory through Git, not root/.git string concatenation") — never
1761
+ // hand-built by string-concatenating ".git" onto the project path, which
1762
+ // would be wrong for a linked worktree. `git rev-parse --git-common-dir` may
1763
+ // return a path relative to `projectDir` depending on the installed Git
1764
+ // version, so the result is resolved to absolute via `path.resolve` against
1765
+ // `projectDir` — that resolution step is the only non-Git part of this
1766
+ // function; the directory identity itself always comes from Git.
1767
+ function _resolveCommonGitDir(projectDir) {
1768
+ const res = spawnSync('git', ['rev-parse', '--git-common-dir'], {
1769
+ cwd: projectDir, shell: false, encoding: 'utf8', windowsHide: true,
1770
+ });
1771
+ if (res.error || res.status !== 0) {
1772
+ throw _err(
1773
+ 'GIT_SPAWN_FAILED',
1774
+ 'execute: failed to resolve the common Git directory for "' + projectDir + '": ' +
1775
+ (res.error ? res.error.message : (res.stderr || '').trim())
1776
+ );
1777
+ }
1778
+ return path.resolve(projectDir, res.stdout.trim());
1779
+ }
1780
+
1781
+ function _resolveRefTip(cwd, ref, label) {
1782
+ const res = spawnSync('git', ['rev-parse', '--verify', ref + '^{commit}'], {
1783
+ cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true,
1784
+ });
1785
+ if (res.error || res.status !== 0) {
1786
+ throw _err(
1787
+ 'GIT_REF_RESOLUTION_FAILED',
1788
+ 'execute: failed to resolve ' + label + ' "' + ref + '" in ' + cwd + ': ' +
1789
+ (res.error ? res.error.message : (res.stderr || '').trim())
1790
+ );
1791
+ }
1792
+ return res.stdout.trim();
1793
+ }
1794
+
1795
+ // Derives taskRef as the CURRENT checked-out branch (Tech-Spec's N=1 model:
1796
+ // "task commit is on feature branch") via `git rev-parse --abbrev-ref HEAD` —
1797
+ // used only when the caller (execute()) does not supply an explicit
1798
+ // args.taskRef override. A detached HEAD has no branch to derive a ref from,
1799
+ // so it fails closed rather than guessing.
1800
+ function _currentBranchRef(projectDir) {
1801
+ const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
1802
+ cwd: projectDir, shell: false, encoding: 'utf8', windowsHide: true,
1803
+ });
1804
+ if (res.error || res.status !== 0) {
1805
+ throw _err(
1806
+ 'GIT_REF_RESOLUTION_FAILED',
1807
+ 'execute: failed to resolve the current branch for "' + projectDir + '": ' +
1808
+ (res.error ? res.error.message : (res.stderr || '').trim())
1809
+ );
1810
+ }
1811
+ const name = res.stdout.trim();
1812
+ if (name === 'HEAD') {
1813
+ throw _err(
1814
+ 'EXECUTE_VALIDATION_ERROR',
1815
+ 'execute: "' + projectDir + '" has a detached HEAD — a task/feature ref cannot be derived automatically; ' +
1816
+ 'check out a branch first, or pass args.taskRef explicitly'
1817
+ );
1818
+ }
1819
+ return 'refs/heads/' + name;
1820
+ }
1821
+
1822
+ function _buildTaskCommitMessage(task) {
1823
+ if (task.commit && typeof task.commit.type === 'string' && typeof task.commit.subject === 'string') {
1824
+ const scope = typeof task.commit.scope === 'string' && task.commit.scope.length > 0
1825
+ ? '(' + task.commit.scope + ')'
1826
+ : '';
1827
+ return task.commit.type + scope + ': ' + task.commit.subject;
1828
+ }
1829
+ return 'chore: ' + task.id;
1830
+ }
1831
+
1832
+ // Real, side-effect-free diff for the review agent (Tech-Spec section 8:
1833
+ // "the review must see exactly what will be checkpointed" / "unexpected
1834
+ // tracked changes invalidate the reviewed diff"). Built per enumerated
1835
+ // changedPaths entry (the SAME array git.detectChangedPaths just produced,
1836
+ // and that will later be staged/committed unchanged) rather than a single
1837
+ // blanket `git diff`, specifically so this function never mutates the Git
1838
+ // index (no `git add -N`/intent-to-add trick, which WOULD mutate it) —
1839
+ // stageTaskFiles's own before/after index snapshot (US-05-TASK-BE-02) must
1840
+ // still see the exact pre-staging state this function ran against.
1841
+ // - 'delete'/'modify' entries: plain `git diff -- <path>` (tracked file,
1842
+ // real exit 0 either way).
1843
+ // - 'add' entries: `git diff --no-index -- /dev/null <path>` — the
1844
+ // documented git idiom for diffing an untracked file against "nothing"
1845
+ // without ever touching the index; per git-diff(1), --no-index exits 1
1846
+ // when a difference is found (the expected, common case here), which is
1847
+ // NOT an error and must not be treated as one.
1848
+ function _computeReviewDiff(cwd, changedPaths) {
1849
+ const parts = changedPaths.map(function (entry) {
1850
+ let res;
1851
+ if (entry.changeType === 'add') {
1852
+ res = spawnSync('git', ['diff', '--no-color', '--no-index', '--', '/dev/null', entry.path], {
1853
+ cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true,
1854
+ });
1855
+ if (res.error || (res.status !== 0 && res.status !== 1)) {
1856
+ throw _err(
1857
+ 'REVIEW_DIFF_FAILED',
1858
+ '_computeReviewDiff: "git diff --no-index" for added path "' + entry.path + '" failed: ' +
1859
+ (res.error ? res.error.message : (res.stderr || '').trim())
1860
+ );
1861
+ }
1862
+ } else {
1863
+ res = spawnSync('git', ['diff', '--no-color', '--', entry.path], {
1864
+ cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true,
1865
+ });
1866
+ if (res.error || res.status !== 0) {
1867
+ throw _err(
1868
+ 'REVIEW_DIFF_FAILED',
1869
+ '_computeReviewDiff: "git diff" for path "' + entry.path + '" (changeType "' + entry.changeType +
1870
+ '") failed: ' + (res.error ? res.error.message : (res.stderr || '').trim())
1871
+ );
1872
+ }
1873
+ }
1874
+ return res.stdout;
1875
+ });
1876
+ return parts.join('\n');
1877
+ }
1878
+
1879
+ // Drives ONE ready task through dispatch -> verify -> review -> checkpoint,
1880
+ // retrying (a NEW attempt on the SAME task) within the retry policy on a
1881
+ // failure, and only returning once the task has reached a terminal status
1882
+ // ('checkpointed' or 'blocked') — i.e. once its reserved slot can safely be
1883
+ // released back to the outer ready-queue loop in execute().
1884
+ async function _runTaskToResolution(ctx) {
1885
+ const executionRoot = ctx.executionRoot;
1886
+ const runId = ctx.runId;
1887
+ const projectDir = ctx.projectDir;
1888
+ const taskRef = ctx.taskRef;
1889
+ const featureRef = ctx.featureRef;
1890
+ const planDigest = ctx.planDigest;
1891
+ const task = ctx.task;
1892
+ const taskId = task.id;
1893
+ const agentId = agentRegistry.resolveWorkBreakdownAgentType(task.agentType).canonicalId;
1894
+
1895
+ for (;;) {
1896
+ let state = store.readState(executionRoot, runId);
1897
+ state = state.setTaskStatus(taskId, 'active');
1898
+ state = store.writeState(executionRoot, runId, state);
1899
+
1900
+ const attemptNumber = state.tasks[taskId].attempts.length + 1;
1901
+
1902
+ let dispatchResult;
1903
+ try {
1904
+ dispatchResult = await dispatchTaskAttempt({
1905
+ executionRoot: executionRoot,
1906
+ runId: runId,
1907
+ task: task,
1908
+ attemptNumber: attemptNumber,
1909
+ claudePath: ctx.claudePath,
1910
+ agentId: agentId,
1911
+ projectDir: projectDir,
1912
+ taskTimeoutMs: ctx.taskTimeoutMs,
1913
+ spawnArgs: ctx.implementationSpawnArgs,
1914
+ });
1915
+ } catch (dispatchErr) {
1916
+ state = store.readState(executionRoot, runId);
1917
+ const attemptRecord = state.tasks[taskId].attempts.find(function (a) { return a.number === attemptNumber; });
1918
+ if (!attemptRecord) {
1919
+ // Failed BEFORE persist-before-invoke (e.g. NO_LOCK_HELD,
1920
+ // AGENT_NOT_VERIFIED, DEFINITION_HASH_MISMATCH, a systemic/config
1921
+ // problem, not a per-task transient failure). Never consume a task
1922
+ // attempt for an environment problem outside the task itself —
1923
+ // propagate so execute() itself fails (the lease is still released
1924
+ // via its own try/finally).
1925
+ throw dispatchErr;
1926
+ }
1927
+ // Genuine spawn-level failure (spawnClaudeAgent itself threw) AFTER
1928
+ // the attempt was already durably recorded — this DOES consume the
1929
+ // attempt, exactly like a verification/review failure does.
1930
+ state = state.updateAttempt(taskId, attemptNumber, {
1931
+ stage: 'failed',
1932
+ terminalReason: 'dispatch-error:' + (dispatchErr.code || 'UNKNOWN'),
1933
+ });
1934
+ state = store.writeState(executionRoot, runId, state);
1935
+ if (_isBeyondRetryPolicy(state, taskId)) {
1936
+ state = state.setTaskStatus(taskId, 'blocked');
1937
+ store.writeState(executionRoot, runId, state);
1938
+ return;
1939
+ }
1940
+ if (state.runStatus === 'stopping') {
1941
+ // A stop request was persisted while this attempt was in flight
1942
+ // (Tech-Spec section 7: "immediate: request termination ... await
1943
+ // confirmed terminal state" — stop work on this task now, not after
1944
+ // one more retry attempt). Still within retry policy, so this is NOT
1945
+ // 'blocked' (that status means "retry policy exhausted", which is
1946
+ // not what happened here) — leave the attempt's/task's status as
1947
+ // already recorded ('failed'/'active') and return without dispatching
1948
+ // attempt 2. execute()'s own outer loop observes 'stopping' on its
1949
+ // very next iteration and sets the final runStatus to 'paused'.
1950
+ return;
1951
+ }
1952
+ continue;
1953
+ }
1954
+
1955
+ void dispatchResult; // raw spawn capture only — not otherwise consulted here.
1956
+
1957
+ const verification = await runVerifications({
1958
+ executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
1959
+ cwd: projectDir, commandTimeoutMs: ctx.taskTimeoutMs,
1960
+ });
1961
+
1962
+ let review = null;
1963
+ let changedPaths = null;
1964
+ if (verification.passed) {
1965
+ // Detected once per attempt (Gate-1 binding constraint #2's mandatory
1966
+ // negative test relies on this: a self-reported "success" that wrote
1967
+ // nothing real is caught here/by verification, never trusted alone).
1968
+ changedPaths = git.detectChangedPaths(projectDir);
1969
+ const diff = _computeReviewDiff(projectDir, changedPaths);
1970
+ review = await runReview({
1971
+ executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
1972
+ claudePath: ctx.claudePath, projectDir: projectDir, diff: diff, taskTimeoutMs: ctx.taskTimeoutMs,
1973
+ spawnArgs: ctx.reviewSpawnArgs,
1974
+ });
1975
+ }
1976
+
1977
+ const outcome = store.persistVerificationReviewOutcome(executionRoot, runId, taskId, attemptNumber, verification, review);
1978
+
1979
+ if (outcome.stage !== 'reviewed') {
1980
+ state = store.readState(executionRoot, runId);
1981
+ if (_isBeyondRetryPolicy(state, taskId)) {
1982
+ state = state.setTaskStatus(taskId, 'blocked');
1983
+ store.writeState(executionRoot, runId, state);
1984
+ return;
1985
+ }
1986
+ if (state.runStatus === 'stopping') {
1987
+ // Same stop-awareness as the dispatch-error retry site above: a stop
1988
+ // request was persisted while this attempt's verification/review was
1989
+ // in flight (or just after). Still within retry policy — return
1990
+ // without dispatching another attempt rather than forcing 'blocked',
1991
+ // and let execute()'s outer loop settle the run to 'paused'.
1992
+ return;
1993
+ }
1994
+ continue;
1995
+ }
1996
+
1997
+ // Checkpoint: stage first (US-05-TASK-BE-02) so its OWN write-tree
1998
+ // result becomes the checkpoint intent's expectedTreeSha, THEN persist
1999
+ // the intent (US-05-TASK-BE-01) with that now-known treeSha, THEN commit
2000
+ // (US-05-TASK-BE-03) against the exact intent just persisted, THEN
2001
+ // register the SHA (US-05-TASK-BE-04, store.js) sequentially — this run
2002
+ // is always N=1 — THEN finalize (US-05-TASK-BE-05).
2003
+ const stagingResult = git.stageTaskFiles(projectDir, changedPaths);
2004
+ const parentSha = _resolveRefTip(projectDir, taskRef, 'taskRef');
2005
+
2006
+ const verificationReceiptIds = verification.results.map(function (_result, index) {
2007
+ return taskId + '-attempt' + attemptNumber + '-verification-' + index;
2008
+ });
2009
+
2010
+ state = store.readState(executionRoot, runId);
2011
+ const persisted = git.persistCheckpointIntent(executionRoot, runId, {
2012
+ featureId: state.featureId,
2013
+ runId: runId,
2014
+ taskId: taskId,
2015
+ attemptNumber: attemptNumber,
2016
+ planDigest: planDigest,
2017
+ featureRef: featureRef,
2018
+ taskRef: taskRef,
2019
+ parentSha: parentSha,
2020
+ expectedTreeSha: stagingResult.treeSha,
2021
+ changedPaths: changedPaths,
2022
+ verificationReceiptIds: verificationReceiptIds,
2023
+ reviewReceiptId: outcome.reviewReceiptId,
2024
+ outcomeReceiptId: outcome.outcomeReceiptId,
2025
+ commitMessage: _buildTaskCommitMessage(task),
2026
+ });
2027
+
2028
+ const commitResult = git.createTaskCommit(projectDir, persisted.intent, stagingResult);
2029
+
2030
+ store.registerCommitSHA(executionRoot, runId, taskId, attemptNumber, commitResult.commitSha, { sequential: true });
2031
+
2032
+ finalizeTaskCheckpoint({
2033
+ executionRoot: executionRoot, runId: runId, taskId: taskId, attemptNumber: attemptNumber,
2034
+ projectDir: projectDir, taskRef: taskRef,
2035
+ });
2036
+
2037
+ return;
2038
+ }
2039
+ }
2040
+
2041
+ // ── createTaskWorktree (US-08-TASK-BE-01) ───────────────────────────────────
2042
+ //
2043
+ // Creates and manages one isolated Git worktree for one task attempt — the
2044
+ // standalone primitive later US-08 tasks (N-limit concurrency/slot
2045
+ // reservation, US-08-TASK-BE-02; serial cherry-pick integration,
2046
+ // US-08-TASK-BE-03) will build on. This function does NOT dispatch any
2047
+ // agent and does NOT stage/commit anything — it only creates/manages the
2048
+ // worktree directory itself. It is NOT wired into execute()'s existing
2049
+ // sequential main loop above, which stays N=1/single-worktree-free per
2050
+ // US-07-TASK-BE-01's explicit scope; execute() never calls this function.
2051
+ //
2052
+ // Path convention: <executionRoot>/runs/<runId>/worktrees/<taskId>-attempt
2053
+ // <attemptNumber> — reuses store.js's own run-owned root layout
2054
+ // (store._executionPaths: runs/<runId>/{state.json,receipts/,intents/}, see
2055
+ // that module's file-header comment) rather than inventing a new root.
2056
+ // Tech-Spec section 8 requires only "an external worktree path under a
2057
+ // validated run-owned root; no reused slot names" for N>1 attempts — it does
2058
+ // not mandate a more precise shape — so extending store.js's existing runDir
2059
+ // convention is the natural, non-duplicating choice. taskId+attemptNumber
2060
+ // together are already the unique identity of an attempt throughout this
2061
+ // module (see _buildProcessTag, addAttempt's own `id: taskId + '#' +
2062
+ // number`), so this path can never collide across tasks or attempts within
2063
+ // one run.
2064
+ //
2065
+ // Branch convention: Tech-Spec section 8 DOES mandate an exact branch name
2066
+ // for N>1 attempts — "each attempt has unique branch
2067
+ // ai-toolkit/<runId>/<taskId>/<attempt>" — so createTaskWorktree creates
2068
+ // exactly that branch (`git worktree add -b <branch> <path> <baseSha>`),
2069
+ // never a detached HEAD, so later cherry-pick/integration (US-08-TASK-BE-03)
2070
+ // has a real ref to read the attempt's commit(s) from.
2071
+ const WORKTREE_BRANCH_PREFIX = 'ai-toolkit';
2072
+
2073
+ function _worktreeFail(field, reason) {
2074
+ throw _err('WORKTREE_VALIDATION_ERROR', 'createTaskWorktree: ' + field + ' ' + reason);
2075
+ }
2076
+
2077
+ // Local string validator (rather than the shared _requireNonEmptyString,
2078
+ // which is hardcoded to DISPATCH_VALIDATION_ERROR for the dispatch-family
2079
+ // functions above) so every malformed-input error this function throws
2080
+ // carries the same, single WORKTREE_VALIDATION_ERROR code.
2081
+ function _requireWorktreeString(value, field) {
2082
+ if (typeof value !== 'string' || value.length === 0) {
2083
+ _worktreeFail(field, 'must be a non-empty string');
2084
+ }
2085
+ }
2086
+
2087
+ // Shared spawn helper for every git invocation in this section — same house
2088
+ // style as every other git.js/index.js spawn site: shell:false (argv array,
2089
+ // no shell interpolation/globbing), encoding utf8, windowsHide.
2090
+ function _runGitForWorktree(cwd, args) {
2091
+ return spawnSync('git', args, { cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true });
2092
+ }
2093
+
2094
+ // Parses `git worktree list --porcelain` into the absolute, resolved list of
2095
+ // every worktree path Git currently knows about for this repo (the main
2096
+ // worktree first, then every linked one) — the ground truth this function
2097
+ // cross-checks a newly requested path against, rather than trusting its own
2098
+ // computed path never to collide.
2099
+ function _listWorktreePaths(projectDir) {
2100
+ const res = _runGitForWorktree(projectDir, ['worktree', 'list', '--porcelain']);
2101
+ if (res.error || res.status !== 0) {
2102
+ throw _err(
2103
+ 'GIT_WORKTREE_LIST_FAILED',
2104
+ 'createTaskWorktree: failed to list existing worktrees for "' + projectDir + '": ' +
2105
+ (res.error ? res.error.message : (res.stderr || '').trim())
2106
+ );
2107
+ }
2108
+ const paths = [];
2109
+ (res.stdout || '').split(/\r?\n/).forEach(function (line) {
2110
+ if (line.indexOf('worktree ') === 0) {
2111
+ paths.push(path.resolve(line.slice('worktree '.length).trim()));
2112
+ }
2113
+ });
2114
+ return paths;
2115
+ }
2116
+
2117
+ // Canonicalizes `p` for COMPARISON purposes only (never for the path
2118
+ // actually handed to git argv or returned to callers — those stay the
2119
+ // plain, deterministic path.resolve() form). This exists because on
2120
+ // Windows, a path built from os.tmpdir()/fs.mkdtempSync can be an 8.3
2121
+ // short-name alias (e.g. "...\TOMADA~1\...") while Git reports the SAME
2122
+ // directory using its long form (e.g. "...\Tomada D\...") in
2123
+ // `git worktree list --porcelain` — proven by direct experiment, not
2124
+ // assumed: plain fs.realpathSync does NOT resolve this alias on this
2125
+ // platform, but fs.realpathSync.native (the libuv-backed, OS-level call)
2126
+ // does. Without canonicalizing both sides before comparison, every
2127
+ // collision/independence check below would silently never match, and every
2128
+ // "same path" check would silently never fire.
2129
+ // For a path that does not exist yet (the target worktree path, before
2130
+ // creation), realpath.native has nothing to resolve — so this walks up to
2131
+ // the deepest EXISTING ancestor, canonicalizes that, and re-appends the
2132
+ // not-yet-existing suffix unchanged.
2133
+ function _canonicalizePathForCompare(p) {
2134
+ const resolved = path.resolve(p);
2135
+ let existing = resolved;
2136
+ const suffix = [];
2137
+ for (;;) {
2138
+ try {
2139
+ const real = fs.realpathSync.native(existing);
2140
+ return suffix.length > 0 ? path.join.apply(path, [real].concat(suffix)) : real;
2141
+ } catch (_) {
2142
+ const parent = path.dirname(existing);
2143
+ if (parent === existing) {
2144
+ return resolved; // reached the filesystem root; nothing existed to canonicalize against
2145
+ }
2146
+ suffix.unshift(path.basename(existing));
2147
+ existing = parent;
2148
+ }
2149
+ }
2150
+ }
2151
+
2152
+ function _samePath(a, b) {
2153
+ return _canonicalizePathForCompare(a) === _canonicalizePathForCompare(b);
2154
+ }
2155
+
2156
+ // True when `b` is exactly `a`, or is nested one or more levels inside `a` —
2157
+ // used both directions (new path inside an existing worktree, or an
2158
+ // existing worktree inside the new path) so createTaskWorktree fails closed
2159
+ // on ANY overlap rather than only the more obvious direction. Compares
2160
+ // canonicalized forms (see _canonicalizePathForCompare) so Windows
2161
+ // short/long path aliasing can never hide a real collision or manufacture a
2162
+ // false one.
2163
+ function _pathsOverlap(a, b) {
2164
+ const realA = _canonicalizePathForCompare(a);
2165
+ const realB = _canonicalizePathForCompare(b);
2166
+ if (realA === realB) return true;
2167
+ const rel = path.relative(realA, realB);
2168
+ return rel !== '' && rel !== '.' && !rel.startsWith('..') && !path.isAbsolute(rel);
2169
+ }
2170
+
2171
+ /**
2172
+ * Creates one isolated Git worktree for one task attempt, under a run-owned
2173
+ * root, and returns its path plus a cleanup callback. See the file comment
2174
+ * immediately above for the path/branch conventions this follows.
2175
+ *
2176
+ * Independence is validated BOTH before and after creation:
2177
+ * - Before: the computed target path is cross-checked against every path
2178
+ * `git worktree list --porcelain` reports (main worktree included) for
2179
+ * exact collision and for nesting in either direction. Any overlap fails
2180
+ * closed with WORKTREE_COLLISION before `git worktree add` ever runs.
2181
+ * - After: the path Git actually registers must include the exact
2182
+ * requested path (WORKTREE_INDEPENDENCE_CHECK_FAILED otherwise), and its
2183
+ * real (symlink-resolved) directory must differ from the main
2184
+ * worktree's real directory — guards against a path that Git or the
2185
+ * filesystem silently aliased back onto the main checkout.
2186
+ *
2187
+ * @param {object} args
2188
+ * @param {string} args.projectDir - Absolute path to the main Git worktree
2189
+ * (the project root `git worktree add` is run from).
2190
+ * @param {string} args.executionRoot - Absolute path to
2191
+ * <common-git-dir>/ai-toolkit/execution (same root store.js's
2192
+ * _executionPaths resolves runs/<runId>/... under).
2193
+ * @param {string} args.runId - UUID of the current run.
2194
+ * @param {string} args.taskId - Task identifier this worktree is for.
2195
+ * @param {number} args.attemptNumber - Positive integer attempt number this
2196
+ * worktree is for.
2197
+ * @param {string} args.baseSha - Commit-ish (full SHA, or any ref Git can
2198
+ * resolve) the new attempt branch is created from.
2199
+ * @returns {{ worktreePath: string, branch: string, cleanup: Function }}
2200
+ * `cleanup` is synchronous, idempotent, and safe to call even if the
2201
+ * worktree directory was already removed by hand — see its own inline
2202
+ * comment below for exactly what "safe" means here.
2203
+ * @throws {Error} WORKTREE_VALIDATION_ERROR for malformed input;
2204
+ * GIT_WORKTREE_LIST_FAILED if `git worktree list` cannot be run;
2205
+ * WORKTREE_COLLISION if the computed path collides with or is nested
2206
+ * inside/around an existing worktree, or already exists on disk;
2207
+ * GIT_WORKTREE_ADD_FAILED if `git worktree add` itself fails;
2208
+ * WORKTREE_INDEPENDENCE_CHECK_FAILED if post-creation verification does
2209
+ * not confirm a genuinely separate, registered worktree.
2210
+ */
2211
+ function createTaskWorktree(args) {
2212
+ args = args || {};
2213
+ _requireWorktreeString(args.projectDir, 'args.projectDir');
2214
+ _requireWorktreeString(args.executionRoot, 'args.executionRoot');
2215
+ _requireWorktreeString(args.runId, 'args.runId');
2216
+ _requireWorktreeString(args.taskId, 'args.taskId');
2217
+ _requireWorktreeString(args.baseSha, 'args.baseSha');
2218
+ if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
2219
+ _worktreeFail('args.attemptNumber', 'must be a positive integer');
2220
+ }
2221
+
2222
+ const projectDir = path.resolve(args.projectDir);
2223
+ const executionRoot = args.executionRoot;
2224
+ const runId = args.runId;
2225
+ const taskId = args.taskId;
2226
+ const attemptNumber = args.attemptNumber;
2227
+ const baseSha = args.baseSha;
2228
+
2229
+ const runDir = store._executionPaths(executionRoot, runId).runDir;
2230
+ const worktreesRoot = path.join(runDir, 'worktrees');
2231
+ const worktreePath = path.resolve(path.join(worktreesRoot, taskId + '-attempt' + attemptNumber));
2232
+ const branch = WORKTREE_BRANCH_PREFIX + '/' + runId + '/' + taskId + '/' + attemptNumber;
2233
+
2234
+ if (fs.existsSync(worktreePath)) {
2235
+ throw _err(
2236
+ 'WORKTREE_COLLISION',
2237
+ 'createTaskWorktree: target path "' + worktreePath + '" already exists on disk — refusing to overwrite it'
2238
+ );
2239
+ }
2240
+
2241
+ // The run-owned root (executionRoot, hence worktreePath) lives under the
2242
+ // repo's common Git directory (Tech-Spec section 5 / store.js's own
2243
+ // convention: <common-git-dir>/ai-toolkit/execution) — administrative Git
2244
+ // storage that is, BY GIT'S OWN DESIGN, excluded from every worktree's
2245
+ // tracked file tree (no `git status`/diff in ANY worktree ever reports
2246
+ // into another worktree's .git internals). A worktree path that lands
2247
+ // under commonDir is therefore never actually "inside" any worktree's
2248
+ // checked-out content, even though it is filesystem-nested under the main
2249
+ // worktree's own directory for a standard (non `--separate-git-dir`)
2250
+ // repo — so nesting under commonDir specifically is exempted from the
2251
+ // collision check below. Anything else — a path nested inside a
2252
+ // worktree's real tracked directory, or identical to one — still fails
2253
+ // closed.
2254
+ const commonDir = path.resolve(_resolveCommonGitDir(projectDir));
2255
+ const worktreePathIsUnderCommonDir = _samePath(commonDir, worktreePath) || _pathsOverlap(commonDir, worktreePath);
2256
+
2257
+ // Pre-creation independence check against every worktree Git already
2258
+ // knows about, main included — fail closed on any real overlap rather
2259
+ // than letting `git worktree add` decide (it would happily create a
2260
+ // worktree nested inside another one's tracked directory).
2261
+ const knownPaths = _listWorktreePaths(projectDir);
2262
+ if (!knownPaths.some(function (p) { return _samePath(p, projectDir); })) {
2263
+ knownPaths.push(projectDir);
2264
+ }
2265
+ knownPaths.forEach(function (knownPath) {
2266
+ if (_samePath(knownPath, worktreePath)) {
2267
+ throw _err(
2268
+ 'WORKTREE_COLLISION',
2269
+ 'createTaskWorktree: requested worktree path "' + worktreePath + '" is identical to the existing ' +
2270
+ 'worktree "' + knownPath + '" — refusing to create a non-independent worktree'
2271
+ );
2272
+ }
2273
+ if (_pathsOverlap(worktreePath, knownPath)) {
2274
+ // The new path would be an ANCESTOR of an existing worktree — always a
2275
+ // real collision (it would swallow that worktree's tracked directory),
2276
+ // regardless of commonDir.
2277
+ throw _err(
2278
+ 'WORKTREE_COLLISION',
2279
+ 'createTaskWorktree: requested worktree path "' + worktreePath + '" would contain the existing ' +
2280
+ 'worktree "' + knownPath + '" — refusing to create a non-independent worktree'
2281
+ );
2282
+ }
2283
+ if (_pathsOverlap(knownPath, worktreePath)) {
2284
+ // The new path is nested inside knownPath's directory. Exempt only
2285
+ // when that nesting is entirely explained by commonDir sitting inside
2286
+ // knownPath (i.e., the overlap is administrative Git storage, not
2287
+ // knownPath's own tracked content).
2288
+ const exemptViaCommonDir = worktreePathIsUnderCommonDir &&
2289
+ (_samePath(knownPath, commonDir) || _pathsOverlap(knownPath, commonDir));
2290
+ if (!exemptViaCommonDir) {
2291
+ throw _err(
2292
+ 'WORKTREE_COLLISION',
2293
+ 'createTaskWorktree: requested worktree path "' + worktreePath + '" is nested inside the existing ' +
2294
+ 'worktree "' + knownPath + '" — refusing to create a non-independent worktree'
2295
+ );
2296
+ }
2297
+ }
2298
+ });
2299
+
2300
+ fs.mkdirSync(worktreesRoot, { recursive: true });
2301
+
2302
+ const addRes = _runGitForWorktree(projectDir, ['worktree', 'add', '-b', branch, worktreePath, baseSha]);
2303
+ if (addRes.error || addRes.status !== 0) {
2304
+ throw _err(
2305
+ 'GIT_WORKTREE_ADD_FAILED',
2306
+ 'createTaskWorktree: "git worktree add -b ' + branch + ' ' + worktreePath + ' ' + baseSha + '" failed: ' +
2307
+ (addRes.error ? addRes.error.message : (addRes.stderr || '').trim())
2308
+ );
2309
+ }
2310
+
2311
+ // Post-creation independence verification: the path Git actually
2312
+ // registered must be exactly the one requested (not silently normalized
2313
+ // or aliased to something else), and its real, symlink-resolved directory
2314
+ // must differ from the main worktree's.
2315
+ const postPaths = _listWorktreePaths(projectDir);
2316
+ if (!postPaths.some(function (p) { return _samePath(p, worktreePath); })) {
2317
+ throw _err(
2318
+ 'WORKTREE_INDEPENDENCE_CHECK_FAILED',
2319
+ 'createTaskWorktree: "git worktree add" reported success but "' + worktreePath + '" is not listed by ' +
2320
+ '"git worktree list --porcelain" afterward — refusing to trust an unconfirmed worktree'
2321
+ );
2322
+ }
2323
+ let realWorktreePath;
2324
+ let realProjectDir;
2325
+ try {
2326
+ realWorktreePath = fs.realpathSync.native(worktreePath);
2327
+ realProjectDir = fs.realpathSync.native(projectDir);
2328
+ } catch (err) {
2329
+ throw _err(
2330
+ 'WORKTREE_INDEPENDENCE_CHECK_FAILED',
2331
+ 'createTaskWorktree: failed to resolve real paths for independence verification: ' + err.message
2332
+ );
2333
+ }
2334
+ if (realWorktreePath === realProjectDir) {
2335
+ throw _err(
2336
+ 'WORKTREE_INDEPENDENCE_CHECK_FAILED',
2337
+ 'createTaskWorktree: the created worktree resolves to the SAME real directory as the main worktree "' +
2338
+ projectDir + '" — refusing an alias rather than a genuinely independent checkout'
2339
+ );
2340
+ }
2341
+
2342
+ let cleaned = false;
2343
+
2344
+ // Removes the worktree directory (`git worktree remove --force`, never the
2345
+ // plain/"safe" variant — deliberate: by the time this primitive's caller
2346
+ // is done with an attempt's worktree, the attempt's real output is already
2347
+ // checkpointed as a commit on `branch` (Tech-Spec section 8: "commit
2348
+ // remains on technical ref until integration"), so any leftover
2349
+ // uncommitted/untracked worktree state is disposable, never the sole copy
2350
+ // of anything that matters. A plain `git worktree remove` refuses on any
2351
+ // uncommitted change, which would leave a slot permanently stuck for a
2352
+ // reason this primitive does not need to honor here).
2353
+ //
2354
+ // Safe to call more than once (no-op after the first successful removal),
2355
+ // and safe to call after the worktree directory was already removed by
2356
+ // hand: this is confirmed by RE-QUERYING `git worktree list --porcelain`
2357
+ // after a failed/no-op remove attempt — only a remove that fails AND still
2358
+ // leaves the path registered is treated as a real failure (e.g. genuinely
2359
+ // locked, or blocked by something other than "it's already gone").
2360
+ function cleanup() {
2361
+ if (cleaned) return;
2362
+
2363
+ const removeRes = _runGitForWorktree(projectDir, ['worktree', 'remove', '--force', worktreePath]);
2364
+ if (!removeRes.error && removeRes.status === 0) {
2365
+ cleaned = true;
2366
+ return;
2367
+ }
2368
+
2369
+ const stillRegistered = _listWorktreePaths(projectDir).some(function (p) { return _samePath(p, worktreePath); });
2370
+ if (!stillRegistered) {
2371
+ cleaned = true; // already gone (manually removed, or already cleaned up) — safe no-op
2372
+ return;
2373
+ }
2374
+
2375
+ throw _err(
2376
+ 'GIT_WORKTREE_REMOVE_FAILED',
2377
+ 'createTaskWorktree.cleanup: "git worktree remove --force ' + worktreePath + '" failed and the worktree ' +
2378
+ 'is still registered: ' + (removeRes.error ? removeRes.error.message : (removeRes.stderr || '').trim())
2379
+ );
2380
+ }
2381
+
2382
+ return { worktreePath: worktreePath, branch: branch, cleanup: cleanup };
2383
+ }
2384
+
2385
+ // ── createSlotPool (US-08-TASK-BE-02) ───────────────────────────────────────
2386
+ //
2387
+ // N-limit concurrency control / slot reservation — the second standalone
2388
+ // US-08 primitive, alongside createTaskWorktree (US-08-TASK-BE-01) above.
2389
+ // Like createTaskWorktree, this is NOT wired into execute()'s existing
2390
+ // sequential main loop (US-07-TASK-BE-01, which stays N=1/unchanged);
2391
+ // execute() never calls this function. Real N>1 dispatch integration is
2392
+ // US-08-TASK-BE-03 (serial integration) and US-08-TASK-BE-04 (SHA tracking) —
2393
+ // both later, separate tasks.
2394
+ //
2395
+ // Tech-Spec's binding rule for N (section 7): "N counts reserved task slots
2396
+ // through verification/review/commit/integration; a full slot is released
2397
+ // only when consolidated or safely stopped. This bounds unintegrated work as
2398
+ // well."
2399
+ //
2400
+ // SCOPE BOUNDARY — read before wiring this into any future orchestrator:
2401
+ // this primitive tracks ONLY the count/identity of currently held slot
2402
+ // tokens against a fixed capacity N. It has no idea what a "task", an
2403
+ // "attempt", "verification", "review", "checkpoint", or "integration" is —
2404
+ // those concepts do not appear anywhere in this section. The Tech-Spec rule
2405
+ // above is an OPERATIONAL DISCIPLINE the CALLER must follow, not something
2406
+ // this primitive enforces or even observes: a future execute()-level
2407
+ // orchestrator is responsible for calling tryAcquire() at dispatch time and
2408
+ // holding the returned token through the ENTIRE dispatch → verify → review →
2409
+ // checkpoint → commit → integration lifecycle for that task attempt, calling
2410
+ // release() only once that whole lifecycle reaches a terminal outcome
2411
+ // (successful integration, or a safe-stop/terminal-failure path that gives
2412
+ // up on the attempt for good). If a future caller released the slot early
2413
+ // (e.g. right after dispatch, before verification/review/integration
2414
+ // complete), this primitive would have no way to know that was wrong — it
2415
+ // would simply see one more free unit of capacity and hand it out to a new
2416
+ // acquire. Enforcing "released only after integration verified" as a system
2417
+ // property requires the future orchestrator to hold the token op-for-op
2418
+ // across that whole lifecycle; this primitive only supplies the fail-closed
2419
+ // counting/identity mechanism that discipline is built on top of.
2420
+ //
2421
+ // maxConcurrency validation deliberately mirrors execute()'s own
2422
+ // "args.maxConcurrency must be a positive safe integer" check (Number.isInteger
2423
+ // + Number.isSafeInteger + >= 1) for consistency of error shape across the
2424
+ // module — but NOT execute()'s separate "only 1 is supported, reject any
2425
+ // other value, never downgrade" policy: that reject/never-downgrade decision
2426
+ // is about what execute() itself currently implements, not a constraint
2427
+ // createSlotPool needs to know about or duplicate. This primitive accepts
2428
+ // any valid positive-safe-integer N — enforcing which N values a future
2429
+ // orchestrator is allowed to request stays entirely execute()'s call.
2430
+ function createSlotPool(maxConcurrency) {
2431
+ if (!Number.isInteger(maxConcurrency) || !Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1) {
2432
+ throw _err('SLOT_POOL_VALIDATION_ERROR', 'createSlotPool: maxConcurrency must be a positive safe integer');
2433
+ }
2434
+
2435
+ // Held-slot identity set, keyed by an opaque token string this pool itself
2436
+ // mints and hands back from tryAcquire(). Using an explicit per-slot token
2437
+ // (rather than a bare counter) is what makes double-release and
2438
+ // bogus-release detectable: release() can check "is this EXACT token
2439
+ // currently held" instead of merely "is the counter above zero" — a caller
2440
+ // that fabricates a token, reuses one already released, or passes a token
2441
+ // from a different pool entirely is rejected the same way a genuine
2442
+ // double-release is (SLOT_NOT_HELD), never silently accepted.
2443
+ const heldTokens = new Set();
2444
+ let nextTokenSeq = 1;
2445
+
2446
+ // Fails closed: returns null (never throws, never blocks) the instant all
2447
+ // maxConcurrency slots are already held — per this task's own documented
2448
+ // choice of "returns null" over "throws SLOT_POOL_EXHAUSTED", since a
2449
+ // future caller driving a ready-queue loop naturally wants a plain falsy
2450
+ // "no capacity right now, try another task or wait" signal here rather
2451
+ // than an exception for what is an entirely expected, non-error condition
2452
+ // (a full pool is the normal steady state of a busy run, not a fault).
2453
+ function tryAcquire() {
2454
+ if (heldTokens.size >= maxConcurrency) {
2455
+ return null;
2456
+ }
2457
+ const token = 'slot-' + (nextTokenSeq++);
2458
+ heldTokens.add(token);
2459
+ return token;
2460
+ }
2461
+
2462
+ // Releases exactly one previously acquired, still-held token. Guards
2463
+ // against double-release and bogus/unknown tokens by construction (Set
2464
+ // membership check) rather than trusting the caller: an already-released
2465
+ // token, a fabricated string, or a token from a different createSlotPool()
2466
+ // instance all fail the same way (SLOT_NOT_HELD) instead of silently
2467
+ // decrementing a bare counter below zero or accepting a release that was
2468
+ // never actually a held slot.
2469
+ function release(token) {
2470
+ if (typeof token !== 'string' || !heldTokens.has(token)) {
2471
+ throw _err(
2472
+ 'SLOT_NOT_HELD',
2473
+ 'createSlotPool.release: "' + token + '" is not a currently held slot in this pool ' +
2474
+ '(never acquired, already released, or acquired from a different pool)'
2475
+ );
2476
+ }
2477
+ heldTokens.delete(token);
2478
+ }
2479
+
2480
+ // Read-only inspection for tests/diagnostics — neither call ever mutates
2481
+ // pool state; both simply report the CURRENT snapshot at call time (there
2482
+ // is no cached/stale value to go wrong, since heldTokens.size is read
2483
+ // fresh on every call).
2484
+ function activeCount() {
2485
+ return heldTokens.size;
2486
+ }
2487
+
2488
+ function remainingCapacity() {
2489
+ return maxConcurrency - heldTokens.size;
2490
+ }
2491
+
2492
+ return {
2493
+ tryAcquire: tryAcquire,
2494
+ release: release,
2495
+ activeCount: activeCount,
2496
+ remainingCapacity: remainingCapacity,
2497
+ maxConcurrency: maxConcurrency,
2498
+ };
2499
+ }
2500
+
2501
+ /**
2502
+ * `start` command real implementation (US-07-TASK-BE-01): sequential
2503
+ * (maxConcurrency=1) main loop with ready-queue dispatch. Validates
2504
+ * args/config, acquires the repo-wide execution lease for a brand-new run,
2505
+ * creates an immutable initial state from the feature's OWN approved plan
2506
+ * (this feature's own Work-Breakdown.md/.csv, read from disk via the same
2507
+ * _deriveFeatureDir/_derivePrefix convention replan() already uses), and
2508
+ * drives dispatch/verification/review/checkpoint to completion or block.
2509
+ *
2510
+ * Only maxConcurrency=1 is implemented here — any other requested value is
2511
+ * REJECTED (UNSUPPORTED_CONCURRENCY), never silently downgraded (Tech-Spec
2512
+ * section 10: "Requested N=effective N; if host cannot support it, reject,
2513
+ * never downgrade"). N>1 concurrent slots is US-08 scope.
2514
+ *
2515
+ * Main-loop design (see file-level "execute() main loop" comment above and
2516
+ * _runTaskToResolution for the per-task detail):
2517
+ * 1. Compute the ready queue (plan.computeReadyQueue) once, up front, over
2518
+ * the immutable plan snapshot — this is the full stable dispatch order
2519
+ * the run will use whenever nothing is actually blocked (see
2520
+ * computeReadyQueue's own doc comment for why one static pass suffices).
2521
+ * 2. Repeatedly: re-read state, find the FIRST task (in that stable order)
2522
+ * that is still 'pending' AND whose dependencies are all
2523
+ * 'checkpointed'/'integrated'. Dispatch it via _runTaskToResolution,
2524
+ * which reserves its slot (status 'active') through dispatch, verify,
2525
+ * review, checkpoint and commit — retrying a NEW attempt on the SAME
2526
+ * task within the retry policy on failure, or marking it 'blocked' once
2527
+ * the policy is exhausted — before this loop ever considers another
2528
+ * task. This is the literal "one task slot" invariant (AC-06).
2529
+ * 3. Stop (never spin) once no task is both non-terminal and ready — e.g.
2530
+ * every remaining task is pending on a dependency that is itself
2531
+ * blocked. The final run status reflects this (see below).
2532
+ *
2533
+ * Final runStatus: 'completed' if every task reached
2534
+ * checkpointed/integrated/skipped; 'blocked' otherwise (at least one task is
2535
+ * blocked, or pending on a dependency that never resolved).
2536
+ *
2537
+ * The repo-wide execution lease is released in a try/finally, so it is
2538
+ * released even if the loop exits via an uncaught error (e.g. a
2539
+ * DEFINITION_HASH_MISMATCH / NO_LOCK_HELD systemic failure propagated by
2540
+ * _runTaskToResolution) — this run never leaves the lease dangling on the
2541
+ * failure path it itself created.
2542
+ *
2543
+ * @param {object} args
2544
+ * @param {string} args.project - Absolute or project-relative path used to
2545
+ * resolve the repo root and common Git directory (also used as `cwd` for
2546
+ * every dispatch/verification/git operation).
2547
+ * @param {string} args.feature - Path to the feature's feature.md, used to
2548
+ * derive PREFIX and locate this feature's OWN Work-Breakdown.md/.csv.
2549
+ * @param {number} [args.maxConcurrency=1] - Requested task slot count N.
2550
+ * Must be a positive safe integer; only 1 is supported by this task — any
2551
+ * other value is rejected with UNSUPPORTED_CONCURRENCY, never downgraded.
2552
+ * @param {string} [args.claudePath] - Explicit path to the qualified Claude
2553
+ * CLI executable, forwarded to every dispatch/review invocation.
2554
+ * @param {number} [args.taskTimeoutMs] - Per-task timeout budget, forwarded
2555
+ * to dispatch/verification/review.
2556
+ * @param {number} [args.agentBudgetUsd] - Per-run agent spend budget,
2557
+ * persisted into state.config unchanged (not itself enforced here).
2558
+ * @param {string} [args.taskRef] - Explicit override for the feature/task ref
2559
+ * commits land on. When omitted, derived from the CURRENT checked-out
2560
+ * branch of args.project via `git rev-parse --abbrev-ref HEAD`.
2561
+ * @param {string[]} [args.implementationSpawnArgs] - Test-only override of
2562
+ * the full CLI args array forwarded to every dispatchTaskAttempt call (see
2563
+ * dispatchTaskAttempt's own args.spawnArgs) — tests use this to substitute
2564
+ * tests/fixtures/fake-claude-cli.js's own invocation shape while still
2565
+ * exercising a real subprocess spawn end-to-end. Omitted in production.
2566
+ * @param {string[]} [args.reviewSpawnArgs] - Test-only override of the full
2567
+ * CLI args array forwarded to every runReview call (see runReview's own
2568
+ * args.spawnArgs). Omitted in production.
2569
+ * @returns {Promise<object>} Versioned result object:
2570
+ * { protocolVersion, runId, runStatus, tasks: [{ taskId, status }] }.
2571
+ * @throws {Error} DISPATCH_VALIDATION_ERROR for malformed maxConcurrency;
2572
+ * UNSUPPORTED_CONCURRENCY for any requested maxConcurrency other than 1;
2573
+ * EXECUTE_VALIDATION_ERROR when a PREFIX cannot be derived or HEAD is
2574
+ * detached with no explicit args.taskRef; PLAN_NOT_FOUND when this
2575
+ * feature's own Work-Breakdown.md/.csv cannot be read; PLAN_PARSE_ERROR
2576
+ * propagated unmodified from plan.createPlanSnapshot; LEASE_HELD when a
2577
+ * repo-wide execution lease is already held; any error propagated
2578
+ * unmodified from dispatchTaskAttempt/runVerifications/runReview/git.js/
2579
+ * store.js for a systemic (non-task-attempt) failure.
2580
+ */
2581
+ async function execute(args) {
2582
+ // Platform qualification guard (US-08-TASK-BE-05) — the single most
2583
+ // fundamental precondition of this whole function, checked before ANY
2584
+ // other validation, before deriving executionRoot, before touching the
2585
+ // plan/snapshot, before acquiring the ownership lease, and before creating
2586
+ // any worktree. Checked dynamically against the live process.platform
2587
+ // (never a module-load-time-cached constant like this file's own
2588
+ // IS_WINDOWS above) so it reflects the real, current platform at call
2589
+ // time. Fail-closed, no partial/"best effort" execution on any other
2590
+ // platform: this feature's own Gate-1 approval (binding constraint #3)
2591
+ // states "E-02 is Windows-only qualified. Other platforms require
2592
+ // dedicated proofs before their execution is enabled; no implicit
2593
+ // cross-platform guarantee." E-02 is the process-supervision evidence
2594
+ // dossier (tree-kill via taskkill, tag-based liveness scanning via
2595
+ // Win32_Process CIM queries) that this executor's crash-recovery/stop/
2596
+ // worker-liveness machinery (ownership.js's checkOwnerLiveness, this
2597
+ // file's own _findLiveTaggedProcess, stop()'s immediate-mode kill) depends
2598
+ // on — none of it is proven correct on non-Windows platforms.
2599
+ if (process.platform !== 'win32') {
2600
+ throw _err(
2601
+ 'PLATFORM_NOT_QUALIFIED',
2602
+ 'execute: platform "' + process.platform + '" is not qualified to run the task executor — the E-02 ' +
2603
+ 'process-supervision qualification (tree-kill via taskkill, tag-based liveness scanning via ' +
2604
+ 'Win32_Process CIM queries) that this executor\'s crash-recovery/stop/worker-liveness machinery ' +
2605
+ 'depends on is proven for Windows only. Other platforms require dedicated proofs before their ' +
2606
+ 'execution is enabled; refusing to proceed (no partial or best-effort execution).'
2607
+ );
2608
+ }
2609
+
2610
+ args = args || {};
2611
+ _requireNonEmptyString(args.project, 'execute: args.project');
2612
+ _requireNonEmptyString(args.feature, 'execute: args.feature');
2613
+
2614
+ const maxConcurrency = args.maxConcurrency === undefined ? 1 : args.maxConcurrency;
2615
+ if (!Number.isInteger(maxConcurrency) || !Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1) {
2616
+ throw _err('DISPATCH_VALIDATION_ERROR', 'execute: args.maxConcurrency must be a positive safe integer');
2617
+ }
2618
+ if (maxConcurrency !== 1) {
2619
+ throw _err(
2620
+ 'UNSUPPORTED_CONCURRENCY',
2621
+ 'execute: requested maxConcurrency ' + maxConcurrency + ' is not supported yet — only maxConcurrency=1 is ' +
2622
+ 'implemented (US-07-TASK-BE-01); N>1 concurrent slots is US-08 scope. Refusing to silently downgrade ' +
2623
+ '(Tech-Spec section 10: "Requested N=effective N; if host cannot support it, reject, never downgrade").'
2624
+ );
2625
+ }
2626
+
2627
+ const projectDir = args.project;
2628
+
2629
+ const featureDir = _deriveFeatureDir(args.feature);
2630
+ const prefix = _derivePrefix(featureDir);
2631
+ if (!prefix) {
2632
+ throw _err(
2633
+ 'EXECUTE_VALIDATION_ERROR',
2634
+ 'execute: could not derive a PREFIX (e.g. "FTR-018") from feature directory "' + featureDir + '"'
2635
+ );
2636
+ }
2637
+ const mdPath = path.join(featureDir, prefix + '-Work-Breakdown.md');
2638
+ const csvPath = path.join(featureDir, prefix + '-Work-Breakdown.csv');
2639
+ let md;
2640
+ let csv;
2641
+ try {
2642
+ md = fs.readFileSync(mdPath, 'utf8');
2643
+ csv = fs.readFileSync(csvPath, 'utf8');
2644
+ } catch (err) {
2645
+ throw _err(
2646
+ 'PLAN_NOT_FOUND',
2647
+ 'execute: Work Breakdown MD/CSV not found at "' + mdPath + '" / "' + csvPath + '": ' + err.message
2648
+ );
2649
+ }
2650
+ // Propagates PLAN_PARSE_ERROR unmodified — a malformed/unapproved plan
2651
+ // never silently starts a run.
2652
+ const snapshot = plan.createPlanSnapshot(md, csv);
2653
+
2654
+ const commonDir = _resolveCommonGitDir(projectDir);
2655
+ const executionRoot = path.join(commonDir, 'ai-toolkit', 'execution');
2656
+
2657
+ const runId = crypto.randomUUID();
2658
+ // Fail closed if already held — this is a brand-new run; reclaiming a
2659
+ // stale lease is an explicit reconcile/resume/replan decision, never an
2660
+ // implicit part of starting a fresh execute() call.
2661
+ const lease = ownership.acquireLease(executionRoot, runId);
2662
+
2663
+ try {
2664
+ const taskRef = typeof args.taskRef === 'string' && args.taskRef.length > 0
2665
+ ? args.taskRef
2666
+ : _currentBranchRef(projectDir);
2667
+ const featureRef = taskRef;
2668
+ const baseSha = _resolveRefTip(projectDir, 'HEAD', 'HEAD');
2669
+
2670
+ let state = new store.State({
2671
+ runId: runId,
2672
+ featureId: prefix,
2673
+ repo: projectDir,
2674
+ commonDir: commonDir,
2675
+ featureRef: featureRef,
2676
+ baseSha: baseSha,
2677
+ planDigest: snapshot.planDigest,
2678
+ config: {
2679
+ maxConcurrency: 1,
2680
+ claudePath: args.claudePath,
2681
+ taskTimeoutMs: args.taskTimeoutMs,
2682
+ agentBudgetUsd: args.agentBudgetUsd,
2683
+ },
2684
+ runStatus: 'running',
2685
+ });
2686
+
2687
+ snapshot.tasks.forEach(function (task) {
2688
+ state = state.addTask(task.id, { dependencies: task.dependsOn });
2689
+ });
2690
+ store.writeState(executionRoot, runId, state);
2691
+
2692
+ const tasksById = new Map(snapshot.tasks.map(function (t) { return [t.id, t]; }));
2693
+ const orderedTaskIds = plan.computeReadyQueue(snapshot).map(function (t) { return t.id; });
2694
+
2695
+ // US-07-TASK-BE-02 loop-cooperation flag: stop()'s graceful mode is
2696
+ // documented as "block new dispatch" — this loop is the only thing that
2697
+ // can actually enforce that block, since stop() itself never touches a
2698
+ // task in flight. Set only when this loop observes runStatus 'stopping'
2699
+ // at its own natural iteration boundary (the re-read below, which
2700
+ // already happens once per completed/blocked task — never mid-task);
2701
+ // used after the loop to report 'paused' instead of running the normal
2702
+ // completed/blocked classification against a run that still has
2703
+ // legitimately-pending (never dispatched) tasks.
2704
+ let stopRequested = false;
2705
+
2706
+ for (;;) {
2707
+ state = store.readState(executionRoot, runId);
2708
+
2709
+ if (state.runStatus === 'stopping') {
2710
+ // A stop request (either mode) was persisted by a concurrent stop()
2711
+ // call. This is the exact "natural iteration boundary" the request
2712
+ // waits for: the previous task (if any) already reached a terminal
2713
+ // outcome (checkpointed/blocked) via the prior loop iteration's
2714
+ // await below, and no task is currently reserved by this loop. Do
2715
+ // not dispatch the next ready task — leave it 'pending' — and stop.
2716
+ stopRequested = true;
2717
+ break;
2718
+ }
2719
+
2720
+ const nonTerminalIds = orderedTaskIds.filter(function (id) {
2721
+ const st = state.tasks[id].status;
2722
+ return st !== 'checkpointed' && st !== 'integrated' && st !== 'skipped' && st !== 'blocked';
2723
+ });
2724
+ if (nonTerminalIds.length === 0) break;
2725
+
2726
+ const readyTaskId = orderedTaskIds.find(function (id) {
2727
+ const t = state.tasks[id];
2728
+ if (t.status !== 'pending') return false;
2729
+ return t.dependencies.every(function (depId) {
2730
+ const dep = state.tasks[depId];
2731
+ return dep && (dep.status === 'checkpointed' || dep.status === 'integrated');
2732
+ });
2733
+ });
2734
+
2735
+ if (!readyTaskId) {
2736
+ // Nothing more can proceed right now (e.g. every remaining task is
2737
+ // pending on a dependency that is itself blocked) — stop, do not
2738
+ // spin. Reflected in the final run status below.
2739
+ break;
2740
+ }
2741
+
2742
+ await _runTaskToResolution({
2743
+ executionRoot: executionRoot,
2744
+ runId: runId,
2745
+ projectDir: projectDir,
2746
+ taskRef: taskRef,
2747
+ featureRef: featureRef,
2748
+ planDigest: snapshot.planDigest,
2749
+ task: tasksById.get(readyTaskId),
2750
+ claudePath: args.claudePath,
2751
+ taskTimeoutMs: args.taskTimeoutMs,
2752
+ implementationSpawnArgs: args.implementationSpawnArgs,
2753
+ reviewSpawnArgs: args.reviewSpawnArgs,
2754
+ });
2755
+ }
2756
+
2757
+ state = store.readState(executionRoot, runId);
2758
+ let finalRunStatus;
2759
+ if (stopRequested) {
2760
+ // Tech-Spec section 10: "Stopping a run returns accepted request
2761
+ // state; status confirms when it actually paused." 'paused' (already
2762
+ // part of RUN_STATUSES) is the correct terminal-for-this-loop status
2763
+ // here — unlike the completed/blocked branch below, a stop-truncated
2764
+ // run legitimately still has 'pending' tasks that were never attempted
2765
+ // and are not "blocked" in the retry-policy/dependency sense.
2766
+ finalRunStatus = 'paused';
2767
+ } else {
2768
+ const allTerminalSuccess = Object.keys(state.tasks).every(function (id) {
2769
+ const st = state.tasks[id].status;
2770
+ return st === 'checkpointed' || st === 'integrated' || st === 'skipped';
2771
+ });
2772
+ finalRunStatus = allTerminalSuccess ? 'completed' : 'blocked';
2773
+ }
2774
+ state = state.setRunStatus(finalRunStatus);
2775
+ state = store.writeState(executionRoot, runId, state);
2776
+
2777
+ return {
2778
+ protocolVersion: store.PROTOCOL_VERSION,
2779
+ runId: runId,
2780
+ runStatus: finalRunStatus,
2781
+ tasks: Object.keys(state.tasks).sort().map(function (id) {
2782
+ return { taskId: id, status: state.tasks[id].status };
2783
+ }),
2784
+ };
2785
+ } finally {
2786
+ try {
2787
+ ownership.releaseLease(executionRoot, runId, lease.nonce);
2788
+ } catch (_releaseErr) {
2789
+ // Best-effort: never mask the loop's own result/error because the
2790
+ // lease could not be released (e.g. it was already gone).
2791
+ }
2792
+ }
2793
+ }
2794
+
2795
+ /**
2796
+ * `status` command. Read-only summary of a run: counts of tasks per
2797
+ * status, current run status, and total cost as reported by the ledger
2798
+ * (the sole cost source). Never mutates state.
2799
+ *
2800
+ * Real implementation: US-06-TASK-BE-01 (loads persisted state/evidence and
2801
+ * classifies task status per the edge-cases table this summary is built
2802
+ * from).
2803
+ *
2804
+ * @param {object} args
2805
+ * @param {string} args.project - Absolute or project-relative repo path.
2806
+ * @param {string} args.runId - UUID of the run to inspect.
2807
+ * @returns {Promise<object>} Versioned result object:
2808
+ * { protocolVersion, runId, runStatus,
2809
+ * taskCounts: { pending, active, checkpointed, integrated, skipped, blocked },
2810
+ * totalTokens, totalCostUsd }.
2811
+ */
2812
+ async function status(args) {
2813
+ return _notImplemented('status', 'US-06-TASK-BE-01');
2814
+ }
2815
+
2816
+ /**
2817
+ * `diagnose` command. Read-only detailed evidence dump for a run:
2818
+ * per-task/attempt evidence (checkpoint, commit, review, diff, worker
2819
+ * liveness), ownership/lease state, and repair suggestions. Never mutates
2820
+ * state.
2821
+ *
2822
+ * Real implementation: US-06-TASK-BE-02 (evidence evaluation per the
2823
+ * edge-cases table: no-reimplementation, reconcile-SHA,
2824
+ * recheck-verification, preserve-diff, or block).
2825
+ *
2826
+ * @param {object} args
2827
+ * @param {string} args.project - Absolute or project-relative repo path.
2828
+ * @param {string} args.runId - UUID of the run to inspect.
2829
+ * @returns {Promise<object>} Versioned result object:
2830
+ * { protocolVersion, runId, ownership: {...},
2831
+ * tasks: [{ taskId, attempts: [...], evidence: {...}, suggestedAction }] }.
2832
+ */
2833
+ async function diagnose(args) {
2834
+ return _notImplemented('diagnose', 'US-06-TASK-BE-02');
2835
+ }
2836
+
2837
+ /**
2838
+ * `stop` command (US-07-TASK-BE-02). Persists a stop request for a running
2839
+ * coordinator and acknowledges receipt of the request ONLY — this function
2840
+ * never itself claims the run has actually stopped. `status`/`diagnose`
2841
+ * (both still NOT_IMPLEMENTED — US-06-TASK-BE-01/02, out of scope here)
2842
+ * confirm that once the coordinator's own loop observes the request.
2843
+ *
2844
+ * Modes:
2845
+ * - 'graceful' (default): persists the request only. execute()'s own main
2846
+ * loop (see its inline comment at the top-of-loop runStatus check, above)
2847
+ * is the ONLY thing that can actually "block new dispatch" — it observes
2848
+ * runStatus 'stopping' at its next natural iteration boundary (after its
2849
+ * current task reaches checkpointed/blocked) and stops there, leaving
2850
+ * every remaining task 'pending'. This function does nothing further for
2851
+ * 'graceful': collecting already-running results and checkpointing only
2852
+ * when evidence is already sufficient (Tech-Spec section 7) is the
2853
+ * RUNNING coordinator's job when it next observes the request, not this
2854
+ * call's — this call only persists the request.
2855
+ * - 'immediate': ADDITIONALLY attempts real termination of every attempt
2856
+ * whose stage is 'dispatching' or 'running' (i.e. potentially mid-flight
2857
+ * right now) with a recorded processIdentity: finds it via the same
2858
+ * tag-scan technique already used elsewhere in this module
2859
+ * (_findLiveTaggedProcess, US-06-TASK-BE-02), and if found alive (or
2860
+ * liveness unknown), terminates it via the same E-02-qualified tree-kill
2861
+ * technique used elsewhere, adapted to a bare PID
2862
+ * (_killProcessTreeByPidSync — needed because, unlike
2863
+ * claude-process.js's spawnClaudeAgent, this function never held a
2864
+ * ChildProcess handle for a worker it did not itself spawn: the worker
2865
+ * may belong to an entirely different coordinator process). It then
2866
+ * polls _findLiveTaggedProcess with a bounded number of short waits
2867
+ * (_awaitConfirmedTermination) before returning. If termination cannot
2868
+ * be positively confirmed within that bound, this function still
2869
+ * acknowledges the request as persisted, but reports
2870
+ * `terminationConfirmed: false` — it never claims a termination it
2871
+ * cannot verify (the same discipline _findLiveTaggedProcess itself
2872
+ * already enforces for 'unknown').
2873
+ *
2874
+ * Idempotency (exact rule): if the run's CURRENT runStatus is already
2875
+ * 'stopping', or is settled — 'completed'/'blocked'/'superseded', or
2876
+ * 'paused' (execute()'s own loop already fully drained a prior stop request
2877
+ * — see its final-status computation) — this call is a pure no-op: nothing
2878
+ * is re-persisted (no generation bump) and NO mode-specific action runs (no
2879
+ * re-kill attempt, and no escalation if this call's mode differs from
2880
+ * whatever was originally requested — a second stop() call never upgrades a
2881
+ * prior 'graceful' request to 'immediate'; only the first accepted request
2882
+ * for a run ever performs mode-specific work). The acknowledged `mode` in
2883
+ * the idempotent-'stopping' case reflects the ORIGINALLY recorded mode
2884
+ * (state.config.stopRequest.mode), not necessarily this call's own mode.
2885
+ *
2886
+ * @param {object} args
2887
+ * @param {string} args.project - Absolute or project-relative repo path
2888
+ * (resolved to the common Git directory / execution root exactly like
2889
+ * execute() does).
2890
+ * @param {string} args.runId - UUID of the run to stop.
2891
+ * @param {'graceful'|'immediate'} [args.mode='graceful'] - Stop mode.
2892
+ * @param {string} [args.exeBasename] - Test-only override of the real
2893
+ * spawned executable's basename to match for 'immediate' mode's worker
2894
+ * termination (_findLiveTaggedProcess) — same override reconcile()/
2895
+ * resume() already accept via ctx.exeBasename. Defaults to
2896
+ * DEFAULT_TAGGED_PROCESS_EXE_BASENAME ('claude.exe') in production.
2897
+ * @returns {Promise<object>} Versioned result object:
2898
+ * { protocolVersion, runId, requestAccepted: true, mode,
2899
+ * idempotent?: true,
2900
+ * terminationConfirmed?: boolean,
2901
+ * killAttempts?: Array<{taskId, attemptNumber, processIdentity, confirmed: boolean}> }
2902
+ * `terminationConfirmed`/`killAttempts` are present only for a
2903
+ * newly-accepted 'immediate' request (never for 'graceful', never for an
2904
+ * idempotent replay).
2905
+ * @throws {Error} STOP_VALIDATION_ERROR for a malformed args.project/args.
2906
+ * runId/args.mode; any error store.readState throws (STATE_NOT_FOUND,
2907
+ * STATE_CORRUPTED) propagates unmodified — a corrupted/missing run is
2908
+ * never silently defaulted.
2909
+ */
2910
+ async function stop(args) {
2911
+ args = args || {};
2912
+ _requireNonEmptyString(args.project, 'stop: args.project');
2913
+ _requireNonEmptyString(args.runId, 'stop: args.runId');
2914
+
2915
+ const mode = args.mode === undefined ? 'graceful' : args.mode;
2916
+ if (mode !== 'graceful' && mode !== 'immediate') {
2917
+ throw _err(
2918
+ 'STOP_VALIDATION_ERROR',
2919
+ 'stop: args.mode must be "graceful" or "immediate", got ' + JSON.stringify(args.mode)
2920
+ );
2921
+ }
2922
+
2923
+ const projectDir = args.project;
2924
+ const runId = args.runId;
2925
+ const commonDir = _resolveCommonGitDir(projectDir);
2926
+ const executionRoot = path.join(commonDir, 'ai-toolkit', 'execution');
2927
+
2928
+ // Propagates STATE_NOT_FOUND / STATE_CORRUPTED unmodified — never
2929
+ // fabricates an empty/default run for a corrupted/missing one.
2930
+ let state = store.readState(executionRoot, runId);
2931
+
2932
+ // Idempotency (see doc comment above for the exact rule and rationale for
2933
+ // including 'paused' alongside the terminal statuses).
2934
+ const SETTLED_OR_STOPPING_RUN_STATUSES = ['stopping', 'completed', 'blocked', 'superseded', 'paused'];
2935
+ if (SETTLED_OR_STOPPING_RUN_STATUSES.indexOf(state.runStatus) !== -1) {
2936
+ const recordedMode = state.config && state.config.stopRequest && state.config.stopRequest.mode;
2937
+ return {
2938
+ protocolVersion: store.PROTOCOL_VERSION,
2939
+ runId: runId,
2940
+ requestAccepted: true,
2941
+ mode: recordedMode || mode,
2942
+ idempotent: true,
2943
+ };
2944
+ }
2945
+
2946
+ // Persist the stop request: one write, both the runStatus transition and
2947
+ // the requested mode (store.js's State#requestStop — see its own doc
2948
+ // comment for why `config` was chosen over a new top-level field).
2949
+ state = state.requestStop(mode);
2950
+ state = store.writeState(executionRoot, runId, state);
2951
+
2952
+ if (mode === 'graceful') {
2953
+ // That's it — the already-running execute() loop enforces the block
2954
+ // (see execute()'s own inline comment at its loop-top runStatus check).
2955
+ return {
2956
+ protocolVersion: store.PROTOCOL_VERSION,
2957
+ runId: runId,
2958
+ requestAccepted: true,
2959
+ mode: mode,
2960
+ };
2961
+ }
2962
+
2963
+ // mode === 'immediate': additionally attempt real termination of every
2964
+ // attempt that is potentially mid-flight right now.
2965
+ const exeBasename = typeof args.exeBasename === 'string' && args.exeBasename.length > 0
2966
+ ? args.exeBasename
2967
+ : DEFAULT_TAGGED_PROCESS_EXE_BASENAME;
2968
+
2969
+ const inFlightStages = ['dispatching', 'running'];
2970
+ const candidates = [];
2971
+ Object.keys(state.tasks).sort().forEach(function (taskId) {
2972
+ const task = state.tasks[taskId];
2973
+ (Array.isArray(task.attempts) ? task.attempts : []).forEach(function (attempt) {
2974
+ if (inFlightStages.indexOf(attempt.stage) === -1) return;
2975
+ if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) return;
2976
+ candidates.push({ taskId: taskId, attemptNumber: attempt.number, processIdentity: attempt.processIdentity });
2977
+ });
2978
+ });
2979
+
2980
+ const killAttempts = [];
2981
+ let terminationConfirmed = true; // vacuously true when there is nothing to kill
2982
+
2983
+ for (let i = 0; i < candidates.length; i++) {
2984
+ const candidate = candidates[i];
2985
+ const liveness = _findLiveTaggedProcess(candidate.processIdentity, exeBasename);
2986
+
2987
+ if (liveness.status === 'confirmed-not-found') {
2988
+ killAttempts.push(Object.assign({}, candidate, { confirmed: true }));
2989
+ continue;
2990
+ }
2991
+
2992
+ // 'found-alive': kill it now. 'unknown': never coerced into either
2993
+ // extreme up front — no kill is attempted without positive evidence,
2994
+ // but the poll below still gets a chance to observe a confirmed-dead
2995
+ // result before this attempt is reported unconfirmed.
2996
+ if (liveness.status === 'found-alive') {
2997
+ for (let p = 0; p < liveness.pids.length; p++) {
2998
+ _killProcessTreeByPidSync(liveness.pids[p]);
2999
+ }
3000
+ }
3001
+
3002
+ const confirmed = await _awaitConfirmedTermination(candidate.processIdentity, exeBasename);
3003
+ killAttempts.push(Object.assign({}, candidate, { confirmed: confirmed }));
3004
+ if (!confirmed) {
3005
+ terminationConfirmed = false;
3006
+ }
3007
+ }
3008
+
3009
+ return {
3010
+ protocolVersion: store.PROTOCOL_VERSION,
3011
+ runId: runId,
3012
+ requestAccepted: true,
3013
+ mode: mode,
3014
+ terminationConfirmed: terminationConfirmed,
3015
+ killAttempts: killAttempts,
3016
+ };
3017
+ }
3018
+
3019
+ /**
3020
+ * Enforces Tech-Spec section 9's "reconcile/resume require no live competing
3021
+ * coordinator" precondition. Called at the very start of both reconcile()
3022
+ * and resume(), before any repair (registerCommitSHA/finalizeTaskCheckpoint)
3023
+ * — fails closed, so no partial repair is ever applied while a different,
3024
+ * live coordinator might also be acting on the same run.
3025
+ *
3026
+ * Decision table:
3027
+ * - No lease held at all: nothing to compete with — proceed.
3028
+ * - Lease held by the SAME `runId` as the caller: this is the caller
3029
+ * reconciling its own run (e.g. as part of its own resume flow, whether
3030
+ * called before or after it re-acquires the lease) — proceed.
3031
+ * - Lease held by a DIFFERENT runId: consult
3032
+ * ownership.checkOwnerLiveness(lease).
3033
+ * - alive === true: a different coordinator is actively live — refuse
3034
+ * with COMPETING_COORDINATOR_LIVE.
3035
+ * - alive === 'unknown': never treat unknown as safe-to-proceed (the
3036
+ * same invariant enforced everywhere else in this codebase, e.g.
3037
+ * ownership.reclaimLease's own LEASE_LIVENESS_UNKNOWN) — refuse with
3038
+ * COMPETING_COORDINATOR_LIVENESS_UNKNOWN.
3039
+ * - alive === false (confirmed dead): proceed. reconcile/resume are
3040
+ * read-mostly + idempotent-repair operations, not a reclaim of the
3041
+ * lease itself — none of their classification/repair work requires
3042
+ * actually holding the lease, so a confirmed-dead old coordinator's
3043
+ * still-present lease file is not a blocker here. This function
3044
+ * deliberately does NOT call ownership.reclaimLease — reclaiming the
3045
+ * lease is a separate, explicit operator/coordinator decision made
3046
+ * elsewhere, not an implicit side effect of reconciling evidence.
3047
+ *
3048
+ * @param {string} executionRoot
3049
+ * @param {string} runId - The calling run's own runId (args.runId).
3050
+ * @param {string} callerName - 'reconcile' or 'resume', used only to make
3051
+ * the thrown error message identify which command refused to proceed.
3052
+ * @throws {Error} COMPETING_COORDINATOR_LIVE, COMPETING_COORDINATOR_LIVENESS_UNKNOWN
3053
+ */
3054
+ function _ensureNoLiveCompetingCoordinator(executionRoot, runId, callerName) {
3055
+ const lease = ownership.readLease(executionRoot);
3056
+ if (!lease || lease.runId === runId) {
3057
+ return;
3058
+ }
3059
+
3060
+ const liveness = ownership.checkOwnerLiveness(lease);
3061
+ if (liveness.alive === true) {
3062
+ throw _err(
3063
+ 'COMPETING_COORDINATOR_LIVE',
3064
+ callerName + ': refusing to proceed — a different coordinator (runId "' + lease.runId +
3065
+ '", host ' + lease.host + ', pid ' + lease.pid + ') currently holds the repo-wide execution ' +
3066
+ 'lease at ' + executionRoot + ' and is confirmed live: ' + liveness.reason
3067
+ );
3068
+ }
3069
+ if (liveness.alive !== false) {
3070
+ throw _err(
3071
+ 'COMPETING_COORDINATOR_LIVENESS_UNKNOWN',
3072
+ callerName + ': refusing to proceed — a different coordinator (runId "' + lease.runId +
3073
+ '", host ' + lease.host + ', pid ' + lease.pid + ') currently holds the repo-wide execution ' +
3074
+ 'lease at ' + executionRoot + ' and its liveness could not be conclusively determined: ' +
3075
+ liveness.reason
3076
+ );
3077
+ }
3078
+ // liveness.alive === false (confirmed dead): fall through and proceed.
3079
+ // Never reclaim the lease here — see doc comment above.
3080
+ }
3081
+
3082
+ /**
3083
+ * `reconcile` command (US-06-TASK-BE-01). Loads persisted state and
3084
+ * evidence (receipts, intents, registered commit SHAs) and classifies every
3085
+ * task's current attempt per the Tech-Spec section 9 edge-cases table (see
3086
+ * the file-level "reconcile/resume evidence classification" comment above
3087
+ * _classifyTask for exactly which rows are fully covered here versus
3088
+ * explicitly deferred to US-06-TASK-BE-02). Applies ONLY two kinds of
3089
+ * idempotent repair, both reusing an already-existing primitive:
3090
+ * - registerCommitSHA, when a checkpoint-prepared attempt's taskRef tip is
3091
+ * found to be an exact match for its own persisted checkpoint intent
3092
+ * (narrow, tip-only recovery — see scope comment above);
3093
+ * - finalizeTaskCheckpoint, when a committed attempt's registered SHA is
3094
+ * confirmed reachable on taskRef but the task's overall status was not
3095
+ * yet advanced to 'checkpointed'.
3096
+ * Never dispatches new work, never touches a task that was never started
3097
+ * (status 'pending' with zero attempts), never reinterprets approvals, never
3098
+ * cleans resources. If `args.planDigest` is supplied and does not match the
3099
+ * run's own recorded state.planDigest, every task is reported blocked
3100
+ * ('blocked-plan-changed') and no repair is attempted for any task (Tech-Spec:
3101
+ * "Plan/config changed -> Block until approved successor plan").
3102
+ *
3103
+ * Enforces "no live competing coordinator" (Tech-Spec section 9) via
3104
+ * _ensureNoLiveCompetingCoordinator, called first — before store.readState,
3105
+ * before any classification, before any repair. See that function's own doc
3106
+ * comment for the exact decision table (no lease / same runId / different-
3107
+ * but-dead runId all proceed; different-and-live-or-unknown refuses).
3108
+ *
3109
+ * @param {object} args
3110
+ * @param {string} args.executionRoot - Absolute path to
3111
+ * <common-git-dir>/ai-toolkit/execution.
3112
+ * @param {string} args.runId - UUID of the run to reconcile.
3113
+ * @param {string} args.projectDir - Absolute path to the Git worktree/repo
3114
+ * the commit-reachability checks run in.
3115
+ * @param {string} args.taskRef - The feature/task ref every task's commits
3116
+ * are checked against (N=1 model only — see file-level scope comment).
3117
+ * @param {string} [args.planDigest] - The currently-approved plan's digest
3118
+ * (e.g. from plan.createPlanSnapshot). When supplied and it differs from
3119
+ * the run's recorded planDigest, every task is reported blocked and no
3120
+ * repair is attempted.
3121
+ * @param {string} [args.exeBasename] - Basename of the real spawned
3122
+ * executable to match against for worker-liveness checks
3123
+ * (_findLiveTaggedProcess, US-06-TASK-BE-02). Defaults to
3124
+ * DEFAULT_TAGGED_PROCESS_EXE_BASENAME ('claude.exe'); tests override this
3125
+ * to whatever real, short-lived child process they spawn under test.
3126
+ * @returns {Promise<object>} Versioned result object:
3127
+ * { protocolVersion, runId, repairsApplied: [{ taskId, action }],
3128
+ * classifications: [{ taskId, classification, action }] } — the last
3129
+ * field is additive (beyond the documented minimum shape above) so callers
3130
+ * get the full per-task classification, not just applied repairs.
3131
+ * @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
3132
+ * COMPETING_COORDINATOR_LIVE / COMPETING_COORDINATOR_LIVENESS_UNKNOWN when
3133
+ * a different, live-or-unknown-liveness coordinator holds the repo-wide
3134
+ * execution lease; any error store.readState throws (STATE_NOT_FOUND,
3135
+ * STATE_CORRUPTED) propagates unmodified — a corrupted/missing run is
3136
+ * never silently defaulted.
3137
+ */
3138
+ async function reconcile(args) {
3139
+ args = args || {};
3140
+ _requireNonEmptyString(args.executionRoot, 'reconcile: args.executionRoot');
3141
+ _requireNonEmptyString(args.runId, 'reconcile: args.runId');
3142
+ _requireNonEmptyString(args.projectDir, 'reconcile: args.projectDir');
3143
+ _requireNonEmptyString(args.taskRef, 'reconcile: args.taskRef');
3144
+ if (args.planDigest !== undefined && args.planDigest !== null) {
3145
+ _requireNonEmptyString(args.planDigest, 'reconcile: args.planDigest');
3146
+ }
3147
+
3148
+ const executionRoot = args.executionRoot;
3149
+ const runId = args.runId;
3150
+
3151
+ // Fail closed BEFORE any repair — see _ensureNoLiveCompetingCoordinator's
3152
+ // own doc comment for the exact decision table.
3153
+ _ensureNoLiveCompetingCoordinator(executionRoot, runId, 'reconcile');
3154
+
3155
+ const ctx = {
3156
+ executionRoot: executionRoot,
3157
+ runId: runId,
3158
+ projectDir: args.projectDir,
3159
+ taskRef: args.taskRef,
3160
+ // US-06-TASK-BE-02: basename of the real spawned executable to match
3161
+ // against when checking worker liveness (_findLiveTaggedProcess). Tests
3162
+ // override this to the real, short-lived child process they spawn under
3163
+ // test (e.g. 'node.exe') — see the DEFAULT_TAGGED_PROCESS_EXE_BASENAME
3164
+ // file comment above IS_WINDOWS for why production defaults to
3165
+ // 'claude.exe' rather than 'node.exe'.
3166
+ exeBasename: typeof args.exeBasename === 'string' && args.exeBasename.length > 0
3167
+ ? args.exeBasename
3168
+ : DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
3169
+ };
3170
+
3171
+ // Propagates STATE_NOT_FOUND / STATE_CORRUPTED unmodified — never
3172
+ // fabricates an empty run for a corrupted/missing one.
3173
+ const state = store.readState(executionRoot, runId);
3174
+
3175
+ const planChanged = typeof args.planDigest === 'string' && args.planDigest !== state.planDigest;
3176
+
3177
+ const repairsApplied = [];
3178
+ const classifications = [];
3179
+
3180
+ Object.keys(state.tasks).sort().forEach(function (taskId) {
3181
+ if (planChanged) {
3182
+ classifications.push({ taskId: taskId, classification: 'blocked', action: 'blocked-plan-changed' });
3183
+ return;
3184
+ }
3185
+ const task = state.tasks[taskId];
3186
+ const result = _classifyTask(ctx, taskId, task);
3187
+ classifications.push({ taskId: taskId, classification: result.classification, action: result.action });
3188
+ if (result.repairAction) {
3189
+ repairsApplied.push({ taskId: taskId, action: result.repairAction });
3190
+ }
3191
+ });
3192
+
3193
+ return {
3194
+ protocolVersion: store.PROTOCOL_VERSION,
3195
+ runId: runId,
3196
+ repairsApplied: repairsApplied,
3197
+ classifications: classifications,
3198
+ };
3199
+ }
3200
+
3201
+ /**
3202
+ * `resume` command (US-06-TASK-BE-01). Enforces "no live competing
3203
+ * coordinator" itself (see _ensureNoLiveCompetingCoordinator) before doing
3204
+ * anything else, then calls reconcile() (which enforces the same precondition
3205
+ * again — cheap and read-only, and keeps reconcile correct as a standalone
3206
+ * entry point too) to apply its idempotent repairs, then re-reads state and
3207
+ * reports, for every task, its current status plus the next-safe-action
3208
+ * classification reconcile already computed. Does NOT dispatch new work
3209
+ * itself — no run loop exists yet (the real dispatch loop is
3210
+ * US-07-TASK-BE-01, execute()); resume's job here ends at "reconcile, then
3211
+ * report what is safe to continue".
3212
+ *
3213
+ * @param {object} args - same shape as reconcile's args (forwarded as-is).
3214
+ * @param {string} args.executionRoot
3215
+ * @param {string} args.runId
3216
+ * @param {string} args.projectDir
3217
+ * @param {string} args.taskRef
3218
+ * @param {string} [args.planDigest]
3219
+ * @returns {Promise<object>} Versioned result object:
3220
+ * { protocolVersion, runId, runStatus, tasks: [{ taskId, status,
3221
+ * nextAction }] } — `nextAction` is additive (beyond the documented
3222
+ * minimum `{ taskId, status }` shape above), taken from reconcile's own
3223
+ * per-task classification.
3224
+ * @throws {Error} Same as reconcile — propagated unmodified, including
3225
+ * COMPETING_COORDINATOR_LIVE / COMPETING_COORDINATOR_LIVENESS_UNKNOWN.
3226
+ */
3227
+ async function resume(args) {
3228
+ args = args || {};
3229
+ _requireNonEmptyString(args.executionRoot, 'resume: args.executionRoot');
3230
+ _requireNonEmptyString(args.runId, 'resume: args.runId');
3231
+ _requireNonEmptyString(args.projectDir, 'resume: args.projectDir');
3232
+ _requireNonEmptyString(args.taskRef, 'resume: args.taskRef');
3233
+
3234
+ // Fail closed BEFORE any repair — same precondition reconcile() enforces
3235
+ // (and will enforce again just below); checked here too so resume fails
3236
+ // closed even if its own delegation to reconcile ever changes.
3237
+ _ensureNoLiveCompetingCoordinator(args.executionRoot, args.runId, 'resume');
3238
+
3239
+ const reconcileResult = await reconcile(args);
3240
+
3241
+ const state = store.readState(args.executionRoot, args.runId);
3242
+
3243
+ const classificationByTaskId = {};
3244
+ reconcileResult.classifications.forEach(function (c) { classificationByTaskId[c.taskId] = c; });
3245
+
3246
+ const tasks = Object.keys(state.tasks).sort().map(function (taskId) {
3247
+ const task = state.tasks[taskId];
3248
+ const classification = classificationByTaskId[taskId];
3249
+ return {
3250
+ taskId: taskId,
3251
+ status: task.status,
3252
+ nextAction: classification ? classification.action : 'none',
3253
+ };
3254
+ });
3255
+
3256
+ return {
3257
+ protocolVersion: store.PROTOCOL_VERSION,
3258
+ runId: args.runId,
3259
+ runStatus: state.runStatus,
3260
+ tasks: tasks,
3261
+ repairsApplied: reconcileResult.repairsApplied,
3262
+ };
3263
+ }
3264
+
3265
+ /**
3266
+ * Evaluates ONE task's persisted evidence (checkpoint, commit, review, diff,
3267
+ * worker liveness) and returns the classification/action reconcile()/
3268
+ * resume() already compute per task, for a single task in isolation
3269
+ * (US-06-TASK-BE-02; completes the Work Breakdown outcome "evaluates
3270
+ * persisted evidence ... and determines: no-reimplementation, reconcile-SHA,
3271
+ * recheck-verification, preserve-diff, or block"). Built directly on top of
3272
+ * reconcile()'s own per-task dispatch (_classifyTask and its helpers) rather
3273
+ * than duplicating any evidence-reading logic — this function's whole
3274
+ * contribution over calling reconcile() for the same run is scoping the
3275
+ * question to one task and returning its full classification result object
3276
+ * (including additive fields like hasPartialEvidence, not just the
3277
+ * {taskId, classification, action} triple reconcile()'s own summary keeps).
3278
+ *
3279
+ * Read-only for the specific case evaluated here EXCEPT for the same two
3280
+ * idempotent repairs reconcile() itself may apply (registerCommitSHA,
3281
+ * finalizeTaskCheckpoint) when this task's evidence matches one of those two
3282
+ * rows — this function does not introduce a new "read-only" mode separate
3283
+ * from reconcile()'s own documented repair behavior.
3284
+ *
3285
+ * Does NOT enforce "no live competing coordinator" itself — a single-task
3286
+ * evidence read is intentionally lighter-weight than reconcile()/resume()'s
3287
+ * own repo-wide precondition; callers that need that guarantee should call
3288
+ * reconcile()/resume() instead, or add it themselves before calling this
3289
+ * function repeatedly in a loop.
3290
+ *
3291
+ * @param {object} args
3292
+ * @param {string} args.executionRoot - Absolute path to
3293
+ * <common-git-dir>/ai-toolkit/execution.
3294
+ * @param {string} args.runId - UUID of the run to evaluate.
3295
+ * @param {object} args.task - Plan task object (plan.js shape): at least
3296
+ * `id`, matching a task already recorded in the run's state.
3297
+ * @param {string} args.projectDir - Absolute path to the Git worktree/repo
3298
+ * the commit-reachability checks run in.
3299
+ * @param {string} args.taskRef - The feature/task ref this task's commits
3300
+ * are checked against (N=1 model only — see file-level scope comment above
3301
+ * the reconcile/resume evidence classification section).
3302
+ * @param {string} [args.exeBasename] - Basename of the real spawned
3303
+ * executable to match against for worker-liveness checks. Defaults to
3304
+ * DEFAULT_TAGGED_PROCESS_EXE_BASENAME ('claude.exe').
3305
+ * @returns {Promise<{taskId: string, classification: string, action: string,
3306
+ * repairAction?: string, hasPartialEvidence?: boolean}>}
3307
+ * @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
3308
+ * STATE_TASK_NOT_FOUND when the task is not recorded in the run's state;
3309
+ * any error store.readState throws (STATE_NOT_FOUND, STATE_CORRUPTED)
3310
+ * propagates unmodified.
3311
+ */
3312
+ async function evaluateTaskEvidence(args) {
3313
+ args = args || {};
3314
+ _requireNonEmptyString(args.executionRoot, 'evaluateTaskEvidence: args.executionRoot');
3315
+ _requireNonEmptyString(args.runId, 'evaluateTaskEvidence: args.runId');
3316
+ _requireNonEmptyString(args.projectDir, 'evaluateTaskEvidence: args.projectDir');
3317
+ _requireNonEmptyString(args.taskRef, 'evaluateTaskEvidence: args.taskRef');
3318
+ if (args.task == null || typeof args.task !== 'object') {
3319
+ throw _err('DISPATCH_VALIDATION_ERROR', 'evaluateTaskEvidence: args.task must be an object');
3320
+ }
3321
+ _requireNonEmptyString(args.task.id, 'evaluateTaskEvidence: args.task.id');
3322
+
3323
+ const executionRoot = args.executionRoot;
3324
+ const runId = args.runId;
3325
+ const taskId = args.task.id;
3326
+
3327
+ const state = store.readState(executionRoot, runId);
3328
+ const task = state.tasks[taskId];
3329
+ if (!task) {
3330
+ throw _err('STATE_TASK_NOT_FOUND', 'evaluateTaskEvidence: task "' + taskId + '" not found');
3331
+ }
3332
+
3333
+ const ctx = {
3334
+ executionRoot: executionRoot,
3335
+ runId: runId,
3336
+ projectDir: args.projectDir,
3337
+ taskRef: args.taskRef,
3338
+ exeBasename: typeof args.exeBasename === 'string' && args.exeBasename.length > 0
3339
+ ? args.exeBasename
3340
+ : DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
3341
+ };
3342
+
3343
+ const result = _classifyTask(ctx, taskId, task);
3344
+ return Object.assign({ taskId: taskId }, result);
3345
+ }
3346
+
3347
+ // ── replan (US-06-TASK-BE-03) ────────────────────────────────────────────────
3348
+ //
3349
+ // Derives the successor feature's directory from its feature.md path, using
3350
+ // the EXACT same separator-agnostic convention src/claude/workflows/
3351
+ // pm-phase2.js's own deriveFeatureDir uses: feature directory = parent of the
3352
+ // feature.md path; strip a trailing "<sep>feature.md" tail (case-insensitive,
3353
+ // POSIX "/" or Windows "\"), or just a trailing separator if the path is
3354
+ // already a directory. Duplicated here rather than imported — pm-phase2.js is
3355
+ // a Claude Code Workflow script (run by the workflow runtime, not a
3356
+ // requirable Node module; see AGENTS.md's "Workflow script pattern"), so this
3357
+ // library module cannot depend on it. Keeping the exact same regex is the
3358
+ // point: both places must agree on where a feature's artifacts live.
3359
+ function _deriveFeatureDir(featurePath) {
3360
+ return /[/\\]feature\.md$/i.test(featurePath)
3361
+ ? featurePath.replace(/[/\\]feature\.md$/i, '')
3362
+ : featurePath.replace(/[/\\]+$/, '');
3363
+ }
3364
+
3365
+ // Same convention pm-phase2.js uses: PREFIX is the first "<UPPER>-<digits>"
3366
+ // substring found in the feature directory name (e.g. "FTR-018", "ASSESS-001").
3367
+ function _derivePrefix(featureDir) {
3368
+ const m = featureDir.match(/([A-Z]+-\d+)/);
3369
+ return m ? m[1] : null;
3370
+ }
3371
+
3372
+ // Minimal, deliberately non-generic Markdown check: does a case-sensitive
3373
+ // "## Gate 2" heading appear anywhere in the successor's Approvals.md?
3374
+ //
3375
+ // This codebase has no existing generic Markdown-section-reader/parser to
3376
+ // reuse for this: every other "verify Gate 2 is present in Approvals.md"
3377
+ // pre-condition in this repo (e.g. src/claude/skills/implement-feature/
3378
+ // SKILL.md Step 4/6, AGENTS.md's "Approval Gate Protocol") is carried out by
3379
+ // an LLM agent reading the file in the main loop, not by a JS utility — there
3380
+ // is nothing to import. plan.js's own section extraction
3381
+ // (_extractTaskDetailsSection) is specific to the "## Task Details" section's
3382
+ // very different internal structure (task anchors, fenced verification
3383
+ // blocks) and would be a wrong-shaped tool bent to a one-line question here.
3384
+ // Writing a full generic Markdown-section parser purely to answer "did a
3385
+ // '## Gate 2' heading ever get appended to this file" would be
3386
+ // over-engineering for what this function actually needs. `^## Gate 2`
3387
+ // anchored to a line start (multiline, case-sensitive — no 'i' flag) is the
3388
+ // literal heading text this codebase's own Approvals.md template always
3389
+ // writes verbatim (see this feature's own FTR-018-Approvals.md: "## Gate 2 —
3390
+ // Work Breakdown Approval").
3391
+ const _GATE_2_HEADING_RE = /^## Gate 2\b/m;
3392
+
3393
+ function _hasGate2Approval(approvalsText) {
3394
+ return _GATE_2_HEADING_RE.test(approvalsText);
3395
+ }
3396
+
3397
+ /**
3398
+ * `replan` command (US-06-TASK-BE-03; completes US-06 "resume from persisted
3399
+ * evidence and reconcile without re-execution" for the branch-off case: an
3400
+ * original run that cannot simply resume because its plan itself must
3401
+ * change). Tech-Spec section 9 (Gate-1-binding, carried into this Work
3402
+ * Breakdown): "Replan does not grant approval. First produce a proposed
3403
+ * successor plan and human decision outside the executor. Then replan
3404
+ * command validates new Gate 2/revision evidence and records old->new digest
3405
+ * and task mapping. Original run becomes superseded only after no live
3406
+ * workers and evidence retained. Carry completion only for unchanged
3407
+ * task/context with reachable verified checkpoints and explicit approved
3408
+ * mapping; otherwise require a new task identity. No data deletion,
3409
+ * automatic branch rewrite or reset of failed-attempt counters to evade
3410
+ * policy."
3411
+ *
3412
+ * This function grants NO approval itself (the successor's Gate 2 approval
3413
+ * must already exist on disk, produced by the normal implement-feature main-
3414
+ * loop gate protocol — see AGENTS.md's "Approval Gate Protocol" — entirely
3415
+ * outside this executor) and does NOT dispatch, start, or touch the
3416
+ * successor run in any way: its job ends at validating + recording the
3417
+ * mapping the caller already decided, and superseding the original run.
3418
+ * `taskMapping` is an explicit, caller-supplied old->new task ID mapping —
3419
+ * this function never invents one.
3420
+ *
3421
+ * Ordering, fail-closed at every step, in order (no step above ever mutates
3422
+ * anything once a later step can still fail):
3423
+ * 1. Successor Gate 2 / revision approval evidence must exist on disk (a
3424
+ * "## Gate 2" heading in the successor's Approvals.md — see
3425
+ * _hasGate2Approval above for why this minimal check, not a generic
3426
+ * parser). Missing/unreadable Approvals.md or no such heading:
3427
+ * SUCCESSOR_NOT_APPROVED, before anything else is read.
3428
+ * 2. The successor's plan snapshot is built via plan.createPlanSnapshot
3429
+ * from its REAL Work-Breakdown.md/.csv files on disk (US-01-TASK-BE-04,
3430
+ * already implemented) — this both proves the successor plan parses/
3431
+ * validates cleanly and yields its planDigest. Missing MD/CSV files:
3432
+ * SUCCESSOR_PLAN_NOT_FOUND. A malformed MD/CSV: the underlying
3433
+ * PLAN_PARSE_ERROR propagates unmodified.
3434
+ * 3. The ORIGINAL run's state is read (store.readState — STATE_NOT_FOUND /
3435
+ * STATE_CORRUPTED propagate unmodified; a corrupted/missing original
3436
+ * run is never silently defaulted).
3437
+ * 4. Every taskMapping entry is validated against BOTH sides: oldTaskId
3438
+ * must exist in the original run's state.tasks; newTaskId must exist in
3439
+ * the successor snapshot's tasks. Any entry referencing an unknown ID on
3440
+ * either side fails the WHOLE call closed with
3441
+ * REPLAN_UNKNOWN_TASK_MAPPING, before any mutation and before any
3442
+ * carry-completion eligibility is even computed.
3443
+ * 5. For each validated pair, carry-completion eligibility is computed —
3444
+ * read-only, never touches the old task's own evidence either way. A
3445
+ * pair is eligible ONLY when the OLD task's status is already
3446
+ * 'checkpointed' or 'integrated' AND its last attempt's registered
3447
+ * originalSha is a valid SHA reachable on `taskRef` right now (reusing
3448
+ * this same file's own _isCommitReachableOnRef — the identical
3449
+ * merge-base-based check finalizeTaskCheckpoint/reconcile already use;
3450
+ * not duplicated as a new helper). Anything else (wrong status, no
3451
+ * attempt, no/invalid SHA, unreachable, or the reachability check itself
3452
+ * failing) is recorded with carriesCompletion=false — "otherwise require
3453
+ * a new task identity" — never guessed into true.
3454
+ * 6. "No live workers" gate: EVERY task/attempt in the ORIGINAL run (not
3455
+ * just the mapped ones) whose attempt carries a recorded
3456
+ * processIdentity is checked via US-06-TASK-BE-02's own
3457
+ * _findLiveTaggedProcess. Any 'found-alive' OR 'unknown' result refuses
3458
+ * the whole call closed with LIVE_WORKER_BLOCKS_REPLAN — 'unknown' is
3459
+ * never treated as safe, mirroring _ensureNoLiveCompetingCoordinator's
3460
+ * and _classifyLiveOrDeadWorkerAttempt's identical "never assume dead"
3461
+ * discipline elsewhere in this file. Only proceeds once every checked
3462
+ * attempt is 'confirmed-not-found', or has no processIdentity at all
3463
+ * (nothing to check).
3464
+ * 7. Only once 1-6 have all succeeded: the old->new plan digest mapping
3465
+ * (including each pair's carriesCompletion flag) is persisted as a
3466
+ * durable, immutable record via store.js's existing writeIntent
3467
+ * primitive — reused rather than inventing a new persistence primitive,
3468
+ * the same immutable-record-by-id mechanism checkpoint intents already
3469
+ * use (see store.js's persistCheckpointIntent-adjacent writeIntent). A
3470
+ * single fixed id ("replan") is used per original run: at most one
3471
+ * supersession decision is ever recorded for a given run; a replayed
3472
+ * call with IDENTICAL inputs is an idempotent no-op (writeIntent's own
3473
+ * identical-content replay rule), while a conflicting second call
3474
+ * (different successor/mapping) fails closed with INTENT_CONFLICT
3475
+ * rather than silently overwriting the first decision. This record is
3476
+ * written BEFORE the run status changes below, so a conflicting replay
3477
+ * never leaves the original run superseded without its mapping record
3478
+ * durably alongside it.
3479
+ * 8. Only after the record is durably persisted: the original run's status
3480
+ * is set to 'superseded' (store.js's existing RUN_STATUSES value — not a
3481
+ * new status) via State.setRunStatus + store.writeState. Nothing about
3482
+ * any task's own attempts/evidence is deleted, rewritten, or reset —
3483
+ * this function never touches task/attempt records at all, only the
3484
+ * run-level runStatus field and the new immutable replan record.
3485
+ *
3486
+ * Does NOT dispatch, start, or touch the successor run in any way (per its
3487
+ * own scope — see the module-level file comment's original stub doc, which
3488
+ * this replaces).
3489
+ *
3490
+ * @param {object} args
3491
+ * @param {string} args.executionRoot - Absolute path to
3492
+ * <common-git-dir>/ai-toolkit/execution.
3493
+ * @param {string} args.runId - UUID of the original run being replanned;
3494
+ * must already hold a valid state.json (STATE_NOT_FOUND otherwise).
3495
+ * @param {string} args.projectDir - Absolute path to the Git worktree/repo
3496
+ * the commit-reachability checks (step 5) run in.
3497
+ * @param {string} args.taskRef - The feature/task ref carry-completion
3498
+ * eligibility's reachability check runs against (N=1 model only — same
3499
+ * scope boundary as reconcile()/finalizeTaskCheckpoint()).
3500
+ * @param {string} args.feature - Path to the SUCCESSOR feature's feature.md,
3501
+ * used to derive its PREFIX/directory (same convention as
3502
+ * src/claude/workflows/pm-phase2.js) and locate its Approvals.md and
3503
+ * Work-Breakdown.md/.csv.
3504
+ * @param {Array<{oldTaskId: string, newTaskId: string}>} args.taskMapping -
3505
+ * Caller-supplied explicit old->new task ID mapping (the human/caller's own
3506
+ * decision — this function validates and records it, never invents one).
3507
+ * @param {string} [args.exeBasename] - Basename of the real spawned
3508
+ * executable to match against for worker-liveness checks
3509
+ * (_findLiveTaggedProcess). Defaults to DEFAULT_TAGGED_PROCESS_EXE_BASENAME
3510
+ * ('claude.exe'); tests override this to whatever real, short-lived child
3511
+ * process they spawn under test.
3512
+ * @returns {Promise<{protocolVersion: number, originalRunId: string,
3513
+ * successorPlanDigest: string,
3514
+ * taskMapping: Array<{oldTaskId: string, newTaskId: string,
3515
+ * carriesCompletion: boolean}>}>}
3516
+ * @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
3517
+ * SUCCESSOR_NOT_APPROVED when the successor's Approvals.md is missing,
3518
+ * unreadable, or has no "## Gate 2" section; SUCCESSOR_PLAN_NOT_FOUND when
3519
+ * the successor's Work-Breakdown.md/.csv cannot be read; the underlying
3520
+ * PLAN_PARSE_ERROR when the successor plan itself is malformed;
3521
+ * REPLAN_UNKNOWN_TASK_MAPPING when any mapping entry references an unknown
3522
+ * ID on either side; LIVE_WORKER_BLOCKS_REPLAN when any original-run
3523
+ * attempt's worker liveness is not confirmed-not-found;
3524
+ * STATE_NOT_FOUND / STATE_CORRUPTED propagated unmodified from
3525
+ * store.readState; INTENT_CONFLICT propagated unmodified from
3526
+ * store.writeIntent when a conflicting replan record already exists for
3527
+ * this run.
3528
+ */
3529
+ async function replan(args) {
3530
+ args = args || {};
3531
+
3532
+ _requireNonEmptyString(args.executionRoot, 'replan: args.executionRoot');
3533
+ _requireNonEmptyString(args.runId, 'replan: args.runId');
3534
+ _requireNonEmptyString(args.projectDir, 'replan: args.projectDir');
3535
+ _requireNonEmptyString(args.taskRef, 'replan: args.taskRef');
3536
+ _requireNonEmptyString(args.feature, 'replan: args.feature');
3537
+ if (!Array.isArray(args.taskMapping)) {
3538
+ throw _err('DISPATCH_VALIDATION_ERROR', 'replan: args.taskMapping must be an array');
3539
+ }
3540
+ args.taskMapping.forEach(function (entry, idx) {
3541
+ if (
3542
+ entry == null || typeof entry !== 'object' ||
3543
+ typeof entry.oldTaskId !== 'string' || entry.oldTaskId.length === 0 ||
3544
+ typeof entry.newTaskId !== 'string' || entry.newTaskId.length === 0
3545
+ ) {
3546
+ throw _err(
3547
+ 'DISPATCH_VALIDATION_ERROR',
3548
+ 'replan: args.taskMapping[' + idx + '] must be an object with non-empty string "oldTaskId"/"newTaskId"'
3549
+ );
3550
+ }
3551
+ });
3552
+
3553
+ const executionRoot = args.executionRoot;
3554
+ const runId = args.runId;
3555
+ const projectDir = args.projectDir;
3556
+ const taskRef = args.taskRef;
3557
+ const exeBasename = typeof args.exeBasename === 'string' && args.exeBasename.length > 0
3558
+ ? args.exeBasename
3559
+ : DEFAULT_TAGGED_PROCESS_EXE_BASENAME;
3560
+
3561
+ // 1. Successor Gate 2 / revision approval evidence — fail closed FIRST,
3562
+ // before reading anything about the original run or the successor's plan
3563
+ // files.
3564
+ const successorFeatureDir = _deriveFeatureDir(args.feature);
3565
+ const successorPrefix = _derivePrefix(successorFeatureDir);
3566
+ if (!successorPrefix) {
3567
+ throw _err(
3568
+ 'SUCCESSOR_NOT_APPROVED',
3569
+ 'replan: could not derive a PREFIX (e.g. "FTR-018") from successor feature directory "' +
3570
+ successorFeatureDir + '" — refusing to treat an unidentifiable feature as approved'
3571
+ );
3572
+ }
3573
+ const successorApprovalsPath = path.join(successorFeatureDir, successorPrefix + '-Approvals.md');
3574
+ let successorApprovalsText;
3575
+ try {
3576
+ successorApprovalsText = fs.readFileSync(successorApprovalsPath, 'utf8');
3577
+ } catch (err) {
3578
+ throw _err(
3579
+ 'SUCCESSOR_NOT_APPROVED',
3580
+ 'replan: successor Approvals.md not found or unreadable at "' + successorApprovalsPath +
3581
+ '" — refusing to replan onto an unapproved successor: ' + err.message
3582
+ );
3583
+ }
3584
+ if (!_hasGate2Approval(successorApprovalsText)) {
3585
+ throw _err(
3586
+ 'SUCCESSOR_NOT_APPROVED',
3587
+ 'replan: successor Approvals.md at "' + successorApprovalsPath +
3588
+ '" has no "## Gate 2" approval section — refusing to replan onto an unapproved successor plan'
3589
+ );
3590
+ }
3591
+
3592
+ // 2. Successor plan snapshot — real Work-Breakdown.md/.csv on disk, same
3593
+ // parser pipeline every other run uses (plan.createPlanSnapshot). Its own
3594
+ // PLAN_PARSE_ERROR propagates unmodified for a malformed successor plan.
3595
+ const successorMdPath = path.join(successorFeatureDir, successorPrefix + '-Work-Breakdown.md');
3596
+ const successorCsvPath = path.join(successorFeatureDir, successorPrefix + '-Work-Breakdown.csv');
3597
+ let successorMd;
3598
+ let successorCsv;
3599
+ try {
3600
+ successorMd = fs.readFileSync(successorMdPath, 'utf8');
3601
+ successorCsv = fs.readFileSync(successorCsvPath, 'utf8');
3602
+ } catch (err) {
3603
+ throw _err(
3604
+ 'SUCCESSOR_PLAN_NOT_FOUND',
3605
+ 'replan: successor Work Breakdown MD/CSV not found at "' + successorMdPath + '" / "' +
3606
+ successorCsvPath + '": ' + err.message
3607
+ );
3608
+ }
3609
+ const successorSnapshot = plan.createPlanSnapshot(successorMd, successorCsv);
3610
+ const successorTaskIds = new Set(successorSnapshot.tasks.map(function (t) { return t.id; }));
3611
+
3612
+ // 3. Load the ORIGINAL run's state — STATE_NOT_FOUND/STATE_CORRUPTED
3613
+ // propagate unmodified.
3614
+ const state = store.readState(executionRoot, runId);
3615
+
3616
+ // 4. Validate the caller-supplied taskMapping against BOTH sides before
3617
+ // touching anything — fail closed on the first unknown ID found.
3618
+ args.taskMapping.forEach(function (entry) {
3619
+ if (!Object.prototype.hasOwnProperty.call(state.tasks, entry.oldTaskId)) {
3620
+ throw _err(
3621
+ 'REPLAN_UNKNOWN_TASK_MAPPING',
3622
+ 'replan: taskMapping references oldTaskId "' + entry.oldTaskId +
3623
+ '", which does not exist in original run "' + runId + '"'
3624
+ );
3625
+ }
3626
+ if (!successorTaskIds.has(entry.newTaskId)) {
3627
+ throw _err(
3628
+ 'REPLAN_UNKNOWN_TASK_MAPPING',
3629
+ 'replan: taskMapping references newTaskId "' + entry.newTaskId +
3630
+ '", which does not exist in the successor plan snapshot (' + successorMdPath + ')'
3631
+ );
3632
+ }
3633
+ });
3634
+
3635
+ // 5. Carry-completion eligibility per mapped pair — read-only, never
3636
+ // touches the old task's own evidence either way regardless of outcome.
3637
+ const taskMappingResult = args.taskMapping.map(function (entry) {
3638
+ const oldTask = state.tasks[entry.oldTaskId];
3639
+ let carriesCompletion = false;
3640
+ if (oldTask.status === 'checkpointed' || oldTask.status === 'integrated') {
3641
+ const lastAttempt = Array.isArray(oldTask.attempts) && oldTask.attempts.length > 0
3642
+ ? oldTask.attempts[oldTask.attempts.length - 1]
3643
+ : null;
3644
+ if (lastAttempt && typeof lastAttempt.originalSha === 'string' && SHA_PATTERN.test(lastAttempt.originalSha)) {
3645
+ try {
3646
+ carriesCompletion = _isCommitReachableOnRef(projectDir, lastAttempt.originalSha, taskRef);
3647
+ } catch (_reachErr) {
3648
+ carriesCompletion = false; // never guess — an unverifiable check is never eligible
3649
+ }
3650
+ }
3651
+ }
3652
+ return { oldTaskId: entry.oldTaskId, newTaskId: entry.newTaskId, carriesCompletion: carriesCompletion };
3653
+ });
3654
+
3655
+ // 6. "No live workers" gate over EVERY task/attempt in the ORIGINAL run
3656
+ // (not just the mapped ones) — reuses US-06-TASK-BE-02's own
3657
+ // _findLiveTaggedProcess exactly like reconcile()'s own worker-liveness
3658
+ // classification. 'unknown' blocks exactly like 'found-alive' — never
3659
+ // assume dead.
3660
+ Object.keys(state.tasks).sort().forEach(function (taskId) {
3661
+ const task = state.tasks[taskId];
3662
+ (Array.isArray(task.attempts) ? task.attempts : []).forEach(function (attempt) {
3663
+ if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) {
3664
+ return; // nothing recorded to check
3665
+ }
3666
+ const liveness = _findLiveTaggedProcess(attempt.processIdentity, exeBasename);
3667
+ if (liveness.status !== 'confirmed-not-found') {
3668
+ throw _err(
3669
+ 'LIVE_WORKER_BLOCKS_REPLAN',
3670
+ 'replan: refusing to supersede run "' + runId + '" — task "' + taskId + '" attempt ' +
3671
+ attempt.number + '\'s worker liveness is "' + liveness.status + '" (' + liveness.reason +
3672
+ '); a run may only be superseded once every worker is confirmed not running'
3673
+ );
3674
+ }
3675
+ });
3676
+ });
3677
+
3678
+ // 7. Persist the durable old->new mapping record BEFORE changing run
3679
+ // status, so a conflicting replay (INTENT_CONFLICT) never leaves the
3680
+ // original run superseded without its mapping record durably alongside it.
3681
+ // Deliberately no wall-clock timestamp field here (unlike, say, a receipt's
3682
+ // startedAt/endedAt): store.js's _writeImmutableRecord treats a replay as
3683
+ // idempotent only when its content is byte-for-byte identical to what is
3684
+ // already recorded (see git.js's own persistCheckpointIntent, which
3685
+ // likewise carries no timestamp in its intent payload) — a fresh
3686
+ // Date().toISOString() on every call would make even a genuine, intentional
3687
+ // replay of the exact same replan decision always look like a conflicting
3688
+ // second decision.
3689
+ const replanRecord = {
3690
+ protocolVersion: store.PROTOCOL_VERSION,
3691
+ originalRunId: runId,
3692
+ successorFeaturePath: args.feature,
3693
+ successorPlanDigest: successorSnapshot.planDigest,
3694
+ taskMapping: taskMappingResult,
3695
+ };
3696
+ store.writeIntent(executionRoot, runId, 'replan', replanRecord);
3697
+
3698
+ // 8. Only now: mark the original run superseded. Task/attempt evidence is
3699
+ // never touched by this function, in either direction.
3700
+ const supersededState = state.setRunStatus('superseded');
3701
+ store.writeState(executionRoot, runId, supersededState);
3702
+
3703
+ return {
3704
+ protocolVersion: store.PROTOCOL_VERSION,
3705
+ originalRunId: runId,
3706
+ successorPlanDigest: successorSnapshot.planDigest,
3707
+ taskMapping: taskMappingResult,
3708
+ };
3709
+ }
3710
+
3711
+ module.exports = {
3712
+ execute,
3713
+ status,
3714
+ diagnose,
3715
+ stop,
3716
+ reconcile,
3717
+ resume,
3718
+ replan,
3719
+ dispatchTaskAttempt,
3720
+ runVerifications,
3721
+ runReview,
3722
+ finalizeTaskCheckpoint,
3723
+ evaluateTaskEvidence,
3724
+ createTaskWorktree,
3725
+ createSlotPool,
3726
+ DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
3727
+ _buildProcessTag,
3728
+ _findLiveTaggedProcess,
3729
+ };