@magnusekdahl/parallix 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/integration-pipelines.json +9 -0
- package/config/workflow.config.schema.json +12 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
- package/docs/adr/0041-integration-pipeline-gates.md +1 -1
- package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
- package/docs/adr/index.md +1 -0
- package/docs/use-cases.md +44 -0
- package/lib/agents/agents.js +149 -100
- package/lib/agents/claude-telemetry.js +14 -10
- package/lib/agents/claude.js +32 -11
- package/lib/agents/codex-telemetry.js +32 -19
- package/lib/agents/codex.js +36 -11
- package/lib/agents/limit-hit.js +42 -25
- package/lib/agents/mistral-telemetry.js +1 -1
- package/lib/agents/mistral.js +13 -3
- package/lib/agents/opencode-export.js +11 -5
- package/lib/agents/opencode-telemetry.js +75 -90
- package/lib/agents/opencode.js +86 -29
- package/lib/agents/stage-telemetry.js +3 -7
- package/lib/commands/active.js +139 -94
- package/lib/commands/checkpoint.js +3 -1
- package/lib/commands/config.js +5 -3
- package/lib/commands/coverage-gate.js +17 -10
- package/lib/commands/diff.js +11 -5
- package/lib/commands/draft.js +95 -55
- package/lib/commands/handoff.js +253 -65
- package/lib/commands/integrate.js +288 -188
- package/lib/commands/mission-start.js +39 -34
- package/lib/commands/rebase.js +32 -21
- package/lib/commands/repair-handoff.js +21 -18
- package/lib/commands/resolve-conflict.js +5 -2
- package/lib/commands/review.js +1 -0
- package/lib/commands/setup-review.js +1 -0
- package/lib/commands/stats-backfill.js +50 -41
- package/lib/commands/stats.js +653 -204
- package/lib/commands/status.js +33 -28
- package/lib/core/fmt.js +58 -15
- package/lib/core/git.js +7 -5
- package/lib/core/gitignore.js +6 -4
- package/lib/core/mission-utils.js +161 -88
- package/lib/core/nels.js +199 -0
- package/lib/core/persistent-data-migration.js +55 -23
- package/lib/core/product-config.js +44 -23
- package/lib/core/runtime-matrix.js +15 -2
- package/lib/core/spawn-tee.js +40 -13
- package/lib/core/state-map.js +32 -13
- package/lib/core/storage.js +19 -5
- package/lib/core/subagent-limit.js +28 -0
- package/lib/core/verification.js +29 -12
- package/lib/review/rebase.js +11 -3
- package/lib/review/review-adapter.js +93 -6
- package/lib/review/review-artifacts.js +89 -23
- package/lib/review/review-commands.js +139 -45
- package/lib/review/review-events.js +103 -52
- package/lib/review/review-loop.js +117 -87
- package/lib/review/review-polling.js +25 -5
- package/lib/review/review-prompts.js +36 -5
- package/lib/review/review-state.js +39 -15
- package/lib/tools/backlog.js +162 -71
- package/lib/tools/forgejo.js +290 -129
- package/lib/tools/gatekeeper.js +15 -0
- package/lib/tools/redgreen.js +220 -0
- package/lib/tools/sessions.js +12 -5
- package/lib/tools/setup-review.js +170 -70
- package/package.json +11 -3
- package/prompts/draft.md +6 -0
- package/templates/mission-scaffold.md +1 -1
package/lib/commands/handoff.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
3
4
|
const git = require('../core/git');
|
|
4
5
|
const missionUtils = require('../core/mission-utils');
|
|
5
6
|
const backlog = require('../tools/backlog');
|
|
@@ -11,16 +12,19 @@ const fmt = require('../core/fmt');
|
|
|
11
12
|
const { runVerificationGate } = require('../core/verification');
|
|
12
13
|
const { isForgejoReviewEnabled } = require('../core/product-config');
|
|
13
14
|
const { rebaseBeforeReviewRound } = require('../review/rebase');
|
|
15
|
+
const nels = require('../core/nels');
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
function verifyHandoff(slug, options = {}) {
|
|
23
|
-
|
|
18
|
+
* Verifies that the current environment is ready for handoff.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} slug - Mission slug
|
|
21
|
+
* @param {{worktree?: string}} [options]
|
|
22
|
+
* @returns {{ ok: boolean, error?: string, missionDir?: string, area?: string, branch?: string, rootDir?: string }}
|
|
23
|
+
*/
|
|
24
|
+
function verifyHandoff(slug, options = {}) {
|
|
25
|
+
/** @type{{worktree?: string}} */
|
|
26
|
+
const opts = options;
|
|
27
|
+
const rootDir = opts.worktree || process.cwd();
|
|
24
28
|
const missionDir = missionUtils.findMissionDir(slug, rootDir);
|
|
25
29
|
if (!missionDir) {
|
|
26
30
|
return { ok: false, error: `Mission directory not found for slug: ${slug}` };
|
|
@@ -43,34 +47,38 @@ function verifyHandoff(slug, options = {}) {
|
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
async function performHandoff(slug, options = {}) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
50
|
+
* Performs the handoff process for a mission:
|
|
51
|
+
* 1. Runs the verification gate.
|
|
52
|
+
* 2. Syncs primary branch and pushes the mission branch to Forgejo, creating or updating the PR.
|
|
53
|
+
* 3. Transitions Backlog task to 'review'.
|
|
54
|
+
* 4. Commits and pushes the Backlog state change to Forgejo.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} slug - Mission slug
|
|
57
|
+
* @param {{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function}} [options]
|
|
58
|
+
* @returns {Promise<{ ok: boolean, error?: string, gatekeeperPushedBack?: boolean }>}
|
|
59
|
+
*/
|
|
60
|
+
async function performHandoff(slug, options = {}) {
|
|
61
|
+
/** @type{{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function}} */
|
|
62
|
+
const opts = options;
|
|
63
|
+
const {
|
|
64
|
+
skipGate = false,
|
|
65
|
+
worktree = null,
|
|
66
|
+
force = false,
|
|
67
|
+
forceWithLease = true,
|
|
68
|
+
log = fmt.log.info,
|
|
69
|
+
error = fmt.log.fail,
|
|
70
|
+
rebaseFn = rebaseBeforeReviewRound
|
|
71
|
+
} = opts;
|
|
72
|
+
|
|
73
|
+
const verification = verifyHandoff(slug, { worktree: worktree || undefined });
|
|
68
74
|
if (!verification.ok) {
|
|
69
75
|
error(verification.error);
|
|
70
76
|
return { ok: false, error: verification.error };
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
const { area, branch,
|
|
79
|
+
const { area, branch, missionDir } = verification;
|
|
80
|
+
const rootDir = /** @type {string} */(verification.rootDir);
|
|
81
|
+
const missionDirPath = /** @type {string} */(missionDir);
|
|
74
82
|
|
|
75
83
|
// Step 0: Resolve Backlog task for identity derivation
|
|
76
84
|
const taskResolution = backlog.resolveTaskFile(slug, rootDir);
|
|
@@ -81,9 +89,9 @@ async function performHandoff(slug, options = {}) {
|
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
// Pre-handoff Content Integrity Check
|
|
84
|
-
const relativeMissionPath = path.relative(rootDir, path.join(
|
|
92
|
+
const relativeMissionPath = path.relative(rootDir, path.join(missionDirPath, 'MISSION.md'));
|
|
85
93
|
const dirtyFiles = git.getWorktreeStatus(rootDir);
|
|
86
|
-
let checkpoints = missionUtils.findCheckpoints(
|
|
94
|
+
let checkpoints = missionUtils.findCheckpoints(missionDirPath);
|
|
87
95
|
|
|
88
96
|
if (dirtyFiles.some(line => line.endsWith(relativeMissionPath))) {
|
|
89
97
|
const msg = `${fmt.path('MISSION.md')} is modified but uncommitted at ${fmt.path(relativeMissionPath)}. Commit the mission contract before handoff.`;
|
|
@@ -97,13 +105,13 @@ async function performHandoff(slug, options = {}) {
|
|
|
97
105
|
// table so the handoff can proceed without manual intervention. The generated
|
|
98
106
|
// file is clearly marked as auto-generated for reviewer awareness, then we
|
|
99
107
|
// re-scan to confirm it is discoverable via findCheckpoints().
|
|
100
|
-
const autoCheckpointPath = path.join(
|
|
108
|
+
const autoCheckpointPath = path.join(missionDirPath, 'CP-1.md');
|
|
101
109
|
fs.writeFileSync(autoCheckpointPath, buildAutoCheckpointContent(slug), 'utf8');
|
|
102
|
-
log(fmt.status('WARN', `No checkpoint documents found — auto-generated ${fmt.path('CP-1.md')} in ${fmt.path(
|
|
110
|
+
log(fmt.status('WARN', `No checkpoint documents found — auto-generated ${fmt.path('CP-1.md')} in ${fmt.path(missionDirPath)}.`));
|
|
103
111
|
|
|
104
|
-
checkpoints = missionUtils.findCheckpoints(
|
|
112
|
+
checkpoints = missionUtils.findCheckpoints(missionDirPath);
|
|
105
113
|
if (checkpoints.length === 0) {
|
|
106
|
-
const msg = `No checkpoint documents found in ${fmt.path(
|
|
114
|
+
const msg = `No checkpoint documents found in ${fmt.path(missionDirPath)} even after auto-remediation. Implementation evidence is mandatory for review.`;
|
|
107
115
|
error(msg);
|
|
108
116
|
return { ok: false, error: msg };
|
|
109
117
|
}
|
|
@@ -117,6 +125,7 @@ async function performHandoff(slug, options = {}) {
|
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/** @type {string} */
|
|
120
129
|
const finalCheckpoint = checkpoints[checkpoints.length - 1];
|
|
121
130
|
const relativeCheckpointPath = path.relative(rootDir, finalCheckpoint);
|
|
122
131
|
if (dirtyFiles.some(line => line.endsWith(relativeCheckpointPath))) {
|
|
@@ -140,7 +149,8 @@ async function performHandoff(slug, options = {}) {
|
|
|
140
149
|
// Verify the goal-check table has at least one row of evidence (table row after header).
|
|
141
150
|
// Must exclude table separator rows (|---|---|---|) and the header row itself —
|
|
142
151
|
// only real evidence rows (with pipe-separated content that is not all dashes) count.
|
|
143
|
-
const
|
|
152
|
+
const goalCheckMatchIndex = goalCheckMatch.index ?? 0;
|
|
153
|
+
const afterHeader = checkpointContent.slice(goalCheckMatchIndex + goalCheckMatch[0].length);
|
|
144
154
|
const separatorPattern = /^\|\s*:?-+:?\s*\|/;
|
|
145
155
|
const headerPattern = /^\| .+\| .+\| .+\|$/;
|
|
146
156
|
const evidenceLinePattern = /^\| .+\| .+\| .+\|$/;
|
|
@@ -149,12 +159,12 @@ async function performHandoff(slug, options = {}) {
|
|
|
149
159
|
let pastHeader = false;
|
|
150
160
|
for (const line of linesAfterHeader) {
|
|
151
161
|
const trimmed = line.trim();
|
|
152
|
-
if (trimmed === '') continue;
|
|
162
|
+
if (trimmed === '') {continue;}
|
|
153
163
|
if (!pastHeader && headerPattern.test(trimmed)) {
|
|
154
164
|
pastHeader = true;
|
|
155
165
|
continue;
|
|
156
166
|
}
|
|
157
|
-
if (separatorPattern.test(trimmed)) continue;
|
|
167
|
+
if (separatorPattern.test(trimmed)) {continue;}
|
|
158
168
|
if (pastHeader && evidenceLinePattern.test(trimmed)) {
|
|
159
169
|
foundEvidence = true;
|
|
160
170
|
break;
|
|
@@ -168,12 +178,12 @@ async function performHandoff(slug, options = {}) {
|
|
|
168
178
|
return { ok: false, error: msg };
|
|
169
179
|
}
|
|
170
180
|
|
|
171
|
-
const isForgejoReviewEnabledFn =
|
|
181
|
+
const isForgejoReviewEnabledFn = opts.isForgejoReviewEnabledFn || isForgejoReviewEnabled;
|
|
172
182
|
const forgejoEnabled = isForgejoReviewEnabledFn(rootDir);
|
|
173
183
|
|
|
174
184
|
const { forgejoUser: reviewStateUser } = resolveReviewIdentity(slug, rootDir, {
|
|
175
185
|
});
|
|
176
|
-
const forgejoUser = reviewStateUser || backlog.getTaskImplementer(taskResolution.taskFile);
|
|
186
|
+
const forgejoUser = reviewStateUser || backlog.getTaskImplementer(/** @type {string} */(taskResolution.taskFile));
|
|
177
187
|
|
|
178
188
|
if (!forgejoUser) {
|
|
179
189
|
error('forgejoUser is required for performHandoff. Ensure review-state.json or the Backlog task has an agent family assigned.');
|
|
@@ -186,8 +196,8 @@ async function performHandoff(slug, options = {}) {
|
|
|
186
196
|
if (skipGate) {
|
|
187
197
|
fmt.log.warn('Step 1: Skipping final verification gate (--no-gate)');
|
|
188
198
|
} else {
|
|
189
|
-
log(`Step 1: Running final verification gate for area: ${fmt.bold(area)}...`);
|
|
190
|
-
const verifyResult = runVerificationGate(area, {
|
|
199
|
+
log(`Step 1: Running final verification gate for area: ${fmt.bold(area || 'docs')}...`);
|
|
200
|
+
const verifyResult = runVerificationGate(area || 'docs', {
|
|
191
201
|
rootDir,
|
|
192
202
|
stdio: 'inherit',
|
|
193
203
|
runFn: git.run
|
|
@@ -202,7 +212,7 @@ async function performHandoff(slug, options = {}) {
|
|
|
202
212
|
// Step 1.5: Rebase mission branch onto latest primary before PR creation
|
|
203
213
|
log('Step 1.5: Rebasing onto primary branch before handoff...');
|
|
204
214
|
const rebaseResult = await rebaseFn(slug, {
|
|
205
|
-
worktree,
|
|
215
|
+
worktree: worktree || undefined,
|
|
206
216
|
log,
|
|
207
217
|
error,
|
|
208
218
|
isForgejoReviewEnabledFn: isForgejoReviewEnabledFn,
|
|
@@ -219,9 +229,17 @@ async function performHandoff(slug, options = {}) {
|
|
|
219
229
|
}
|
|
220
230
|
}
|
|
221
231
|
|
|
232
|
+
// Step 1.7: NEL capture — compute actual NEL from merge diff and persist record
|
|
233
|
+
log('Step 1.7: Capturing Net Engineering Lines (NEL) at handoff...');
|
|
234
|
+
const nelResult = captureNelAtHandoff(slug, { rootDir, missionDir: missionDirPath, log, error });
|
|
235
|
+
if (nelResult.ok) {
|
|
236
|
+
log(fmt.status('PASS', `NEL captured: ${nelResult.nel} NEL (${nelResult.bucket.label} bucket)`));
|
|
237
|
+
} else {
|
|
238
|
+
log(fmt.status('WARN', `NEL capture skipped: ${nelResult.error}`));
|
|
239
|
+
}
|
|
240
|
+
|
|
222
241
|
// Step 2: Forgejo PR Update/Create (optional mirror when Forgejo is enabled)
|
|
223
242
|
let token = null;
|
|
224
|
-
let bootstrapUsed = false;
|
|
225
243
|
let fallbackUser = null;
|
|
226
244
|
let bootstrapFailureReason = null;
|
|
227
245
|
if (forgejoEnabled) {
|
|
@@ -247,7 +265,6 @@ async function performHandoff(slug, options = {}) {
|
|
|
247
265
|
|
|
248
266
|
if (bootstrapResult.ok) {
|
|
249
267
|
log(fmt.status('PASS', `Bootstrap succeeded for ${fmt.agent(forgejoUser)}.`));
|
|
250
|
-
bootstrapUsed = true;
|
|
251
268
|
token = forgejo.readToken(forgejoUser);
|
|
252
269
|
if (!token) {
|
|
253
270
|
bootstrapFailureReason = 'bootstrap completed but token file for the agent user was not found';
|
|
@@ -258,6 +275,10 @@ async function performHandoff(slug, options = {}) {
|
|
|
258
275
|
error(`Bootstrap for ${fmt.agent(forgejoUser)} failed: ${bootstrapFailureReason}. Falling back to default user.`);
|
|
259
276
|
}
|
|
260
277
|
|
|
278
|
+
// Handle bootstrap result that may not have error property
|
|
279
|
+
/** @type{{error?: string}} */
|
|
280
|
+
const br = bootstrapResult;
|
|
281
|
+
|
|
261
282
|
// Magnus fallback if bootstrap didn't produce a token
|
|
262
283
|
if (!token) {
|
|
263
284
|
token = forgejo.readToken('human');
|
|
@@ -265,7 +286,7 @@ async function performHandoff(slug, options = {}) {
|
|
|
265
286
|
fallbackUser = 'human';
|
|
266
287
|
log(`Token not found for ${fmt.agent(forgejoUser)} and bootstrap did not succeed. Falling back to PR creation as ${fmt.agent(fallbackUser)}.`);
|
|
267
288
|
} else {
|
|
268
|
-
const msg = `No Forgejo token found for user "${fmt.agent(forgejoUser)}", bootstrap failed (${
|
|
289
|
+
const msg = `No Forgejo token found for user "${fmt.agent(forgejoUser)}", bootstrap failed (${br.error || 'unknown'}), and no fallback token available for "${fmt.agent('human')}". Manual action required: create a token manually or run \`node parallix setup-review\` first.`;
|
|
269
290
|
error(msg);
|
|
270
291
|
return { ok: false, error: msg };
|
|
271
292
|
}
|
|
@@ -281,7 +302,7 @@ async function performHandoff(slug, options = {}) {
|
|
|
281
302
|
}
|
|
282
303
|
}
|
|
283
304
|
|
|
284
|
-
const prResult = forgejo.createPr(branch, fallbackUser || forgejoUser, token, {
|
|
305
|
+
const prResult = forgejo.createPr(branch || '', String(fallbackUser || forgejoUser || 'default'), String(token || ''), {
|
|
285
306
|
rootDir,
|
|
286
307
|
log,
|
|
287
308
|
forceWithLease
|
|
@@ -315,7 +336,21 @@ async function performHandoff(slug, options = {}) {
|
|
|
315
336
|
|
|
316
337
|
if (gatekeeperPushedBack) {
|
|
317
338
|
log(`Gatekeeper pushback posted for ${fmt.slug(slug)} — skipping Backlog transition to review.`);
|
|
318
|
-
return { ok: true, gatekeeperPushedBack: true };
|
|
339
|
+
return /** @type{{ok: boolean, gatekeeperPushedBack: boolean}} */({ ok: true, gatekeeperPushedBack: true });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Step 2.6: Generic ## Gates runner — execute any gates declared in MISSION.md
|
|
343
|
+
log('Step 2.6: Running declared gates from MISSION.md...');
|
|
344
|
+
const gatesResult = runDeclaredGates(verification.missionDir || '', rootDir, { log, error });
|
|
345
|
+
if (!gatesResult.ok) {
|
|
346
|
+
const msg = `Declared gate "${gatesResult.gate}" failed for ${fmt.slug(slug)}: ${gatesResult.error || gatesResult.reason}. Blocking handoff — task remains in active.`;
|
|
347
|
+
error(msg);
|
|
348
|
+
return { ok: false, error: msg };
|
|
349
|
+
}
|
|
350
|
+
if (gatesResult.skipped) {
|
|
351
|
+
log(`No declared gates for ${fmt.slug(slug)} (${gatesResult.reason}).`);
|
|
352
|
+
} else {
|
|
353
|
+
log(`All ${gatesResult.count} declared gate(s) passed for ${fmt.slug(slug)}.`);
|
|
319
354
|
}
|
|
320
355
|
|
|
321
356
|
// Step 3 & 4: Backlog Transition and Commit
|
|
@@ -346,24 +381,24 @@ async function performHandoff(slug, options = {}) {
|
|
|
346
381
|
error(`${msg}${fetchError ? ` ${fetchError}` : ''}`);
|
|
347
382
|
return { ok: false, error: msg };
|
|
348
383
|
}
|
|
349
|
-
const tracking = forgejo.resolveTrackingBranchSha(branch, rootDir);
|
|
384
|
+
const tracking = forgejo.resolveTrackingBranchSha(branch || '', rootDir);
|
|
350
385
|
if (!tracking.ok) {
|
|
351
386
|
const msg = `Failed to resolve Backlog transition lease for ${fmt.slug(slug)} before Forgejo push.`;
|
|
352
387
|
error(msg);
|
|
353
388
|
return { ok: false, error: `${msg} ${tracking.error || ''}`.trim() };
|
|
354
389
|
}
|
|
355
|
-
pushLeaseArg = `--force-with-lease=refs/heads/${branch}:${tracking.sha}`;
|
|
390
|
+
pushLeaseArg = `--force-with-lease=refs/heads/${branch}:${String(tracking.sha || '')}`;
|
|
356
391
|
}
|
|
357
392
|
const pushArgs = ['-C', rootDir, 'push'];
|
|
358
393
|
if (pushLeaseArg) {
|
|
359
394
|
pushArgs.push(pushLeaseArg);
|
|
360
395
|
}
|
|
361
|
-
pushArgs.push(remoteUrl, branch);
|
|
396
|
+
pushArgs.push(String(remoteUrl || ''), branch || '');
|
|
362
397
|
const pushBacklogForgejo = git.git(pushArgs);
|
|
363
398
|
if (pushBacklogForgejo.status !== 0) {
|
|
364
399
|
const pushError = [pushBacklogForgejo.stderr, pushBacklogForgejo.stdout].filter(Boolean).join('\n');
|
|
365
400
|
if (force && /non-fast-forward|stale info|fetch first/i.test(pushError || '')) {
|
|
366
|
-
const forceArgs = ['-C', rootDir, 'push', '--force', remoteUrl, branch];
|
|
401
|
+
const forceArgs = ['-C', rootDir, 'push', '--force', String(remoteUrl || ''), branch || ''];
|
|
367
402
|
const forceResult = git.git(forceArgs);
|
|
368
403
|
if (forceResult.status === 0) {
|
|
369
404
|
fmt.log.info(`Backlog transition for ${fmt.slug(slug)} required plain force after stale lease.`);
|
|
@@ -382,7 +417,66 @@ async function performHandoff(slug, options = {}) {
|
|
|
382
417
|
}
|
|
383
418
|
|
|
384
419
|
fmt.log.pass(`Mission ${fmt.slug(slug)} handed off successfully.`);
|
|
385
|
-
return { ok: true, gatekeeperPushedBack };
|
|
420
|
+
return /** @type{{ok: boolean, gatekeeperPushedBack?: boolean}} */({ ok: true, gatekeeperPushedBack });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Parse and execute declared gates from a mission's MISSION.md `## Gates` section.
|
|
425
|
+
* Each gate line is treated as a shell command to be executed via spawnSync.
|
|
426
|
+
* Returns { ok, skipped, count, reason } on success or { ok: false, gate, error, reason } on failure.
|
|
427
|
+
*/
|
|
428
|
+
/** @param {string} missionDir @param {string} rootDir @param {{log?: Function, error?: Function}} [options] */
|
|
429
|
+
function runDeclaredGates(missionDir, rootDir, options = {}) {
|
|
430
|
+
const { log = fmt.log.plain } = /** @type {{log?: Function, error?: Function}} */ (options);
|
|
431
|
+
const missionPath = path.join(missionDir, 'MISSION.md');
|
|
432
|
+
if (!fs.existsSync(missionPath)) {
|
|
433
|
+
return { ok: true, skipped: true, reason: 'no-mission-file' };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const content = fs.readFileSync(missionPath, 'utf8');
|
|
437
|
+
|
|
438
|
+
// Extract the ## Gates section
|
|
439
|
+
const gatesSectionMatch = content.match(/^## Gates\s*\n([\s\S]*?)(?=\n## |\n$)/m);
|
|
440
|
+
if (!gatesSectionMatch) {
|
|
441
|
+
return { ok: true, skipped: true, reason: 'no-gates-section' };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const gatesBlock = gatesSectionMatch[1];
|
|
445
|
+
const gateLines = gatesBlock.split('\n')
|
|
446
|
+
.map(line => line.trim())
|
|
447
|
+
.filter(line => line.startsWith('- [ ]') || line.startsWith('- [x]') || line.startsWith('- '));
|
|
448
|
+
|
|
449
|
+
// Strip the checkbox prefix to get the command
|
|
450
|
+
const commands = gateLines.map(line => {
|
|
451
|
+
// Remove "- [ ] ", "- [x] ", or "- " prefix
|
|
452
|
+
return line.replace(/^- \[[ x]\]\s*/, '').replace(/^- \s*/, '');
|
|
453
|
+
}).filter(cmd => cmd.length > 0);
|
|
454
|
+
|
|
455
|
+
if (commands.length === 0) {
|
|
456
|
+
return { ok: true, skipped: true, reason: 'no-gates-declared' };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Execute each gate command
|
|
460
|
+
for (const cmd of commands) {
|
|
461
|
+
log(` Gate: ${cmd}`);
|
|
462
|
+
const result = spawnSync('bash', ['-c', cmd], {
|
|
463
|
+
cwd: rootDir,
|
|
464
|
+
encoding: 'utf8',
|
|
465
|
+
stdio: ['inherit', 'pipe', 'pipe']
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
if (result.status !== 0) {
|
|
469
|
+
const stderr = (result.stderr || '').trim();
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
gate: cmd,
|
|
473
|
+
reason: 'gate-failed',
|
|
474
|
+
error: stderr || `Gate exited with status ${result.status}`
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { ok: true, skipped: false, count: commands.length, reason: 'all-gates-passed' };
|
|
386
480
|
}
|
|
387
481
|
|
|
388
482
|
/**
|
|
@@ -419,16 +513,19 @@ function buildAutoCheckpointContent(slug) {
|
|
|
419
513
|
}
|
|
420
514
|
|
|
421
515
|
/**
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
function writeFallbackSummary(slug, summary, options = {}) {
|
|
431
|
-
|
|
516
|
+
* Write a fallback summary (`## Fallback:` heading) into the backlog task file.
|
|
517
|
+
* This is the in-scope equivalent of backlog.setTaskFinalSummary, implemented
|
|
518
|
+
* inline here since modifying backlog.js is out of scope for this mission.
|
|
519
|
+
* @param {string} slug - Mission slug
|
|
520
|
+
* @param {string} summary - The fallback summary text (must contain ## Fallback)
|
|
521
|
+
* @param {{rootDir?: string, log?: Function}} options
|
|
522
|
+
* @returns {boolean}
|
|
523
|
+
*/
|
|
524
|
+
function writeFallbackSummary(slug, summary, options = {}) {
|
|
525
|
+
/** @type{{rootDir?: string, log?: Function}} */
|
|
526
|
+
const opts = options;
|
|
527
|
+
const rootDir = opts.rootDir || process.cwd();
|
|
528
|
+
const log = opts.log || fmt.log.plain;
|
|
432
529
|
const resolution = backlog.resolveTaskFile(slug, rootDir);
|
|
433
530
|
if (!resolution.ok) {
|
|
434
531
|
log(fmt.status('WARN', `Could not write fallback summary for ${fmt.slug(slug)}: ${resolution.reason}`));
|
|
@@ -436,6 +533,11 @@ function writeFallbackSummary(slug, summary, options = {}) {
|
|
|
436
533
|
}
|
|
437
534
|
|
|
438
535
|
const taskFile = resolution.taskFile;
|
|
536
|
+
if (!taskFile) {
|
|
537
|
+
log(fmt.status('WARN', `Could not write fallback summary for ${fmt.slug(slug)}: task file not found.`));
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
/** @type{string} */
|
|
439
541
|
let content = fs.readFileSync(taskFile, 'utf8');
|
|
440
542
|
|
|
441
543
|
// Already present — no-op
|
|
@@ -478,6 +580,90 @@ function writeFallbackSummary(slug, summary, options = {}) {
|
|
|
478
580
|
return true;
|
|
479
581
|
}
|
|
480
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Capture NEL (Net Engineering Lines) at handoff time.
|
|
585
|
+
*
|
|
586
|
+
* Computes actual NEL from the merge diff (primary..HEAD), reads the predicted
|
|
587
|
+
* bucket from the mission's Refinement Signals, resolves review rounds from
|
|
588
|
+
* review-state.json, and persists a per-mission NEL record as `nel-record.json`.
|
|
589
|
+
*
|
|
590
|
+
* This is purely observational — no enforcement, gates, or blocks.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} slug - Mission slug
|
|
593
|
+
* @param {{ rootDir: string, missionDir: string, log: Function, error: Function }} options
|
|
594
|
+
* @returns {{ ok: boolean, nel?: number, bucket?: string, error?: string }}
|
|
595
|
+
*/
|
|
596
|
+
function captureNelAtHandoff(slug, options) {
|
|
597
|
+
const { rootDir, missionDir, error } = options;
|
|
598
|
+
|
|
599
|
+
// 1. Determine primary branch for diff range
|
|
600
|
+
let primaryBranch;
|
|
601
|
+
try {
|
|
602
|
+
primaryBranch = missionUtils.getPrimaryBranch(rootDir);
|
|
603
|
+
} catch (_) {
|
|
604
|
+
return { ok: false, error: 'could not detect primary branch for NEL diff range' };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (!primaryBranch) {
|
|
608
|
+
return { ok: false, error: 'primary branch is empty' };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 2. Compute actual NEL from primary..HEAD
|
|
612
|
+
let nelRecord;
|
|
613
|
+
try {
|
|
614
|
+
nelRecord = nels.computeNELRecord(`${primaryBranch}..HEAD`, { cwd: rootDir });
|
|
615
|
+
} catch (_) {
|
|
616
|
+
return { ok: false, error: 'NEL computation failed' };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const actualNel = nelRecord.nel;
|
|
620
|
+
const actualBucket = nelRecord.bucket.label;
|
|
621
|
+
|
|
622
|
+
// 3. Read predicted bucket from MISSION.md Refinement Signals
|
|
623
|
+
const missionMdPath = path.join(missionDir, 'MISSION.md');
|
|
624
|
+
let predictedBucket = 'Unknown';
|
|
625
|
+
if (fs.existsSync(missionMdPath)) {
|
|
626
|
+
const content = fs.readFileSync(missionMdPath, 'utf8');
|
|
627
|
+
const predictedMatch = content.match(/Predicted NEL bucket:\s*(Small|Medium|Large)/i);
|
|
628
|
+
if (predictedMatch) {
|
|
629
|
+
predictedBucket = predictedMatch[1];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 4. Read review rounds from review-state.json
|
|
634
|
+
let reviewRounds = 1;
|
|
635
|
+
const reviewStatePath = path.join(missionDir, 'review-state.json');
|
|
636
|
+
if (fs.existsSync(reviewStatePath)) {
|
|
637
|
+
try {
|
|
638
|
+
const rs = JSON.parse(fs.readFileSync(reviewStatePath, 'utf8'));
|
|
639
|
+
reviewRounds = rs.round || 1;
|
|
640
|
+
} catch (_) {
|
|
641
|
+
// ignore parse errors
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 5. Persist NEL record
|
|
646
|
+
const nelRecordPath = path.join(missionDir, 'nel-record.json');
|
|
647
|
+
const record = {
|
|
648
|
+
slug,
|
|
649
|
+
predictedBucket,
|
|
650
|
+
actualNel,
|
|
651
|
+
actualBucket,
|
|
652
|
+
reviewRounds,
|
|
653
|
+
capturedAt: new Date().toISOString(),
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
fs.writeFileSync(nelRecordPath, JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
658
|
+
} catch (err) {
|
|
659
|
+
error(`Failed to write NEL record: ${err.message}`);
|
|
660
|
+
return { ok: false, error: `failed to write NEL record: ${err.message}` };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return { ok: true, nel: actualNel, bucket: actualBucket };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** @param {string[]} args */
|
|
481
667
|
async function handoffCommand(args) {
|
|
482
668
|
const explicitSlug = args[0];
|
|
483
669
|
const slug = missionUtils.inferSlug(explicitSlug);
|
|
@@ -499,3 +685,5 @@ module.exports = handoffCommand;
|
|
|
499
685
|
module.exports.verifyHandoff = verifyHandoff;
|
|
500
686
|
module.exports.performHandoff = performHandoff;
|
|
501
687
|
module.exports.gatekeeper = gatekeeper;
|
|
688
|
+
module.exports.runDeclaredGates = runDeclaredGates;
|
|
689
|
+
module.exports.captureNelAtHandoff = captureNelAtHandoff;
|