@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.
Files changed (68) hide show
  1. package/config/integration-pipelines.json +9 -0
  2. package/config/workflow.config.schema.json +12 -0
  3. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
  4. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
  5. package/docs/adr/0041-integration-pipeline-gates.md +1 -1
  6. package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
  7. package/docs/adr/index.md +1 -0
  8. package/docs/use-cases.md +44 -0
  9. package/lib/agents/agents.js +149 -100
  10. package/lib/agents/claude-telemetry.js +14 -10
  11. package/lib/agents/claude.js +32 -11
  12. package/lib/agents/codex-telemetry.js +32 -19
  13. package/lib/agents/codex.js +36 -11
  14. package/lib/agents/limit-hit.js +42 -25
  15. package/lib/agents/mistral-telemetry.js +1 -1
  16. package/lib/agents/mistral.js +13 -3
  17. package/lib/agents/opencode-export.js +11 -5
  18. package/lib/agents/opencode-telemetry.js +75 -90
  19. package/lib/agents/opencode.js +86 -29
  20. package/lib/agents/stage-telemetry.js +3 -7
  21. package/lib/commands/active.js +139 -94
  22. package/lib/commands/checkpoint.js +3 -1
  23. package/lib/commands/config.js +5 -3
  24. package/lib/commands/coverage-gate.js +17 -10
  25. package/lib/commands/diff.js +11 -5
  26. package/lib/commands/draft.js +95 -55
  27. package/lib/commands/handoff.js +253 -65
  28. package/lib/commands/integrate.js +288 -188
  29. package/lib/commands/mission-start.js +39 -34
  30. package/lib/commands/rebase.js +32 -21
  31. package/lib/commands/repair-handoff.js +21 -18
  32. package/lib/commands/resolve-conflict.js +5 -2
  33. package/lib/commands/review.js +1 -0
  34. package/lib/commands/setup-review.js +1 -0
  35. package/lib/commands/stats-backfill.js +50 -41
  36. package/lib/commands/stats.js +653 -204
  37. package/lib/commands/status.js +33 -28
  38. package/lib/core/fmt.js +58 -15
  39. package/lib/core/git.js +7 -5
  40. package/lib/core/gitignore.js +6 -4
  41. package/lib/core/mission-utils.js +161 -88
  42. package/lib/core/nels.js +199 -0
  43. package/lib/core/persistent-data-migration.js +55 -23
  44. package/lib/core/product-config.js +44 -23
  45. package/lib/core/runtime-matrix.js +15 -2
  46. package/lib/core/spawn-tee.js +40 -13
  47. package/lib/core/state-map.js +32 -13
  48. package/lib/core/storage.js +19 -5
  49. package/lib/core/subagent-limit.js +28 -0
  50. package/lib/core/verification.js +29 -12
  51. package/lib/review/rebase.js +11 -3
  52. package/lib/review/review-adapter.js +93 -6
  53. package/lib/review/review-artifacts.js +89 -23
  54. package/lib/review/review-commands.js +139 -45
  55. package/lib/review/review-events.js +103 -52
  56. package/lib/review/review-loop.js +117 -87
  57. package/lib/review/review-polling.js +25 -5
  58. package/lib/review/review-prompts.js +36 -5
  59. package/lib/review/review-state.js +39 -15
  60. package/lib/tools/backlog.js +162 -71
  61. package/lib/tools/forgejo.js +290 -129
  62. package/lib/tools/gatekeeper.js +15 -0
  63. package/lib/tools/redgreen.js +220 -0
  64. package/lib/tools/sessions.js +12 -5
  65. package/lib/tools/setup-review.js +170 -70
  66. package/package.json +11 -3
  67. package/prompts/draft.md +6 -0
  68. package/templates/mission-scaffold.md +1 -1
@@ -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
- * Verifies that the current environment is ready for handoff.
17
- *
18
- * @param {string} slug - Mission slug
19
- * @param {object} [options]
20
- * @returns {{ ok: boolean, error?: string, missionDir?: string, area?: string, branch?: string, rootDir?: string }}
21
- */
22
- function verifyHandoff(slug, options = {}) {
23
- const rootDir = options.worktree || process.cwd();
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
- * Performs the handoff process for a mission:
47
- * 1. Runs the verification gate.
48
- * 2. Syncs primary branch and pushes the mission branch to Forgejo, creating or updating the PR.
49
- * 3. Transitions Backlog task to 'review'.
50
- * 4. Commits and pushes the Backlog state change to Forgejo.
51
- *
52
- * @param {string} slug - Mission slug
53
- * @param {object} [options]
54
- * @returns {Promise<{ ok: boolean, error?: string }>}
55
- */
56
- async function performHandoff(slug, options = {}) {
57
- const {
58
- skipGate = false,
59
- worktree = null,
60
- force = false,
61
- forceWithLease = true,
62
- log = fmt.log.info,
63
- error = fmt.log.fail,
64
- rebaseFn = rebaseBeforeReviewRound
65
- } = options;
66
-
67
- const verification = verifyHandoff(slug, { worktree });
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, rootDir } = verification;
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(verification.missionDir, 'MISSION.md'));
92
+ const relativeMissionPath = path.relative(rootDir, path.join(missionDirPath, 'MISSION.md'));
85
93
  const dirtyFiles = git.getWorktreeStatus(rootDir);
86
- let checkpoints = missionUtils.findCheckpoints(verification.missionDir);
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(verification.missionDir, 'CP-1.md');
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(verification.missionDir)}.`));
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(verification.missionDir);
112
+ checkpoints = missionUtils.findCheckpoints(missionDirPath);
105
113
  if (checkpoints.length === 0) {
106
- const msg = `No checkpoint documents found in ${fmt.path(verification.missionDir)} even after auto-remediation. Implementation evidence is mandatory for review.`;
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 afterHeader = checkpointContent.slice(goalCheckMatch.index + goalCheckMatch[0].length);
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 = options.isForgejoReviewEnabledFn || isForgejoReviewEnabled;
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 (${bootstrapResult.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.`;
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
- * Write a fallback summary (`## Fallback:` heading) into the backlog task file.
423
- * This is the in-scope equivalent of backlog.setTaskFinalSummary, implemented
424
- * inline here since modifying backlog.js is out of scope for this mission.
425
- * @param {string} slug - Mission slug
426
- * @param {string} summary - The fallback summary text (must contain ## Fallback)
427
- * @param {object} options
428
- * @returns {boolean}
429
- */
430
- function writeFallbackSummary(slug, summary, options = {}) {
431
- const { rootDir = process.cwd(), log = fmt.log.plain } = options;
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;