@dtlabs/ai-toolkit 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/bin/cli.js +851 -9
  3. package/docs/task-executor-bootstrap.md +195 -0
  4. package/docs/task-executor-cli.md +319 -0
  5. package/docs/workflow-control-plane.md +95 -0
  6. package/lib/agent-registry.js +1018 -0
  7. package/lib/execution-ledger.js +283 -7
  8. package/lib/task-executor/claude-process.js +563 -0
  9. package/lib/task-executor/git.js +1362 -0
  10. package/lib/task-executor/index.js +3729 -0
  11. package/lib/task-executor/ownership.js +590 -0
  12. package/lib/task-executor/plan.js +725 -0
  13. package/lib/task-executor/store.js +1164 -0
  14. package/lib/workflow-artifacts.js +102 -0
  15. package/lib/workflow-control.js +219 -0
  16. package/lib/workflow-runner.js +39 -0
  17. package/package.json +1 -1
  18. package/src/claude/agents/{concurrency-safety-assessment.md → gaia-concurrency-safety-assessment.md} +1 -1
  19. package/src/claude/agents/{define-feature.md → gaia-define-feature.md} +1 -1
  20. package/src/claude/agents/{dependency-injection-refactoring.md → gaia-dependency-injection-refactoring.md} +1 -1
  21. package/src/claude/agents/{dependency-supply-chain-security.md → gaia-dependency-supply-chain-security.md} +1 -1
  22. package/src/claude/agents/{developer-backend.md → gaia-developer-backend.md} +1 -1
  23. package/src/claude/agents/{developer-frontend.md → gaia-developer-frontend.md} +1 -1
  24. package/src/claude/agents/{developer-testing.md → gaia-developer-testing.md} +1 -1
  25. package/src/claude/agents/{domain-model-refactoring.md → gaia-domain-model-refactoring.md} +1 -1
  26. package/src/claude/agents/{generate-requirements.md → gaia-generate-requirements.md} +1 -1
  27. package/src/claude/agents/{generate-tech-spec.md → gaia-generate-tech-spec.md} +1 -1
  28. package/src/claude/agents/{generate-work-breakdown.md → gaia-generate-work-breakdown.md} +5 -5
  29. package/src/claude/agents/{generic-software-assessment.md → gaia-generic-software-assessment.md} +1 -1
  30. package/src/claude/agents/{god-class-decomposition.md → gaia-god-class-decomposition.md} +1 -1
  31. package/src/claude/agents/{init-agents-md.md → gaia-init-agents-md.md} +1 -1
  32. package/src/claude/agents/{install-toolkit.md → gaia-install-toolkit.md} +1 -1
  33. package/src/claude/agents/{intervention-documentation-standard.md → gaia-intervention-documentation-standard.md} +10 -10
  34. package/src/claude/agents/{layered-architecture-assessment.md → gaia-layered-architecture-assessment.md} +1 -1
  35. package/src/claude/agents/{review-solution.md → gaia-review-solution.md} +1 -1
  36. package/src/claude/agents/{security-hardening.md → gaia-security-hardening.md} +1 -1
  37. package/src/claude/agents/{validate-feature-docs.md → gaia-validate-feature-docs.md} +2 -2
  38. package/src/claude/agents/{validate-work-breakdown-semantic.md → gaia-validate-work-breakdown-semantic.md} +1 -1
  39. package/src/claude/commands/next-task.md +4 -4
  40. package/src/claude/skills/assess-codebase/SKILL.md +9 -103
  41. package/src/claude/skills/define-feature/SKILL.md +2 -2
  42. package/src/claude/skills/hi-gaia/SKILL.md +1 -1
  43. package/src/claude/skills/implement-feature/SKILL.md +131 -38
  44. package/src/claude/skills/init-agents/SKILL.md +2 -2
  45. package/src/claude/skills/install-toolkit/SKILL.md +2 -2
  46. package/src/claude/workflows/am-phase1.js +43 -294
  47. package/src/claude/workflows/am-phase2.js +10 -164
  48. package/src/claude/workflows/pm-phase1.js +45 -287
  49. package/src/claude/workflows/pm-phase2.js +51 -511
  50. package/src/claude/workflows/pm-phase3.js +5 -767
@@ -0,0 +1,1362 @@
1
+ 'use strict';
2
+
3
+ // Git checkpoint/integration module (FTR-018).
4
+ //
5
+ // Implemented here: checkpoint intent persistence (US-05-TASK-BE-01),
6
+ // controlled staging with path enumeration (US-05-TASK-BE-02), commit
7
+ // creation with task trailers (US-05-TASK-BE-03), and serial, dispatch-order
8
+ // integration of N>1 technical-branch commits onto the feature branch
9
+ // (US-08-TASK-BE-03, integrateAttempt — see its own file section below).
10
+ // SHA registration outside the tracked worktree (US-05-TASK-BE-04) and
11
+ // original/integrated SHA field tracking (US-08-TASK-BE-04) are both
12
+ // implemented in store.js (registerCommitSHA / registerIntegratedSHA), not
13
+ // here — this module hands createTaskCommit's returned, re-inspected
14
+ // commitSha, and integrateAttempt's returned integratedSha, to those
15
+ // functions; it does not register either itself. See integrateAttempt's own
16
+ // doc comment for the exact scope boundary against registerIntegratedSHA.
17
+ // Still pending: commit/ledger reconciliation and task completion
18
+ // (US-05-TASK-BE-05 — actually landed separately in index.js's
19
+ // finalizeTaskCheckpoint). See FTR-018-Tech-Spec.md section 8 ("Checkpoint
20
+ // intent ... Persist before commit." / "Integration is serialized by
21
+ // dispatch sequence...").
22
+
23
+ const fs = require('fs');
24
+ const { spawnSync } = require('child_process');
25
+ const store = require('./store');
26
+
27
+ // ── persistCheckpointIntent (US-05-TASK-BE-01) ──────────────────────────────
28
+
29
+ const SHA_PATTERN = /^[0-9a-f]{40}$/i;
30
+ const CHANGE_TYPES = ['add', 'modify', 'delete'];
31
+
32
+ // Attempt stages this function may be called against: 'reviewed' is the real
33
+ // precondition (BR-09: "review persisted before checkpoint" — mirrors
34
+ // store.js's own persistVerificationReviewOutcome, which is the only writer
35
+ // that ever puts an attempt into 'reviewed'). 'checkpoint-prepared' is
36
+ // accepted too, but only as an idempotent replay of the exact same intent
37
+ // (see the alreadyRecorded check below) — never as a way to re-derive a
38
+ // second, different intent for the same attempt.
39
+ const ALLOWED_SOURCE_STAGES = ['reviewed', 'checkpoint-prepared'];
40
+
41
+ function _err(code, message) {
42
+ const e = new Error(message);
43
+ e.code = code;
44
+ return e;
45
+ }
46
+
47
+ function _fail(field, reason) {
48
+ throw _err(
49
+ 'CHECKPOINT_INTENT_VALIDATION_ERROR',
50
+ 'persistCheckpointIntent: ' + field + ' ' + reason
51
+ );
52
+ }
53
+
54
+ function _requireNonEmptyString(value, field) {
55
+ if (typeof value !== 'string' || value.length === 0) {
56
+ _fail(field, 'must be a non-empty string');
57
+ }
58
+ }
59
+
60
+ function _requirePositiveInt(value, field) {
61
+ if (!Number.isInteger(value) || value < 1) {
62
+ _fail(field, 'must be a positive integer');
63
+ }
64
+ }
65
+
66
+ function _requireSha(value, field) {
67
+ if (typeof value !== 'string' || !SHA_PATTERN.test(value)) {
68
+ _fail(field, 'must be a 40-character hex SHA-1 string');
69
+ }
70
+ }
71
+
72
+ // "exact changed path/object inventory ... must be an explicit enumerated
73
+ // list, not a wildcard" — enforced here so US-05-TASK-BE-02 (controlled
74
+ // staging) has a concrete, non-empty list of {path, changeType} entries to
75
+ // stage against and validate the resulting tree with, never a glob.
76
+ function _validateChangedPaths(changedPaths) {
77
+ if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
78
+ _fail('changedPaths', 'must be a non-empty array (explicit enumeration — never a wildcard)');
79
+ }
80
+ changedPaths.forEach(function (entry, index) {
81
+ const prefix = 'changedPaths[' + index + ']';
82
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
83
+ _fail(prefix, 'must be an object with "path" and "changeType"');
84
+ }
85
+ if (typeof entry.path !== 'string' || entry.path.length === 0) {
86
+ _fail(prefix + '.path', 'must be a non-empty string');
87
+ }
88
+ if (CHANGE_TYPES.indexOf(entry.changeType) === -1) {
89
+ _fail(prefix + '.changeType', 'must be one of: ' + CHANGE_TYPES.join(', '));
90
+ }
91
+ });
92
+ }
93
+
94
+ function _validateReceiptIdArray(value, field) {
95
+ if (!Array.isArray(value) || value.length === 0) {
96
+ _fail(field, 'must be a non-empty array of receipt ids');
97
+ }
98
+ value.forEach(function (id, index) {
99
+ if (typeof id !== 'string' || id.length === 0) {
100
+ _fail(field + '[' + index + ']', 'must be a non-empty string');
101
+ }
102
+ });
103
+ }
104
+
105
+ // Builds the exact commit trailer values (AI-Toolkit-Run/Task/Attempt/Plan)
106
+ // this checkpoint intends to use. Derived here from the already-validated
107
+ // intent fields — never accepted redundantly from the caller — so there is
108
+ // exactly one source of truth for trailer values and US-05-TASK-BE-03
109
+ // (commit creation) commits with EXACTLY what was persisted here, never
110
+ // improvised at commit time.
111
+ function _buildTrailers(intent) {
112
+ return {
113
+ 'AI-Toolkit-Run': String(intent.runId),
114
+ 'AI-Toolkit-Task': String(intent.taskId),
115
+ 'AI-Toolkit-Attempt': String(intent.attemptNumber),
116
+ 'AI-Toolkit-Plan': String(intent.planDigest),
117
+ };
118
+ }
119
+
120
+ // Validates every required field is present and well-formed. Throws
121
+ // CHECKPOINT_INTENT_VALIDATION_ERROR (fail closed) on the first problem
122
+ // found — called before any persistence is attempted, so a partial/
123
+ // incomplete intent is never written.
124
+ function _validateIntent(runId, intent) {
125
+ if (intent == null || typeof intent !== 'object' || Array.isArray(intent)) {
126
+ _fail('intent', 'must be an object');
127
+ }
128
+
129
+ _requireNonEmptyString(intent.featureId, 'featureId');
130
+ _requireNonEmptyString(intent.runId, 'runId');
131
+ if (intent.runId !== runId) {
132
+ _fail('runId', 'must match the runId argument ("' + runId + '"), got ' + JSON.stringify(intent.runId));
133
+ }
134
+ _requireNonEmptyString(intent.taskId, 'taskId');
135
+ _requirePositiveInt(intent.attemptNumber, 'attemptNumber');
136
+ _requireNonEmptyString(intent.planDigest, 'planDigest');
137
+ _requireNonEmptyString(intent.featureRef, 'featureRef');
138
+ _requireNonEmptyString(intent.taskRef, 'taskRef');
139
+ _requireSha(intent.parentSha, 'parentSha');
140
+ _requireSha(intent.expectedTreeSha, 'expectedTreeSha');
141
+ _validateChangedPaths(intent.changedPaths);
142
+ _validateReceiptIdArray(intent.verificationReceiptIds, 'verificationReceiptIds');
143
+ _requireNonEmptyString(intent.reviewReceiptId, 'reviewReceiptId');
144
+ _requireNonEmptyString(intent.outcomeReceiptId, 'outcomeReceiptId');
145
+ _requireNonEmptyString(intent.commitMessage, 'commitMessage');
146
+ }
147
+
148
+ // Persists the full pre-commit checkpoint intent for one task attempt:
149
+ // feature/run/task/attempt identifiers, planDigest (as produced by
150
+ // createPlanSnapshot, US-01-TASK-BE-04 — supplied by the caller, never
151
+ // recomputed here), featureRef/taskRef, the parent SHA this checkpoint is
152
+ // relative to, the expected full tree SHA, an explicit enumerated changed-
153
+ // path inventory, the review/verification receipt ids that already
154
+ // authorized this checkpoint (US-04-TASK-BE-01/03 — accepted and referenced,
155
+ // never recomputed or re-verified here), and the planned commit message plus
156
+ // the exact AI-Toolkit-Run/Task/Attempt/Plan trailer values.
157
+ //
158
+ // Persisted BEFORE any commit is attempted. This function never touches
159
+ // Git — no staging, no commit, no SHA computation: that is
160
+ // US-05-TASK-BE-02 (staging) and US-05-TASK-BE-03 (commit creation).
161
+ //
162
+ // Reuses store.js's writeIntent immutable-record primitive (INFRA-TASK-BE-01)
163
+ // for the actual persistence rather than inventing a new one. The intentId is
164
+ // deterministic: "<taskId>-attempt<attemptNumber>-checkpoint".
165
+ //
166
+ // Preconditions (fail closed, nothing partial is ever written):
167
+ // - every required intent field above must be present and well-formed, or
168
+ // CHECKPOINT_INTENT_VALIDATION_ERROR is thrown before writeIntent runs;
169
+ // - the target attempt must already be at stage 'reviewed' — the terminal
170
+ // success stage persistVerificationReviewOutcome writes — or
171
+ // CHECKPOINT_INTENT_STAGE_ERROR is thrown. A prior identical call that
172
+ // already advanced the attempt to 'checkpoint-prepared' is accepted as an
173
+ // idempotent replay (same intentId, same content); anything else at that
174
+ // stage is rejected rather than silently re-deriving a second intent.
175
+ //
176
+ // On success, the attempt's stage is advanced to 'checkpoint-prepared' and
177
+ // its intentIds list gains this intentId (both durably written via
178
+ // store.writeState) before this function returns.
179
+ function persistCheckpointIntent(executionRoot, runId, intent) {
180
+ _validateIntent(runId, intent);
181
+
182
+ const intentId = intent.taskId + '-attempt' + intent.attemptNumber + '-checkpoint';
183
+ const trailers = _buildTrailers(intent);
184
+
185
+ const record = {
186
+ featureId: intent.featureId,
187
+ runId: intent.runId,
188
+ taskId: intent.taskId,
189
+ attemptNumber: intent.attemptNumber,
190
+ planDigest: intent.planDigest,
191
+ featureRef: intent.featureRef,
192
+ taskRef: intent.taskRef,
193
+ parentSha: intent.parentSha,
194
+ expectedTreeSha: intent.expectedTreeSha,
195
+ changedPaths: intent.changedPaths,
196
+ verificationReceiptIds: intent.verificationReceiptIds,
197
+ reviewReceiptId: intent.reviewReceiptId,
198
+ outcomeReceiptId: intent.outcomeReceiptId,
199
+ commitMessage: intent.commitMessage,
200
+ trailers: trailers,
201
+ };
202
+
203
+ let state = store.readState(executionRoot, runId);
204
+ const task = state.tasks[intent.taskId];
205
+ if (!task) {
206
+ throw _err('STATE_TASK_NOT_FOUND', 'persistCheckpointIntent: task "' + intent.taskId + '" not found');
207
+ }
208
+ const attempt = task.attempts.find(function (a) { return a.number === intent.attemptNumber; });
209
+ if (!attempt) {
210
+ throw _err(
211
+ 'STATE_ATTEMPT_NOT_FOUND',
212
+ 'persistCheckpointIntent: attempt ' + intent.attemptNumber + ' not found for task "' + intent.taskId + '"'
213
+ );
214
+ }
215
+ if (ALLOWED_SOURCE_STAGES.indexOf(attempt.stage) === -1) {
216
+ throw _err(
217
+ 'CHECKPOINT_INTENT_STAGE_ERROR',
218
+ 'persistCheckpointIntent: attempt ' + intent.attemptNumber + ' of task "' + intent.taskId + '" is at stage "' +
219
+ attempt.stage + '"; a checkpoint intent may only be persisted once verification and review have both ' +
220
+ 'passed (expected stage "reviewed", or "checkpoint-prepared" for an idempotent replay)'
221
+ );
222
+ }
223
+
224
+ // Throws INTENT_CONFLICT if a prior intent exists under this id with
225
+ // different content — never silently overwrites a differing record.
226
+ const written = store.writeIntent(executionRoot, runId, intentId, record);
227
+
228
+ const existingIntentIds = Array.isArray(attempt.intentIds) ? attempt.intentIds : [];
229
+ const alreadyRecorded = attempt.stage === 'checkpoint-prepared' && existingIntentIds.indexOf(intentId) !== -1;
230
+ if (alreadyRecorded) {
231
+ return { intentId: intentId, intent: written }; // idempotent no-op — no generation bump for a repeated call
232
+ }
233
+
234
+ const mergedIntentIds = existingIntentIds.indexOf(intentId) === -1
235
+ ? existingIntentIds.concat([intentId])
236
+ : existingIntentIds;
237
+
238
+ state = state.updateAttempt(intent.taskId, intent.attemptNumber, {
239
+ stage: 'checkpoint-prepared',
240
+ intentIds: mergedIntentIds,
241
+ });
242
+ store.writeState(executionRoot, runId, state);
243
+
244
+ return { intentId: intentId, intent: written };
245
+ }
246
+
247
+ // ── stageTaskFiles (US-05-TASK-BE-02) ───────────────────────────────────────
248
+ //
249
+ // Stages ONLY the enumerated task-attributable paths into the Git index via
250
+ // argv-based `git add -- <path...>` (literal `--` separator, argv array, no
251
+ // shell, no globbing — Tech-Spec section 8: "Stage only enumerated paths
252
+ // using argv and literal path handling").
253
+ //
254
+ // Real git behavior was verified in an isolated tmp repo (never against this
255
+ // project's own working tree) before writing this, per the delivery
256
+ // constraint that this area must never be assumed:
257
+ // - a plain `git add -- <path>` DOES correctly stage a deletion when a
258
+ // previously tracked path is missing from the working tree — there is
259
+ // no need for a separate `git rm --cached` branch for changeType
260
+ // 'delete';
261
+ // - a path that does not match a tracked or existing file makes the WHOLE
262
+ // `git add` invocation fail atomically (exit 128, "did not match any
263
+ // files") without staging any of the other paths passed in the same
264
+ // call.
265
+ // Both were confirmed by direct experiment, not assumed from documentation.
266
+ //
267
+ // This function does NOT know or compare against the checkpoint intent's
268
+ // expectedTreeSha (that lives in the intent persisted by
269
+ // persistCheckpointIntent, US-05-TASK-BE-01) — it only stages, then reports
270
+ // the resulting tree SHA and the full list of currently-staged paths so the
271
+ // CALLER (US-05-TASK-BE-03, commit creation) can compare against the
272
+ // persisted intent. Keeping that comparison out of this function is
273
+ // deliberate: "stage exactly these paths, report what happened", nothing
274
+ // more.
275
+ //
276
+ // Two failure modes this function DOES own, because they can only be
277
+ // detected from inside the staging step itself:
278
+ // - STAGING_MISMATCH: an enumerated path did not end up staged the way its
279
+ // declared changeType implies (e.g. a 'delete' entry that is not staged
280
+ // as a deletion, or an 'add'/'modify' entry that ended up staged as a
281
+ // deletion instead, or a path that git add silently left untouched).
282
+ // - UNEXPECTED_INDEX_CHANGE: a `git status` snapshot taken immediately
283
+ // before staging is compared against one taken immediately after; any
284
+ // path OUTSIDE the enumerated list whose status differs is reported here
285
+ // rather than silently folded into the resulting tree. This is the
286
+ // "hooks may fail or change index" case the Tech-Spec warns about
287
+ // (section 8) surfacing at the staging step; commit-time hook mutation
288
+ // (post-commit tree/parent/trailer re-inspection) is a separate concern
289
+ // owned by US-05-TASK-BE-03.
290
+ // Neither failure mode resets, cleans, or discards anything — the index is
291
+ // left exactly as git produced it; only an error is thrown so the caller can
292
+ // decide what to do (Tech-Spec: "block on mismatch without discarding
293
+ // anything").
294
+
295
+ // fnLabel identifies the calling function in error messages (e.g.
296
+ // 'stageTaskFiles', 'createTaskCommit') — shared by every function in this
297
+ // module that spawns git rather than each duplicating its own spawn logic.
298
+ // opts is optional and merged into the spawnSync options (e.g. { input }
299
+ // to pipe a commit message to git commit-tree's stdin).
300
+ function _runGitSync(fnLabel, cwd, args, opts) {
301
+ const spawnOpts = Object.assign(
302
+ {
303
+ cwd: cwd,
304
+ shell: false, // never true: argv array + literal paths only, no shell interpolation/globbing
305
+ encoding: 'utf8',
306
+ windowsHide: true,
307
+ },
308
+ opts || {}
309
+ );
310
+ const res = spawnSync('git', args, spawnOpts);
311
+ if (res.error) {
312
+ throw _err(
313
+ 'GIT_SPAWN_FAILED',
314
+ fnLabel + ': failed to spawn "git ' + args.join(' ') + '": ' + res.error.message
315
+ );
316
+ }
317
+ return res;
318
+ }
319
+
320
+ function _runGitOrThrow(fnLabel, cwd, args, code, label, opts) {
321
+ const res = _runGitSync(fnLabel, cwd, args, opts);
322
+ if (res.status !== 0) {
323
+ throw _err(
324
+ code,
325
+ fnLabel + ': ' + label + ' failed ("git ' + args.join(' ') + '", exit ' + res.status + '): ' +
326
+ (res.stderr || '').trim()
327
+ );
328
+ }
329
+ return res;
330
+ }
331
+
332
+ // Parses `git status --porcelain=v1 -z --no-renames --untracked-files=all`
333
+ // output into a Map<path, "XY"> (the two raw porcelain status characters).
334
+ // --no-renames and -z together guarantee exactly one NUL-terminated "XY path"
335
+ // record per entry — no rename-pair ambiguity, no quoting/escaping surprises
336
+ // for paths containing spaces or non-ASCII characters.
337
+ function _statusSnapshot(cwd) {
338
+ const res = _runGitOrThrow(
339
+ 'stageTaskFiles',
340
+ cwd,
341
+ ['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all'],
342
+ 'GIT_STATUS_FAILED',
343
+ 'capturing status snapshot'
344
+ );
345
+ const map = new Map();
346
+ res.stdout.split('\0').forEach(function (record) {
347
+ if (record.length === 0) return;
348
+ const statusCode = record.slice(0, 2);
349
+ const filePath = record.slice(3);
350
+ map.set(filePath, statusCode);
351
+ });
352
+ return map;
353
+ }
354
+
355
+ function _stagingFail(field, reason) {
356
+ throw _err('STAGING_VALIDATION_ERROR', 'stageTaskFiles: ' + field + ' ' + reason);
357
+ }
358
+
359
+ function _validateStagingInputs(cwd, changedPaths) {
360
+ if (typeof cwd !== 'string' || cwd.length === 0) {
361
+ _stagingFail('cwd', 'must be a non-empty string');
362
+ }
363
+ let stat;
364
+ try {
365
+ stat = fs.statSync(cwd);
366
+ } catch (err) {
367
+ _stagingFail('cwd', 'does not exist (' + err.code + ')');
368
+ }
369
+ if (!stat.isDirectory()) {
370
+ _stagingFail('cwd', 'must be a directory');
371
+ }
372
+
373
+ if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
374
+ _stagingFail('changedPaths', 'must be a non-empty array (explicit enumeration — never a wildcard)');
375
+ }
376
+ const seen = new Set();
377
+ changedPaths.forEach(function (entry, index) {
378
+ const prefix = 'changedPaths[' + index + ']';
379
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
380
+ _stagingFail(prefix, 'must be an object with "path" and "changeType"');
381
+ }
382
+ if (typeof entry.path !== 'string' || entry.path.length === 0) {
383
+ _stagingFail(prefix + '.path', 'must be a non-empty string');
384
+ }
385
+ if (CHANGE_TYPES.indexOf(entry.changeType) === -1) {
386
+ _stagingFail(prefix + '.changeType', 'must be one of: ' + CHANGE_TYPES.join(', '));
387
+ }
388
+ if (seen.has(entry.path)) {
389
+ _stagingFail(prefix + '.path', 'is duplicated in changedPaths ("' + entry.path + '")');
390
+ }
391
+ seen.add(entry.path);
392
+ });
393
+ }
394
+
395
+ // Expected porcelain index-status letter (X, the first character) for a
396
+ // declared changeType: 'delete' must land as 'D'; 'add'/'modify' must land
397
+ // as anything staged EXCEPT 'D' (or the "not staged"/"untracked" markers ' '
398
+ // and '?'). Exact 'A' vs 'M' is deliberately not distinguished further — a
399
+ // caller-declared 'add' landing as 'M' (or vice versa) is not itself treated
400
+ // as a sign of the wrong path; only the delete/non-delete distinction is
401
+ // authoritative here.
402
+ function _indexStatusMatchesChangeType(indexChar, changeType) {
403
+ if (changeType === 'delete') {
404
+ return indexChar === 'D';
405
+ }
406
+ return indexChar !== 'D' && indexChar !== ' ' && indexChar !== '?';
407
+ }
408
+
409
+ /**
410
+ * Stages exactly the enumerated task-attributable paths and reports the
411
+ * resulting tree. Does not commit, does not compare against a persisted
412
+ * checkpoint intent's expectedTreeSha (that is the caller's job) — see the
413
+ * file comment above for the full design.
414
+ *
415
+ * @param {string} cwd - absolute path to the Git worktree to stage in.
416
+ * @param {Array<{path: string, changeType: 'add'|'modify'|'delete'}>} changedPaths
417
+ * - explicit enumerated inventory (same shape persisted by
418
+ * persistCheckpointIntent) — never a wildcard, must be non-empty.
419
+ * @returns {{ treeSha: string, stagedPaths: string[] }} the SHA of the tree
420
+ * `git write-tree` produces against the resulting index, and the full list
421
+ * of paths git reports as currently staged (`git diff --cached --name-only`).
422
+ * @throws {Error} STAGING_VALIDATION_ERROR for malformed input;
423
+ * GIT_SPAWN_FAILED if git itself cannot be spawned; GIT_ADD_FAILED,
424
+ * GIT_STATUS_FAILED, GIT_WRITE_TREE_FAILED, GIT_DIFF_FAILED for a non-zero
425
+ * exit from the corresponding git command; STAGING_MISMATCH when an
426
+ * enumerated path did not end up staged consistently with its declared
427
+ * changeType; UNEXPECTED_INDEX_CHANGE when staging altered the status of a
428
+ * path outside the enumerated list (hook/external mutation).
429
+ */
430
+ function stageTaskFiles(cwd, changedPaths) {
431
+ _validateStagingInputs(cwd, changedPaths);
432
+
433
+ const enumeratedPaths = changedPaths.map(function (e) { return e.path; });
434
+ const enumeratedSet = new Set(enumeratedPaths);
435
+
436
+ const before = _statusSnapshot(cwd);
437
+
438
+ _runGitOrThrow('stageTaskFiles', cwd, ['add', '--'].concat(enumeratedPaths), 'GIT_ADD_FAILED', 'staging enumerated paths');
439
+
440
+ const after = _statusSnapshot(cwd);
441
+
442
+ // UNEXPECTED_INDEX_CHANGE: any path outside the enumerated list whose
443
+ // status differs between the before/after snapshots — covers a path that
444
+ // newly appears, disappears, or changes status code as a side effect of
445
+ // this staging call. Pre-existing dirty state that is unchanged by our
446
+ // call (same status before and after) is out of scope here.
447
+ const unexpected = [];
448
+ const allPaths = new Set(before.keys());
449
+ after.forEach(function (_statusCode, p) { allPaths.add(p); });
450
+ allPaths.forEach(function (p) {
451
+ if (enumeratedSet.has(p)) return;
452
+ if (before.get(p) !== after.get(p)) {
453
+ unexpected.push({ path: p, before: before.get(p) || null, after: after.get(p) || null });
454
+ }
455
+ });
456
+ if (unexpected.length > 0) {
457
+ throw _err(
458
+ 'UNEXPECTED_INDEX_CHANGE',
459
+ 'stageTaskFiles: staging changed ' + unexpected.length + ' path(s) outside the enumerated changedPaths ' +
460
+ 'list: ' + unexpected.map(function (u) { return u.path + ' (' + (u.before || '--') + ' -> ' + (u.after || '--') + ')'; }).join(', ') +
461
+ ' — index left as-is; nothing discarded'
462
+ );
463
+ }
464
+
465
+ // STAGING_MISMATCH: every enumerated path must actually be staged, and its
466
+ // staged index status must be consistent with its declared changeType.
467
+ const mismatches = [];
468
+ changedPaths.forEach(function (entry) {
469
+ const statusCode = after.get(entry.path);
470
+ if (!statusCode) {
471
+ mismatches.push(entry.path + ': not staged at all after "git add"');
472
+ return;
473
+ }
474
+ const indexChar = statusCode.charAt(0);
475
+ if (!_indexStatusMatchesChangeType(indexChar, entry.changeType)) {
476
+ mismatches.push(
477
+ entry.path + ': declared changeType "' + entry.changeType + '" but staged index status is "' + indexChar + '"'
478
+ );
479
+ }
480
+ });
481
+ if (mismatches.length > 0) {
482
+ throw _err(
483
+ 'STAGING_MISMATCH',
484
+ 'stageTaskFiles: staged result does not match declared changedPaths intent: ' + mismatches.join('; ') +
485
+ ' — index left as-is; nothing discarded'
486
+ );
487
+ }
488
+
489
+ const treeRes = _runGitOrThrow('stageTaskFiles', cwd, ['write-tree'], 'GIT_WRITE_TREE_FAILED', 'computing resulting tree');
490
+ const treeSha = treeRes.stdout.trim();
491
+
492
+ const stagedRes = _runGitOrThrow(
493
+ 'stageTaskFiles',
494
+ cwd,
495
+ ['diff', '--cached', '--name-only', '-z', '--no-renames'],
496
+ 'GIT_DIFF_FAILED',
497
+ 'listing staged paths'
498
+ );
499
+ const stagedPaths = stagedRes.stdout.split('\0').filter(function (p) { return p.length > 0; });
500
+
501
+ return { treeSha: treeSha, stagedPaths: stagedPaths };
502
+ }
503
+
504
+ // ── detectChangedPaths (US-07-TASK-BE-01) ───────────────────────────────────
505
+ //
506
+ // Detects which files an implementation agent actually changed in the
507
+ // worktree, producing the {path, changeType} inventory persistCheckpointIntent
508
+ // and stageTaskFiles both already require as an explicit, non-wildcard input
509
+ // (see their own comments above). This function has NO OTHER RESPONSIBILITY —
510
+ // no staging, no intent-building, only detection/reporting. The run loop
511
+ // (lib/task-executor/index.js's execute(), US-07-TASK-BE-01) calls it exactly
512
+ // once per attempt, immediately after the implementation agent has run and
513
+ // BEFORE any staging happens, then reuses the same result both for the review
514
+ // diff and for the later checkpoint staging call — so this function must
515
+ // never mutate the index or working tree, only read `git status`.
516
+ //
517
+ // Reuses stageTaskFiles's own proven `git status --porcelain=v1 -z
518
+ // --no-renames --untracked-files=all` invocation (see _statusSnapshot above)
519
+ // rather than diverging with a second parsing strategy — same flags, same
520
+ // NUL-terminated "XY path" record shape, same spawn discipline (shell:false,
521
+ // argv array, windowsHide).
522
+ //
523
+ // Status-code -> changeType mapping (XY: X = index/staged column, Y =
524
+ // worktree column; --no-renames guarantees no rename-pair ambiguity — a
525
+ // rename shows up as a separate 'D' record for the old path and a separate
526
+ // untracked/'A' record for the new path, which is the expected, documented
527
+ // behavior per the Work Breakdown outcome, not a bug):
528
+ // '??' -> 'add' (untracked)
529
+ // either column is 'D' -> 'delete'
530
+ // either column is 'A' -> 'add' (newly staged / intent-to-add)
531
+ // anything else (M, R, C, U, ...) -> 'modify' (the documented default for
532
+ // any other porcelain code this function does not special-case above,
533
+ // rather than silently guessing per call site)
534
+ function detectChangedPaths(cwd) {
535
+ if (typeof cwd !== 'string' || cwd.length === 0) {
536
+ throw _err('DETECT_CHANGED_PATHS_VALIDATION_ERROR', 'detectChangedPaths: cwd must be a non-empty string');
537
+ }
538
+ let stat;
539
+ try {
540
+ stat = fs.statSync(cwd);
541
+ } catch (err) {
542
+ throw _err('DETECT_CHANGED_PATHS_VALIDATION_ERROR', 'detectChangedPaths: cwd "' + cwd + '" does not exist (' + err.code + ')');
543
+ }
544
+ if (!stat.isDirectory()) {
545
+ throw _err('DETECT_CHANGED_PATHS_VALIDATION_ERROR', 'detectChangedPaths: cwd "' + cwd + '" must be a directory');
546
+ }
547
+
548
+ const res = _runGitOrThrow(
549
+ 'detectChangedPaths',
550
+ cwd,
551
+ ['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all'],
552
+ 'GIT_STATUS_FAILED',
553
+ 'capturing status snapshot'
554
+ );
555
+
556
+ const changedPaths = [];
557
+ res.stdout.split('\0').forEach(function (record) {
558
+ if (record.length === 0) return;
559
+ const statusCode = record.slice(0, 2);
560
+ const filePath = record.slice(3);
561
+ const indexChar = statusCode.charAt(0);
562
+ const worktreeChar = statusCode.charAt(1);
563
+
564
+ let changeType;
565
+ if (statusCode === '??') {
566
+ changeType = 'add';
567
+ } else if (indexChar === 'D' || worktreeChar === 'D') {
568
+ changeType = 'delete';
569
+ } else if (indexChar === 'A' || worktreeChar === 'A') {
570
+ changeType = 'add';
571
+ } else {
572
+ changeType = 'modify';
573
+ }
574
+
575
+ changedPaths.push({ path: filePath, changeType: changeType });
576
+ });
577
+
578
+ return changedPaths;
579
+ }
580
+
581
+ // ── createTaskCommit (US-05-TASK-BE-03) ─────────────────────────────────────
582
+ //
583
+ // Creates the task commit for an already-persisted checkpoint intent
584
+ // (persistCheckpointIntent, US-05-TASK-BE-01) and an already-staged tree
585
+ // (stageTaskFiles, US-05-TASK-BE-02), enforcing every guard the Tech-Spec
586
+ // requires (section 8) BEFORE anything irreversible happens, then commits
587
+ // with `git commit-tree` rather than `git commit`.
588
+ //
589
+ // Why commit-tree and not `git commit`: `git commit` re-derives the tree to
590
+ // commit from the live index at the moment it runs. We have already
591
+ // verified stagingResult.treeSha against the checkpoint intent's
592
+ // expectedTreeSha (see TREE_MISMATCH below) — using `git commit` would
593
+ // throw that verification away and re-read the index a second time,
594
+ // reopening exactly the index-drift race (something touches the index
595
+ // between our verification and the commit) that verifying the tree was
596
+ // meant to close. `git commit-tree <tree> -p <parent>` takes both as
597
+ // explicit SHA arguments, so the object committed is provably the object we
598
+ // already verified — no re-read, no window.
599
+ //
600
+ // Hook-detection note (Tech-Spec: "Hooks may fail or change index:
601
+ // reinspect tree/parent/trailers after commit, block on mismatch without
602
+ // discarding anything"): `git commit-tree` never runs commit hooks
603
+ // (pre-commit, commit-msg, post-commit) at all — those only fire for
604
+ // `git commit`. So the specific failure mode the Tech-Spec is guarding
605
+ // against (a hook mutating the index after we verified it, so the commit
606
+ // silently contains something other than what was reviewed) is structurally
607
+ // impossible here, not just handled: there is no index read at commit time
608
+ // for a hook to have mutated. The post-commit reinspection below is still
609
+ // implemented and still meaningful, but for a narrower reason than hook
610
+ // interference: (a) it closes the remaining real race, which is a *ref*
611
+ // race — something else moving intent.taskRef between our PARENT_MISMATCH
612
+ // check and our own update-ref call — closed with a compare-and-swap
613
+ // update-ref plus a post-hoc re-read of what the ref now points at and what
614
+ // that commit object actually contains; and (b) it defends against a bug in
615
+ // this function's own message/trailer construction by re-parsing the exact
616
+ // bytes git stored and comparing them back to intent, independently of the
617
+ // code path that built them.
618
+ //
619
+ // The ref is only ever advanced via compare-and-swap:
620
+ // git update-ref <taskRef> <newSha> <parentSha>
621
+ // which git fails atomically if taskRef does not currently point at
622
+ // parentSha. That, combined with the pre-commit PARENT_MISMATCH check, is
623
+ // what proves "ancestry verified on the correct branch" (AC-05 / Work
624
+ // Breakdown outcome): the new commit's sole parent is exactly the SHA
625
+ // taskRef pointed at immediately before we moved it, and no other commit
626
+ // could have landed on taskRef in between.
627
+ //
628
+ // Assumes intent.taskRef already exists and currently resolves to some
629
+ // commit (normally intent.parentSha) — establishing the ref for a brand-new
630
+ // task/attempt is out of scope here; see US-05-TASK-BE-01's checkpoint
631
+ // intent and the Tech-Spec's N=1/N>1 branch model (section 8).
632
+
633
+ const TRAILER_KEYS = ['AI-Toolkit-Run', 'AI-Toolkit-Task', 'AI-Toolkit-Attempt', 'AI-Toolkit-Plan'];
634
+
635
+ function _commitFail(field, reason) {
636
+ throw _err('COMMIT_VALIDATION_ERROR', 'createTaskCommit: ' + field + ' ' + reason);
637
+ }
638
+
639
+ function _validateCommitInputs(cwd, intent, stagingResult) {
640
+ if (typeof cwd !== 'string' || cwd.length === 0) {
641
+ _commitFail('cwd', 'must be a non-empty string');
642
+ }
643
+ let stat;
644
+ try {
645
+ stat = fs.statSync(cwd);
646
+ } catch (err) {
647
+ _commitFail('cwd', 'does not exist (' + err.code + ')');
648
+ }
649
+ if (!stat.isDirectory()) {
650
+ _commitFail('cwd', 'must be a directory');
651
+ }
652
+
653
+ if (intent == null || typeof intent !== 'object' || Array.isArray(intent)) {
654
+ _commitFail('intent', 'must be an object');
655
+ }
656
+ if (typeof intent.taskRef !== 'string' || intent.taskRef.length === 0) {
657
+ _commitFail('intent.taskRef', 'must be a non-empty string');
658
+ }
659
+ if (typeof intent.parentSha !== 'string' || !SHA_PATTERN.test(intent.parentSha)) {
660
+ _commitFail('intent.parentSha', 'must be a 40-character hex SHA-1 string');
661
+ }
662
+ if (typeof intent.expectedTreeSha !== 'string' || !SHA_PATTERN.test(intent.expectedTreeSha)) {
663
+ _commitFail('intent.expectedTreeSha', 'must be a 40-character hex SHA-1 string');
664
+ }
665
+ if (typeof intent.commitMessage !== 'string' || intent.commitMessage.length === 0) {
666
+ _commitFail('intent.commitMessage', 'must be a non-empty string');
667
+ }
668
+ if (intent.trailers == null || typeof intent.trailers !== 'object' || Array.isArray(intent.trailers)) {
669
+ _commitFail('intent.trailers', 'must be an object with AI-Toolkit-Run/Task/Attempt/Plan');
670
+ }
671
+ TRAILER_KEYS.forEach(function (key) {
672
+ if (typeof intent.trailers[key] !== 'string' || intent.trailers[key].length === 0) {
673
+ _commitFail('intent.trailers["' + key + '"]', 'must be a non-empty string');
674
+ }
675
+ });
676
+
677
+ if (stagingResult == null || typeof stagingResult !== 'object' || Array.isArray(stagingResult)) {
678
+ _commitFail('stagingResult', 'must be an object');
679
+ }
680
+ if (typeof stagingResult.treeSha !== 'string' || !SHA_PATTERN.test(stagingResult.treeSha)) {
681
+ _commitFail('stagingResult.treeSha', 'must be a 40-character hex SHA-1 string');
682
+ }
683
+ }
684
+
685
+ // Appends the four trailers in fixed key order as a standard git trailer
686
+ // block: the (trailing-whitespace-trimmed) message, a blank line, then one
687
+ // "Key: value" line per trailer.
688
+ function _buildCommitMessageWithTrailers(commitMessage, trailers) {
689
+ const base = commitMessage.replace(/\s+$/, '');
690
+ const trailerBlock = TRAILER_KEYS.map(function (key) {
691
+ return key + ': ' + trailers[key];
692
+ }).join('\n');
693
+ return base + '\n\n' + trailerBlock + '\n';
694
+ }
695
+
696
+ // Parses `git cat-file -p <commitSha>` output into { tree, parents, message }.
697
+ function _parseCommitObject(raw) {
698
+ const lines = raw.split('\n');
699
+ let tree = null;
700
+ const parents = [];
701
+ let i = 0;
702
+ for (; i < lines.length; i += 1) {
703
+ const line = lines[i];
704
+ if (line === '') {
705
+ i += 1;
706
+ break;
707
+ }
708
+ if (line.indexOf('tree ') === 0) {
709
+ tree = line.slice(5).trim();
710
+ } else if (line.indexOf('parent ') === 0) {
711
+ parents.push(line.slice(7).trim());
712
+ }
713
+ }
714
+ return { tree: tree, parents: parents, message: lines.slice(i).join('\n') };
715
+ }
716
+
717
+ // Reads the trailing "Key: value" paragraph back out of a commit message
718
+ // (the mirror image of _buildCommitMessageWithTrailers) for post-commit
719
+ // reinspection.
720
+ function _extractTrailingTrailers(message) {
721
+ const trimmed = message.replace(/\s+$/, '');
722
+ const paragraphs = trimmed.split(/\n\n+/);
723
+ const lastParagraph = paragraphs[paragraphs.length - 1] || '';
724
+ const map = {};
725
+ lastParagraph.split('\n').forEach(function (line) {
726
+ const idx = line.indexOf(': ');
727
+ if (idx === -1) return;
728
+ map[line.slice(0, idx)] = line.slice(idx + 2);
729
+ });
730
+ return map;
731
+ }
732
+
733
+ /**
734
+ * @param {string} cwd - absolute path to the Git worktree/repo to commit in.
735
+ * @param {object} intent - the checkpoint-intent record shape produced by
736
+ * persistCheckpointIntent: parentSha, expectedTreeSha, commitMessage,
737
+ * trailers { AI-Toolkit-Run/Task/Attempt/Plan }, taskRef, ... — read back
738
+ * from store or the direct return value of persistCheckpointIntent, never
739
+ * re-derived here.
740
+ * @param {{treeSha: string, stagedPaths: string[]}} stagingResult - the
741
+ * return value of stageTaskFiles for this same attempt.
742
+ * @returns {{commitSha: string, treeSha: string, parentSha: string}}
743
+ * @throws {Error} COMMIT_VALIDATION_ERROR for malformed input;
744
+ * TREE_MISMATCH if the staged tree does not match the intent's
745
+ * expectedTreeSha; PARENT_MISMATCH if intent.taskRef does not currently
746
+ * resolve to intent.parentSha; GIT_COMMIT_TREE_FAILED, REF_UPDATE_FAILED,
747
+ * GIT_CAT_FILE_FAILED for a non-zero exit from the corresponding git
748
+ * command; COMMIT_REINSPECTION_MISMATCH if the commit git actually stored
749
+ * does not match the intended tree/parent/trailers (defensive — see the
750
+ * file comment above for why this is not expected to fire in practice).
751
+ * None of these paths discard or reset anything already on disk.
752
+ */
753
+ function createTaskCommit(cwd, intent, stagingResult) {
754
+ _validateCommitInputs(cwd, intent, stagingResult);
755
+
756
+ if (stagingResult.treeSha !== intent.expectedTreeSha) {
757
+ throw _err(
758
+ 'TREE_MISMATCH',
759
+ 'createTaskCommit: staged tree ' + stagingResult.treeSha + ' does not match checkpoint intent\'s ' +
760
+ 'expectedTreeSha ' + intent.expectedTreeSha + ' — nothing committed'
761
+ );
762
+ }
763
+
764
+ const currentTipRes = _runGitSync('createTaskCommit', cwd, ['rev-parse', '--verify', intent.taskRef + '^{commit}']);
765
+ if (currentTipRes.status !== 0) {
766
+ throw _err(
767
+ 'PARENT_MISMATCH',
768
+ 'createTaskCommit: could not resolve taskRef "' + intent.taskRef + '" to a commit (' +
769
+ (currentTipRes.stderr || '').trim() + ') — expected it to point at parentSha ' + intent.parentSha +
770
+ ' — nothing committed'
771
+ );
772
+ }
773
+ const currentTip = currentTipRes.stdout.trim();
774
+ if (currentTip !== intent.parentSha) {
775
+ throw _err(
776
+ 'PARENT_MISMATCH',
777
+ 'createTaskCommit: taskRef "' + intent.taskRef + '" currently points at ' + currentTip + ', not the ' +
778
+ 'checkpoint intent\'s parentSha ' + intent.parentSha + ' — the ref moved since the intent was persisted; ' +
779
+ 'nothing committed'
780
+ );
781
+ }
782
+
783
+ const fullMessage = _buildCommitMessageWithTrailers(intent.commitMessage, intent.trailers);
784
+
785
+ const commitTreeRes = _runGitOrThrow(
786
+ 'createTaskCommit',
787
+ cwd,
788
+ ['commit-tree', stagingResult.treeSha, '-p', intent.parentSha],
789
+ 'GIT_COMMIT_TREE_FAILED',
790
+ 'creating commit object',
791
+ { input: fullMessage }
792
+ );
793
+ const newSha = commitTreeRes.stdout.trim();
794
+
795
+ // Compare-and-swap: fails atomically if taskRef no longer points at
796
+ // parentSha (moved between the check above and here). Nothing is
797
+ // discarded either way — on failure the new commit object is simply
798
+ // unreferenced (harmless, eligible for eventual gc), and taskRef is left
799
+ // exactly where it was.
800
+ _runGitOrThrow(
801
+ 'createTaskCommit',
802
+ cwd,
803
+ ['update-ref', intent.taskRef, newSha, intent.parentSha],
804
+ 'REF_UPDATE_FAILED',
805
+ 'moving taskRef "' + intent.taskRef + '" to the new commit (compare-and-swap against expected old value ' +
806
+ intent.parentSha + ')'
807
+ );
808
+
809
+ // Post-commit reinspection (see file comment above for what this does and
810
+ // does not guard against with commit-tree).
811
+ const catFileRes = _runGitOrThrow(
812
+ 'createTaskCommit',
813
+ cwd,
814
+ ['cat-file', '-p', newSha],
815
+ 'GIT_CAT_FILE_FAILED',
816
+ 'reinspecting the created commit'
817
+ );
818
+ const parsed = _parseCommitObject(catFileRes.stdout);
819
+
820
+ if (parsed.tree !== stagingResult.treeSha) {
821
+ throw _err(
822
+ 'COMMIT_REINSPECTION_MISMATCH',
823
+ 'createTaskCommit: commit ' + newSha + ' tree ' + parsed.tree + ' does not match the intended tree ' +
824
+ stagingResult.treeSha + ' — taskRef already moved to ' + newSha + '; not discarding anything, blocking'
825
+ );
826
+ }
827
+ if (parsed.parents.length !== 1 || parsed.parents[0] !== intent.parentSha) {
828
+ throw _err(
829
+ 'COMMIT_REINSPECTION_MISMATCH',
830
+ 'createTaskCommit: commit ' + newSha + ' parents [' + parsed.parents.join(', ') + '] do not match the ' +
831
+ 'intended single parent ' + intent.parentSha + ' — taskRef already moved to ' + newSha + '; not discarding ' +
832
+ 'anything, blocking'
833
+ );
834
+ }
835
+ const actualTrailers = _extractTrailingTrailers(parsed.message);
836
+ const trailerMismatches = TRAILER_KEYS.filter(function (key) {
837
+ return actualTrailers[key] !== intent.trailers[key];
838
+ });
839
+ if (trailerMismatches.length > 0) {
840
+ throw _err(
841
+ 'COMMIT_REINSPECTION_MISMATCH',
842
+ 'createTaskCommit: commit ' + newSha + ' trailers do not match intent for: ' + trailerMismatches.join(', ') +
843
+ ' — taskRef already moved to ' + newSha + '; not discarding anything, blocking'
844
+ );
845
+ }
846
+
847
+ return { commitSha: newSha, treeSha: stagingResult.treeSha, parentSha: intent.parentSha };
848
+ }
849
+
850
+ // ── integrateAttempt (US-08-TASK-BE-03) ─────────────────────────────────────
851
+ //
852
+ // Serial, dispatch-order integration of one N>1 attempt's already-verified,
853
+ // already-committed technical-branch commit onto the feature branch.
854
+ // Tech-Spec section 8 (N>1 branch model): "each attempt has unique branch
855
+ // ai-toolkit/<runId>/<taskId>/<attempt> ... Commit remains on technical ref
856
+ // until integration." / "Integration is serialized by dispatch sequence. Use
857
+ // cherry-pick --no-commit into a dedicated run-owned integration worktree at
858
+ // expected feature HEAD, then inspect candidate diff and run integration
859
+ // checks. This changes no user worktree. On conflict leave state/worktree
860
+ // for diagnosis; never automatically abort/reset it. Persist integration
861
+ // intent with original SHA, feature-parent, candidate tree and message.
862
+ // Create integration commit there, verify, then advance the feature ref only
863
+ // by compare-and-swap against expected HEAD ... Record both SHAs and verify
864
+ // reachability. Never force-move a branch or overwrite a user's index."
865
+ //
866
+ // DEVIATION FROM THE DISPATCHED TASK PROMPT — read before changing anything
867
+ // here: the task prompt that requested this function described `cwd` as
868
+ // "the MAIN project worktree, where integration lands" and its conflict
869
+ // handling as "abort cleanly (`git cherry-pick --abort`) and fail closed".
870
+ // Both of those are inconsistent with the actual approved artifacts:
871
+ // - The Work Breakdown's own outcome text for this task says
872
+ // "cherry-picks from attempt branch to integration worktree" (not the
873
+ // main project worktree), and the Tech-Spec is explicit that
874
+ // integration "changes no user worktree" and happens in "a dedicated
875
+ // run-owned integration worktree".
876
+ // - The Tech-Spec is equally explicit that on conflict the correct
877
+ // behavior is "leave state/worktree for diagnosis; never automatically
878
+ // abort/reset it" — the exact opposite of an automatic
879
+ // `cherry-pick --abort`.
880
+ // This implementation follows the Work Breakdown/Tech-Spec (the approved,
881
+ // authoritative documents) rather than the task prompt's paraphrase in both
882
+ // respects. `cwd` here is therefore the dedicated, run-owned INTEGRATION
883
+ // worktree (checked out on featureRef, kept at the expected feature HEAD
884
+ // between calls) — never the user's own main project checkout. See the
885
+ // scope-boundary note below the function for what creating/maintaining that
886
+ // integration worktree itself is (and is not) responsible for here.
887
+ //
888
+ // Cherry-pick approach chosen: a REAL `git cherry-pick --no-commit -x`
889
+ // (rather than a fully hand-rolled plumbing equivalent). Unlike
890
+ // createTaskCommit's feature-branch commit (which deliberately avoids
891
+ // `git commit` to prevent re-deriving a tree from a live index it did not
892
+ // just verify), the tree this function ultimately commits is NEVER taken
893
+ // from cherry-pick's own working state on faith: after `--no-commit`
894
+ // finishes cleanly, `git write-tree` is called to capture the resulting
895
+ // tree as an explicit SHA (identical discipline to stageTaskFiles), and the
896
+ // actual commit object is still built with `git commit-tree <tree> -p
897
+ // <parent>` — never with `git commit` or `git cherry-pick`'s own commit
898
+ // step. So the "never commit from an unverified live index" rule is fully
899
+ // preserved; `git cherry-pick --no-commit` is used only for the one thing
900
+ // there is no existing plumbing-only equivalent for in this codebase: three-
901
+ // way-merging a commit's diff onto a different parent tree and reporting a
902
+ // conflict if it does not apply cleanly. Reimplementing that merge algorithm
903
+ // by hand would duplicate Git's own merge machinery for no safety benefit —
904
+ // the tree it produces is independently verified via write-tree before
905
+ // anything is committed, exactly like every other tree this file commits.
906
+ //
907
+ // SCOPE BOUNDARY vs US-08-TASK-BE-04 ("tracks originalSha ... and
908
+ // integratedSha ... separately; updates integration attempt record"): this
909
+ // function does NOT write attempt.originalSha or attempt.integratedSha —
910
+ // those attempt-record fields are US-08-TASK-BE-04's own, separate job
911
+ // (store.js's registerIntegratedSHA, DONE). This function DOES call store.js's existing
912
+ // recordIntegration(taskId, attemptNumber) — a different, already-
913
+ // implemented piece of state (the top-level integrationSequence array, not
914
+ // the per-attempt SHA fields) that this task's own instructions explicitly
915
+ // call out for reuse so integrationSequence reflects real dispatch-serial
916
+ // integration order. Calling it here (rather than leaving it to a not-yet-
917
+ // existing caller) is deliberate: unlike createTaskCommit/registerCommitSHA
918
+ // (where a real caller — index.js's N=1 path — already exists and owns the
919
+ // follow-up registration call), no execute()-level N>1 caller exists yet for
920
+ // this standalone primitive (same "not yet wired into execute()'s main
921
+ // loop" status as createTaskWorktree/createSlotPool), so leaving
922
+ // recordIntegration to an as-yet-nonexistent caller would mean it is never
923
+ // actually exercised until a future wiring task lands. recordIntegration
924
+ // only touches integrationSequence, never attempt.originalSha/integratedSha,
925
+ // so calling it here cannot collide with US-08-TASK-BE-04's later field work.
926
+ //
927
+ // SCOPE BOUNDARY — integration worktree lifecycle: this function assumes its
928
+ // `cwd` argument is ALREADY an existing, dedicated, run-owned Git worktree
929
+ // checked out on `featureRef`, kept at the expected feature HEAD across
930
+ // calls (Tech-Spec: "a clean coordination checkout/branch"). Creating,
931
+ // provisioning, or re-synchronizing that worktree is explicitly OUT OF SCOPE
932
+ // here — it is a future wiring concern (analogous to how createTaskWorktree,
933
+ // US-08-TASK-BE-01, only creates PER-ATTEMPT worktrees, not this shared
934
+ // integration one). This function only verifies the worktree it is handed is
935
+ // in the expected state (clean, on featureRef, at expectedParentSha) before
936
+ // touching it, and fails closed otherwise.
937
+ //
938
+ // "Run integration checks" (Tech-Spec: "...then inspect candidate diff and
939
+ // run integration checks") is ALSO out of scope here: this is a git.js
940
+ // primitive, not the run loop — it has no mechanism for invoking arbitrary
941
+ // verification commands (that is index.js's runVerifications, an
942
+ // LLM/process-orchestration concern this module has never touched). This
943
+ // function performs the git-level mechanics only (cherry-pick, tree/parent/
944
+ // trailer verification, CAS ref-advance); running additional integration
945
+ // checks against the candidate tree before it is committed is left to a
946
+ // future caller, exactly like this file's own createTaskCommit leaves
947
+ // "review" to index.js's runReview rather than performing it itself.
948
+
949
+ const INTEGRATED_FROM_TRAILER_KEY = 'AI-Toolkit-Integrated-From';
950
+
951
+ function _integrationFail(field, reason) {
952
+ throw _err('INTEGRATION_VALIDATION_ERROR', 'integrateAttempt: ' + field + ' ' + reason);
953
+ }
954
+
955
+ function _requireIntegrationString(value, field) {
956
+ if (typeof value !== 'string' || value.length === 0) {
957
+ _integrationFail(field, 'must be a non-empty string');
958
+ }
959
+ }
960
+
961
+ function _requireIntegrationSha(value, field) {
962
+ if (typeof value !== 'string' || !SHA_PATTERN.test(value)) {
963
+ _integrationFail(field, 'must be a 40-character hex SHA-1 string');
964
+ }
965
+ }
966
+
967
+ function _validateIntegrationInputs(args) {
968
+ if (args == null || typeof args !== 'object' || Array.isArray(args)) {
969
+ _integrationFail('args', 'must be an object');
970
+ }
971
+ _requireIntegrationString(args.executionRoot, 'args.executionRoot');
972
+ _requireIntegrationString(args.runId, 'args.runId');
973
+ _requireIntegrationString(args.taskId, 'args.taskId');
974
+ if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
975
+ _integrationFail('args.attemptNumber', 'must be a positive integer');
976
+ }
977
+ _requireIntegrationString(args.cwd, 'args.cwd');
978
+ let stat;
979
+ try {
980
+ stat = fs.statSync(args.cwd);
981
+ } catch (err) {
982
+ _integrationFail('args.cwd', 'does not exist (' + err.code + ')');
983
+ }
984
+ if (!stat.isDirectory()) {
985
+ _integrationFail('args.cwd', 'must be a directory');
986
+ }
987
+ _requireIntegrationString(args.featureRef, 'args.featureRef');
988
+ _requireIntegrationSha(args.expectedParentSha, 'args.expectedParentSha');
989
+ _requireIntegrationString(args.attemptBranch, 'args.attemptBranch');
990
+ _requireIntegrationSha(args.commitSha, 'args.commitSha');
991
+ }
992
+
993
+ // Same merge-base-based reachability check index.js's finalizeTaskCheckpoint
994
+ // already uses (_isCommitReachableOnRef) — duplicated locally (git.js does
995
+ // not import from index.js; index.js imports FROM git.js) rather than
996
+ // restructured into a shared module, matching this module's existing
997
+ // tolerance for small, private, single-purpose duplicates over introducing a
998
+ // new shared-utility file for one helper (see index.js's own
999
+ // _readCommitObjectSummary comment for the identical trade-off already made
1000
+ // in this codebase).
1001
+ function _isCommitAncestorOf(cwd, sha, ref) {
1002
+ const res = _runGitSync('integrateAttempt', cwd, ['merge-base', '--is-ancestor', sha, ref]);
1003
+ if (res.status === 0) return true;
1004
+ if (res.status === 1) return false;
1005
+ throw _err(
1006
+ 'GIT_MERGE_BASE_FAILED',
1007
+ 'integrateAttempt: "git merge-base --is-ancestor ' + sha + ' ' + ref + '" in "' + cwd + '" failed unexpectedly: ' +
1008
+ (res.stderr || '').trim()
1009
+ );
1010
+ }
1011
+
1012
+ function _requireCommitVerifiedOnAttemptBranch(cwd, commitSha, attemptBranch) {
1013
+ const existsRes = _runGitSync('integrateAttempt', cwd, ['cat-file', '-e', commitSha + '^{commit}']);
1014
+ if (existsRes.status !== 0) {
1015
+ throw _err(
1016
+ 'INTEGRATION_SOURCE_NOT_FOUND',
1017
+ 'integrateAttempt: commitSha "' + commitSha + '" does not resolve to a commit object in "' + cwd + '" — nothing integrated'
1018
+ );
1019
+ }
1020
+ if (!_isCommitAncestorOf(cwd, commitSha, attemptBranch)) {
1021
+ throw _err(
1022
+ 'INTEGRATION_SOURCE_NOT_ON_BRANCH',
1023
+ 'integrateAttempt: commitSha "' + commitSha + '" is not reachable from attemptBranch "' + attemptBranch +
1024
+ '" — refusing to integrate an unverified commit; nothing integrated'
1025
+ );
1026
+ }
1027
+ }
1028
+
1029
+ // The dispatch-order CAS precondition: featureRef's CURRENT tip must already
1030
+ // be expectedParentSha before anything else runs. Failing this means an
1031
+ // earlier-dispatched attempt has not integrated yet — per the Tech-Spec's
1032
+ // "do not overtake earlier dispatched tasks in integration" rule, this
1033
+ // function never reorders, retries, or forces past that; it fails closed and
1034
+ // leaves it to the caller to retry integration once dispatch order actually
1035
+ // allows it.
1036
+ function _requireFeatureRefAtExpectedParent(cwd, featureRef, expectedParentSha) {
1037
+ const res = _runGitSync('integrateAttempt', cwd, ['rev-parse', '--verify', featureRef + '^{commit}']);
1038
+ if (res.status !== 0) {
1039
+ throw _err(
1040
+ 'INTEGRATION_PARENT_MISMATCH',
1041
+ 'integrateAttempt: could not resolve featureRef "' + featureRef + '" to a commit in "' + cwd + '" (' +
1042
+ (res.stderr || '').trim() + ') — expected it to point at expectedParentSha ' + expectedParentSha +
1043
+ ' — nothing integrated'
1044
+ );
1045
+ }
1046
+ const currentTip = res.stdout.trim();
1047
+ if (currentTip !== expectedParentSha) {
1048
+ throw _err(
1049
+ 'INTEGRATION_PARENT_MISMATCH',
1050
+ 'integrateAttempt: featureRef "' + featureRef + '" currently points at ' + currentTip + ', not the expected ' +
1051
+ 'parent ' + expectedParentSha + ' — an earlier-dispatched attempt has not integrated yet; this attempt must ' +
1052
+ 'not overtake it. Nothing integrated; retry in dispatch order.'
1053
+ );
1054
+ }
1055
+ }
1056
+
1057
+ // Verifies the integration worktree itself is exactly where this function
1058
+ // requires it before it ever touches it: clean (no pending changes of any
1059
+ // kind), HEAD resolves to expectedParentSha, and — when HEAD is attached —
1060
+ // the checked-out branch is featureRef itself, never some other branch.
1061
+ function _requireIntegrationWorktreeReady(cwd, featureRef, expectedParentSha) {
1062
+ const statusRes = _runGitOrThrow(
1063
+ 'integrateAttempt', cwd,
1064
+ ['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all'],
1065
+ 'GIT_STATUS_FAILED', 'checking integration worktree cleanliness'
1066
+ );
1067
+ if (statusRes.stdout.length > 0) {
1068
+ throw _err(
1069
+ 'INTEGRATION_WORKTREE_NOT_CLEAN',
1070
+ 'integrateAttempt: integration worktree "' + cwd + '" is not clean — refusing to cherry-pick into it; nothing integrated'
1071
+ );
1072
+ }
1073
+
1074
+ const headRes = _runGitOrThrow('integrateAttempt', cwd, ['rev-parse', '--verify', 'HEAD'], 'GIT_REV_PARSE_FAILED', 'resolving integration worktree HEAD');
1075
+ const headSha = headRes.stdout.trim();
1076
+ if (headSha !== expectedParentSha) {
1077
+ throw _err(
1078
+ 'INTEGRATION_WORKTREE_STATE_MISMATCH',
1079
+ 'integrateAttempt: integration worktree "' + cwd + '" HEAD (' + headSha + ') does not match expectedParentSha (' +
1080
+ expectedParentSha + ') — refusing to cherry-pick into a worktree that is not at the expected feature HEAD'
1081
+ );
1082
+ }
1083
+
1084
+ const symRes = _runGitSync('integrateAttempt', cwd, ['symbolic-ref', '-q', '--short', 'HEAD']);
1085
+ if (symRes.status !== 0) {
1086
+ throw _err(
1087
+ 'INTEGRATION_WORKTREE_STATE_MISMATCH',
1088
+ 'integrateAttempt: integration worktree "' + cwd + '" HEAD is detached; expected it checked out on featureRef "' + featureRef + '"'
1089
+ );
1090
+ }
1091
+ const shortRef = symRes.stdout.trim();
1092
+ const featureShort = featureRef.indexOf('refs/heads/') === 0 ? featureRef.slice('refs/heads/'.length) : featureRef;
1093
+ if (shortRef !== featureShort) {
1094
+ throw _err(
1095
+ 'INTEGRATION_WORKTREE_STATE_MISMATCH',
1096
+ 'integrateAttempt: integration worktree "' + cwd + '" is checked out on "' + shortRef + '", not featureRef "' +
1097
+ featureRef + '" — refusing to cherry-pick into the wrong branch'
1098
+ );
1099
+ }
1100
+ }
1101
+
1102
+ // Detects a real merge conflict left behind by `git cherry-pick --no-commit`.
1103
+ // Verified by direct experiment (not assumed from documentation): with
1104
+ // --no-commit, a conflicting cherry-pick does NOT write .git/CHERRY_PICK_HEAD
1105
+ // (that marker is only written when git itself is tracking a pending commit
1106
+ // for --continue/--abort) — the only real signal left behind is the index
1107
+ // itself containing unmerged entries. Detected the same way `git status`
1108
+ // reports them: a porcelain status code of exactly "AA"/"DD", or containing
1109
+ // "U" in either column (unmerged, both-added, or both-deleted).
1110
+ function _hasConflictedPaths(cwd) {
1111
+ const res = _runGitSync('integrateAttempt', cwd, ['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all']);
1112
+ if (res.status !== 0) return false;
1113
+ return res.stdout.split('\0').some(function (record) {
1114
+ if (record.length === 0) return false;
1115
+ const statusCode = record.slice(0, 2);
1116
+ return statusCode === 'AA' || statusCode === 'DD' || statusCode.indexOf('U') !== -1;
1117
+ });
1118
+ }
1119
+
1120
+ // Appends exactly one trailer line, same "blank line + Key: value" shape
1121
+ // _buildCommitMessageWithTrailers already uses for the four checkpoint
1122
+ // trailers — kept as its own small helper (rather than generalizing
1123
+ // _buildCommitMessageWithTrailers's fixed TRAILER_KEYS) because the
1124
+ // integration commit reuses the ORIGINAL commit's message verbatim (it is
1125
+ // the same change, only re-parented) and adds just this one traceability
1126
+ // trailer, never the checkpoint's four AI-Toolkit-Run/Task/Attempt/Plan
1127
+ // trailers again (those already exist, unchanged, earlier in the reused
1128
+ // message).
1129
+ function _appendIntegratedFromTrailer(message, commitSha) {
1130
+ const base = message.replace(/\s+$/, '');
1131
+ return base + '\n\n' + INTEGRATED_FROM_TRAILER_KEY + ': ' + commitSha + '\n';
1132
+ }
1133
+
1134
+ /**
1135
+ * Integrates one already-verified, already-committed N>1 attempt commit
1136
+ * (living on its private technical branch, `attemptBranch`) onto the feature
1137
+ * branch (`featureRef`), serially and in dispatch order. See the file
1138
+ * section comment above for the full design/scope rationale.
1139
+ *
1140
+ * @param {object} args
1141
+ * @param {string} args.executionRoot - Absolute path to
1142
+ * <common-git-dir>/ai-toolkit/execution (store.js's run-owned root).
1143
+ * @param {string} args.runId - UUID of the current run.
1144
+ * @param {string} args.taskId - Task ID this attempt belongs to.
1145
+ * @param {number} args.attemptNumber - Positive integer attempt number.
1146
+ * @param {string} args.cwd - Absolute path to the dedicated, run-owned
1147
+ * INTEGRATION worktree (never the user's main project worktree) — must
1148
+ * already be a clean checkout of featureRef at expectedParentSha.
1149
+ * @param {string} args.featureRef - The feature ref being advanced (e.g.
1150
+ * "refs/heads/main" or the run's configured feature branch).
1151
+ * @param {string} args.expectedParentSha - The feature ref's expected
1152
+ * current tip — the dispatch-order CAS precondition; INTEGRATION_PARENT_
1153
+ * MISMATCH if featureRef has moved past this (an earlier-dispatched
1154
+ * attempt has not integrated yet).
1155
+ * @param {string} args.attemptBranch - The N>1 technical ref
1156
+ * (ai-toolkit/<runId>/<taskId>/<attempt>, as created by createTaskWorktree)
1157
+ * commitSha must be reachable from.
1158
+ * @param {string} args.commitSha - The exact, already-verified commit SHA on
1159
+ * attemptBranch to integrate (never a range, never "branch tip" implicitly).
1160
+ * @returns {{taskId: string, attemptNumber: number, originalSha: string,
1161
+ * integratedSha: string, treeSha: string, parentSha: string,
1162
+ * featureRef: string, intentId: string}}
1163
+ * @throws {Error} INTEGRATION_VALIDATION_ERROR for malformed input;
1164
+ * INTEGRATION_PARENT_MISMATCH if featureRef has moved past
1165
+ * expectedParentSha (do not overtake earlier-dispatched integrations);
1166
+ * INTEGRATION_WORKTREE_NOT_CLEAN / INTEGRATION_WORKTREE_STATE_MISMATCH if
1167
+ * the integration worktree is not exactly where this function requires it;
1168
+ * INTEGRATION_SOURCE_NOT_FOUND / INTEGRATION_SOURCE_NOT_ON_BRANCH if
1169
+ * commitSha cannot be verified against attemptBranch;
1170
+ * INTEGRATION_CONFLICT if the cherry-pick does not apply cleanly — the
1171
+ * worktree is deliberately left exactly as git left it, for diagnosis,
1172
+ * never auto-aborted/reset (Tech-Spec section 8);
1173
+ * GIT_CHERRY_PICK_FAILED for any other non-conflict cherry-pick failure
1174
+ * (also left untouched, same non-destructive discipline);
1175
+ * GIT_WRITE_TREE_FAILED, GIT_COMMIT_TREE_FAILED, GIT_CAT_FILE_FAILED for a
1176
+ * non-zero exit from the corresponding git command; REF_UPDATE_FAILED if
1177
+ * the compare-and-swap update-ref fails (featureRef moved concurrently);
1178
+ * INTEGRATION_REINSPECTION_MISMATCH if the integration commit git actually
1179
+ * stored does not match the intended tree/parent/trailer — featureRef has
1180
+ * already moved at that point; nothing is reverted, the caller must
1181
+ * diagnose; INTEGRATION_CLEANUP_FAILED if the post-integration
1182
+ * `git cherry-pick --quit` sequencer cleanup fails — this does NOT mean
1183
+ * the integration itself failed (featureRef has already been verified
1184
+ * advanced by this point); it means the caller must manually clear the
1185
+ * integration worktree's cherry-pick sequencer state before the next
1186
+ * serial integration can reuse it.
1187
+ */
1188
+ function integrateAttempt(args) {
1189
+ _validateIntegrationInputs(args);
1190
+
1191
+ const executionRoot = args.executionRoot;
1192
+ const runId = args.runId;
1193
+ const taskId = args.taskId;
1194
+ const attemptNumber = args.attemptNumber;
1195
+ const cwd = args.cwd;
1196
+ const featureRef = args.featureRef;
1197
+ const expectedParentSha = args.expectedParentSha;
1198
+ const attemptBranch = args.attemptBranch;
1199
+ const commitSha = args.commitSha;
1200
+
1201
+ // 1. Dispatch-order CAS precondition — never overtake an earlier-dispatched,
1202
+ // not-yet-integrated attempt.
1203
+ _requireFeatureRefAtExpectedParent(cwd, featureRef, expectedParentSha);
1204
+
1205
+ // 2. The integration worktree must already be exactly where this function
1206
+ // requires it: clean, at expectedParentSha, checked out on featureRef.
1207
+ _requireIntegrationWorktreeReady(cwd, featureRef, expectedParentSha);
1208
+
1209
+ // 3. commitSha must be a real commit, verified reachable from attemptBranch
1210
+ // — never trust the caller's word alone.
1211
+ _requireCommitVerifiedOnAttemptBranch(cwd, commitSha, attemptBranch);
1212
+
1213
+ // 4. Apply the change via a real cherry-pick, without committing —
1214
+ // see the file section comment above for why --no-commit is used here
1215
+ // (real 3-way merge) but commit-tree still builds the actual commit below
1216
+ // (never `git commit`/cherry-pick's own commit step).
1217
+ const cherryRes = _runGitSync('integrateAttempt', cwd, ['cherry-pick', '--no-commit', '-x', commitSha]);
1218
+ if (cherryRes.status !== 0) {
1219
+ if (_hasConflictedPaths(cwd)) {
1220
+ throw _err(
1221
+ 'INTEGRATION_CONFLICT',
1222
+ 'integrateAttempt: cherry-picking ' + commitSha + ' onto "' + featureRef + '" in "' + cwd + '" conflicted — ' +
1223
+ 'the integration worktree is left exactly as git left it for diagnosis; nothing was auto-aborted or reset ' +
1224
+ '(Tech-Spec section 8: "On conflict leave state/worktree for diagnosis; never automatically abort/reset it").'
1225
+ );
1226
+ }
1227
+ throw _err(
1228
+ 'GIT_CHERRY_PICK_FAILED',
1229
+ 'integrateAttempt: "git cherry-pick --no-commit -x ' + commitSha + '" in "' + cwd + '" failed (exit ' +
1230
+ cherryRes.status + '): ' + (cherryRes.stderr || '').trim() + ' — nothing was auto-aborted or reset'
1231
+ );
1232
+ }
1233
+
1234
+ // 5. Capture the resulting candidate tree as an explicit, independently
1235
+ // verified SHA — never trust cherry-pick's own live index at commit time.
1236
+ const treeRes = _runGitOrThrow('integrateAttempt', cwd, ['write-tree'], 'GIT_WRITE_TREE_FAILED', 'computing the candidate integration tree');
1237
+ const candidateTreeSha = treeRes.stdout.trim();
1238
+
1239
+ // 6. Reuse the original commit's own message verbatim (this is the same
1240
+ // change, only re-parented) plus one traceability trailer back to it.
1241
+ const originalCatRes = _runGitOrThrow('integrateAttempt', cwd, ['cat-file', '-p', commitSha], 'GIT_CAT_FILE_FAILED', 'reading the original commit to integrate');
1242
+ const originalParsed = _parseCommitObject(originalCatRes.stdout);
1243
+ const integrationMessage = _appendIntegratedFromTrailer(originalParsed.message, commitSha);
1244
+
1245
+ // 7. Persist the integration intent BEFORE creating the integration commit
1246
+ // (Tech-Spec: "Persist integration intent with original SHA, feature-
1247
+ // parent, candidate tree and message") — same "persist before commit"
1248
+ // discipline persistCheckpointIntent already applies to checkpoint
1249
+ // commits, giving a crash between here and the CAS ref-advance below a
1250
+ // durable, exact record to reconcile against later (full reconciliation
1251
+ // logic itself is a separate, later concern — see US-06/US-09 scope notes
1252
+ // elsewhere in this codebase).
1253
+ const intentId = taskId + '-attempt' + attemptNumber + '-integration';
1254
+ const writtenIntent = store.writeIntent(executionRoot, runId, intentId, {
1255
+ taskId: taskId,
1256
+ attemptNumber: attemptNumber,
1257
+ originalSha: commitSha,
1258
+ attemptBranch: attemptBranch,
1259
+ featureRef: featureRef,
1260
+ featureParentSha: expectedParentSha,
1261
+ candidateTreeSha: candidateTreeSha,
1262
+ message: integrationMessage,
1263
+ });
1264
+ void writtenIntent; // returned for idempotent-replay parity with persistCheckpointIntent; not otherwise consulted here.
1265
+
1266
+ // 8. Create the integration commit via commit-tree — explicit tree/parent
1267
+ // SHAs, never a re-derivation from the live index (identical discipline to
1268
+ // createTaskCommit).
1269
+ const commitTreeRes = _runGitOrThrow(
1270
+ 'integrateAttempt', cwd,
1271
+ ['commit-tree', candidateTreeSha, '-p', expectedParentSha],
1272
+ 'GIT_COMMIT_TREE_FAILED', 'creating the integration commit object',
1273
+ { input: integrationMessage }
1274
+ );
1275
+ const integratedSha = commitTreeRes.stdout.trim();
1276
+
1277
+ // 9. Advance featureRef ONLY by compare-and-swap against expectedParentSha
1278
+ // — fails atomically if it moved concurrently; never force-moved.
1279
+ _runGitOrThrow(
1280
+ 'integrateAttempt', cwd,
1281
+ ['update-ref', featureRef, integratedSha, expectedParentSha],
1282
+ 'REF_UPDATE_FAILED',
1283
+ 'advancing featureRef "' + featureRef + '" to the integration commit (compare-and-swap against expected old value ' +
1284
+ expectedParentSha + ')'
1285
+ );
1286
+
1287
+ // 10. Post-commit reinspection — same discipline as createTaskCommit's own
1288
+ // post-commit re-read: re-parse the exact bytes git stored, independent of
1289
+ // the code path that built them.
1290
+ const newCatRes = _runGitOrThrow('integrateAttempt', cwd, ['cat-file', '-p', integratedSha], 'GIT_CAT_FILE_FAILED', 'reinspecting the created integration commit');
1291
+ const newParsed = _parseCommitObject(newCatRes.stdout);
1292
+ if (newParsed.tree !== candidateTreeSha) {
1293
+ throw _err(
1294
+ 'INTEGRATION_REINSPECTION_MISMATCH',
1295
+ 'integrateAttempt: integration commit ' + integratedSha + ' tree ' + newParsed.tree + ' does not match the ' +
1296
+ 'intended candidate tree ' + candidateTreeSha + ' — featureRef already moved to ' + integratedSha +
1297
+ '; not reverting, blocking'
1298
+ );
1299
+ }
1300
+ if (newParsed.parents.length !== 1 || newParsed.parents[0] !== expectedParentSha) {
1301
+ throw _err(
1302
+ 'INTEGRATION_REINSPECTION_MISMATCH',
1303
+ 'integrateAttempt: integration commit ' + integratedSha + ' parents [' + newParsed.parents.join(', ') +
1304
+ '] do not match the intended single parent ' + expectedParentSha + ' — featureRef already moved to ' +
1305
+ integratedSha + '; not reverting, blocking'
1306
+ );
1307
+ }
1308
+ const newTrailers = _extractTrailingTrailers(newParsed.message);
1309
+ if (newTrailers[INTEGRATED_FROM_TRAILER_KEY] !== commitSha) {
1310
+ throw _err(
1311
+ 'INTEGRATION_REINSPECTION_MISMATCH',
1312
+ 'integrateAttempt: integration commit ' + integratedSha + ' is missing or has a mismatched "' +
1313
+ INTEGRATED_FROM_TRAILER_KEY + '" trailer (expected ' + commitSha + ') — featureRef already moved to ' +
1314
+ integratedSha + '; not reverting, blocking'
1315
+ );
1316
+ }
1317
+
1318
+ // 11. Clear the leftover sequencer/merge-state files (.git/MERGE_MSG,
1319
+ // .git/AUTO_MERGE — verified by direct experiment; --no-commit does NOT
1320
+ // write CHERRY_PICK_HEAD on a clean apply, only these) left behind by step
1321
+ // 4's --no-commit — required so the NEXT serial integration reusing this
1322
+ // same worktree does not fail with "you have not concluded your previous
1323
+ // cherry-pick". `--quit` only forgets this in-progress sequencer state; it
1324
+ // does not touch HEAD, the index, or the working tree (both already
1325
+ // correct at this point — the ref/commit are already verified above), so
1326
+ // this is not a "reset/clean" in the sense the Tech-Spec forbids elsewhere.
1327
+ const quitRes = _runGitSync('integrateAttempt', cwd, ['cherry-pick', '--quit']);
1328
+ if (quitRes.status !== 0) {
1329
+ throw _err(
1330
+ 'INTEGRATION_CLEANUP_FAILED',
1331
+ 'integrateAttempt: integration commit ' + integratedSha + ' was created and featureRef advanced successfully, ' +
1332
+ 'but "git cherry-pick --quit" failed to clear the sequencer state in "' + cwd + '" (' +
1333
+ (quitRes.stderr || '').trim() + ') — the integration itself is NOT rolled back; manually run ' +
1334
+ '"git cherry-pick --quit" in the integration worktree before the next serial integration.'
1335
+ );
1336
+ }
1337
+
1338
+ // 12. Record dispatch-serial integration order (integrationSequence only —
1339
+ // never attempt.originalSha/integratedSha; see scope-boundary note above).
1340
+ let state = store.readState(executionRoot, runId);
1341
+ state = state.recordIntegration(taskId, attemptNumber);
1342
+ store.writeState(executionRoot, runId, state);
1343
+
1344
+ return {
1345
+ taskId: taskId,
1346
+ attemptNumber: attemptNumber,
1347
+ originalSha: commitSha,
1348
+ integratedSha: integratedSha,
1349
+ treeSha: candidateTreeSha,
1350
+ parentSha: expectedParentSha,
1351
+ featureRef: featureRef,
1352
+ intentId: intentId,
1353
+ };
1354
+ }
1355
+
1356
+ module.exports = {
1357
+ persistCheckpointIntent,
1358
+ stageTaskFiles,
1359
+ createTaskCommit,
1360
+ detectChangedPaths,
1361
+ integrateAttempt,
1362
+ };