@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.
- package/CHANGELOG.md +52 -0
- package/bin/cli.js +371 -2
- package/docs/task-executor-bootstrap.md +195 -0
- package/docs/task-executor-cli.md +319 -0
- package/docs/workflow-control-plane.md +102 -0
- package/lib/execution-ledger.js +186 -4
- package/lib/task-executor/claude-process.js +563 -0
- package/lib/task-executor/git.js +1362 -0
- package/lib/task-executor/index.js +3729 -0
- package/lib/task-executor/ownership.js +590 -0
- package/lib/task-executor/plan.js +725 -0
- package/lib/task-executor/store.js +1164 -0
- package/lib/workflow-artifacts.js +102 -0
- package/lib/workflow-control.js +225 -0
- package/lib/workflow-runner.js +39 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-intervention-documentation-standard.md +9 -9
- package/src/claude/agents/gaia-validate-feature-docs.md +31 -28
- package/src/claude/skills/assess-codebase/SKILL.md +9 -103
- package/src/claude/skills/implement-feature/SKILL.md +74 -159
- package/src/claude/workflows/am-phase1.js +43 -294
- package/src/claude/workflows/am-phase2.js +10 -164
- package/src/claude/workflows/pm-phase1.js +45 -358
- package/src/claude/workflows/pm-phase2.js +50 -571
- package/src/claude/workflows/pm-phase3.js +5 -824
|
@@ -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
|
+
};
|