@magnusekdahl/parallix 1.2.1 → 1.3.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 (79) hide show
  1. package/index.js +190 -185
  2. package/lib/commands/active.js +502 -579
  3. package/lib/commands/active.ts +665 -0
  4. package/lib/commands/checkpoint.js +98 -74
  5. package/lib/commands/checkpoint.ts +79 -0
  6. package/lib/commands/config.js +61 -31
  7. package/lib/commands/config.ts +47 -0
  8. package/lib/commands/coverage-gate.js +271 -246
  9. package/lib/commands/coverage-gate.ts +362 -0
  10. package/lib/commands/diff.js +140 -119
  11. package/lib/commands/diff.ts +125 -0
  12. package/lib/commands/draft.js +770 -923
  13. package/lib/commands/draft.ts +1021 -0
  14. package/lib/commands/handoff.js +588 -608
  15. package/lib/commands/handoff.ts +694 -0
  16. package/lib/commands/integrate.js +1331 -1468
  17. package/lib/commands/integrate.ts +1688 -0
  18. package/lib/commands/mission-start.js +279 -237
  19. package/lib/commands/mission-start.ts +263 -0
  20. package/lib/commands/rebase.js +606 -582
  21. package/lib/commands/rebase.ts +635 -0
  22. package/lib/commands/repair-handoff.js +181 -179
  23. package/lib/commands/repair-handoff.ts +228 -0
  24. package/lib/commands/resolve-conflict.js +121 -102
  25. package/lib/commands/resolve-conflict.ts +112 -0
  26. package/lib/commands/review.js +3 -4
  27. package/lib/commands/review.ts +14 -0
  28. package/lib/commands/setup-review.js +4 -5
  29. package/lib/commands/setup-review.ts +14 -0
  30. package/lib/commands/setup.js +3 -3
  31. package/lib/commands/setup.ts +2 -0
  32. package/lib/commands/stats-backfill.js +394 -359
  33. package/lib/commands/stats-backfill.ts +418 -0
  34. package/lib/commands/stats.js +1424 -1665
  35. package/lib/commands/stats.ts +2186 -0
  36. package/lib/commands/status.js +219 -200
  37. package/lib/commands/status.ts +219 -0
  38. package/lib/commands/verify.js +3 -1
  39. package/lib/commands/verify.ts +2 -0
  40. package/lib/core/nels.js +112 -127
  41. package/lib/core/nels.ts +189 -0
  42. package/lib/core/subagent-limit.js +15 -16
  43. package/lib/core/subagent-limit.ts +25 -0
  44. package/lib/index.js +142 -59
  45. package/lib/index.ts +134 -0
  46. package/lib/review/rebase.js +142 -145
  47. package/lib/review/rebase.ts +188 -0
  48. package/lib/review/review-adapter.js +99 -87
  49. package/lib/review/review-adapter.ts +198 -0
  50. package/lib/review/review-artifacts.js +486 -629
  51. package/lib/review/review-artifacts.ts +622 -0
  52. package/lib/review/review-commands.js +1220 -1373
  53. package/lib/review/review-commands.ts +1579 -0
  54. package/lib/review/review-events.js +544 -886
  55. package/lib/review/review-events.ts +954 -0
  56. package/lib/review/review-loop.js +945 -1020
  57. package/lib/review/review-loop.ts +1174 -0
  58. package/lib/review/review-polling.js +86 -115
  59. package/lib/review/review-polling.ts +194 -0
  60. package/lib/review/review-prompts.js +154 -189
  61. package/lib/review/review-prompts.ts +182 -0
  62. package/lib/review/review-state.js +246 -225
  63. package/lib/review/review-state.ts +353 -0
  64. package/lib/review/review.js +63 -84
  65. package/lib/review/review.ts +138 -0
  66. package/lib/tools/backlog.js +694 -680
  67. package/lib/tools/backlog.ts +835 -0
  68. package/lib/tools/forgejo.js +1270 -1405
  69. package/lib/tools/forgejo.ts +1793 -0
  70. package/lib/tools/gatekeeper.js +106 -96
  71. package/lib/tools/gatekeeper.ts +124 -0
  72. package/lib/tools/redgreen.js +200 -181
  73. package/lib/tools/redgreen.ts +221 -0
  74. package/lib/tools/sessions.js +58 -52
  75. package/lib/tools/sessions.ts +81 -0
  76. package/lib/tools/setup-review.js +902 -975
  77. package/lib/tools/setup-review.ts +1152 -0
  78. package/package.json +4 -4
  79. package/px.js +226 -208
@@ -1,19 +1,53 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { spawnSync } = require('child_process');
4
- const git = require('../core/git');
5
- const missionUtils = require('../core/mission-utils');
6
- const backlog = require('../tools/backlog');
7
- const forgejo = require('../tools/forgejo');
8
- const { resolveReviewIdentity } = require('../review/review-state');
9
- const setupReview = require('../tools/setup-review');
10
- const gatekeeper = require('../tools/gatekeeper');
11
- const fmt = require('../core/fmt');
12
- const { runVerificationGate } = require('../core/verification');
13
- const { isForgejoReviewEnabled } = require('../core/product-config');
14
- const { rebaseBeforeReviewRound } = require('../review/rebase');
15
- const nels = require('../core/nels');
16
-
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ // @ts-nocheck
36
+ const fs = __importStar(require("node:fs"));
37
+ const path = __importStar(require("node:path"));
38
+ const node_child_process_1 = require("node:child_process");
39
+ const git = __importStar(require("../core/git.js"));
40
+ const missionUtils = __importStar(require("../core/mission-utils.js"));
41
+ const backlog = __importStar(require("../tools/backlog.js"));
42
+ const forgejo = __importStar(require("../tools/forgejo.js"));
43
+ const review_state_js_1 = require("../review/review-state.js");
44
+ const setupReview = __importStar(require("../tools/setup-review.js"));
45
+ const gatekeeper = __importStar(require("../tools/gatekeeper.js"));
46
+ const fmt = __importStar(require("../core/fmt.js"));
47
+ const verification_js_1 = require("../core/verification.js");
48
+ const product_config_js_1 = require("../core/product-config.js");
49
+ const rebase_js_1 = require("../review/rebase.js");
50
+ const nels = __importStar(require("../core/nels.js"));
17
51
  /**
18
52
  * Verifies that the current environment is ready for handoff.
19
53
  *
@@ -21,31 +55,26 @@ const nels = require('../core/nels');
21
55
  * @param {{worktree?: string}} [options]
22
56
  * @returns {{ ok: boolean, error?: string, missionDir?: string, area?: string, branch?: string, rootDir?: string }}
23
57
  */
24
- function verifyHandoff(slug, options = {}) {
25
- /** @type{{worktree?: string}} */
26
- const opts = options;
27
- const rootDir = opts.worktree || process.cwd();
28
- const missionDir = missionUtils.findMissionDir(slug, rootDir);
29
- if (!missionDir) {
30
- return { ok: false, error: `Mission directory not found for slug: ${slug}` };
31
- }
32
-
33
- const area = missionUtils.findMissionArea(missionDir);
34
- const branch = missionUtils.missionBranchName(slug, rootDir);
35
- const current = git.getCurrentBranch(rootDir);
36
-
37
- if (current !== branch) {
38
- return { ok: false, error: `Not on mission branch. Current: ${current}, Expected: ${branch}` };
39
- }
40
-
41
- const missionMdPath = path.join(missionDir, 'MISSION.md');
42
- if (!fs.existsSync(missionMdPath)) {
43
- return { ok: false, error: `MISSION.md not found at ${missionMdPath}. The mission contract must exist before handoff.` };
44
- }
45
-
46
- return { ok: true, missionDir, area, branch, rootDir };
58
+ function verifyHandoff(slug, options = {}) {
59
+ /** @type{{worktree?: string}} */
60
+ const opts = options;
61
+ const rootDir = opts.worktree || process.cwd();
62
+ const missionDir = missionUtils.findMissionDir(slug, rootDir);
63
+ if (!missionDir) {
64
+ return { ok: false, error: `Mission directory not found for slug: ${slug}` };
65
+ }
66
+ const area = missionUtils.findMissionArea(missionDir);
67
+ const branch = missionUtils.missionBranchName(slug, rootDir);
68
+ const current = git.getCurrentBranch(rootDir);
69
+ if (current !== branch) {
70
+ return { ok: false, error: `Not on mission branch. Current: ${current}, Expected: ${branch}` };
71
+ }
72
+ const missionMdPath = path.join(missionDir, 'MISSION.md');
73
+ if (!fs.existsSync(missionMdPath)) {
74
+ return { ok: false, error: `MISSION.md not found at ${missionMdPath}. The mission contract must exist before handoff.` };
75
+ }
76
+ return { ok: true, missionDir, area, branch, rootDir };
47
77
  }
48
-
49
78
  /**
50
79
  * Performs the handoff process for a mission:
51
80
  * 1. Runs the verification gate.
@@ -57,369 +86,341 @@ const nels = require('../core/nels');
57
86
  * @param {{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function}} [options]
58
87
  * @returns {Promise<{ ok: boolean, error?: string, gatekeeperPushedBack?: boolean }>}
59
88
  */
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 });
74
- if (!verification.ok) {
75
- error(verification.error);
76
- return { ok: false, error: verification.error };
77
- }
78
-
79
- const { area, branch, missionDir } = verification;
80
- const rootDir = /** @type {string} */(verification.rootDir);
81
- const missionDirPath = /** @type {string} */(missionDir);
82
-
83
- // Step 0: Resolve Backlog task for identity derivation
84
- const taskResolution = backlog.resolveTaskFile(slug, rootDir);
85
- if (!taskResolution.ok) {
86
- const msg = `Backlog task file for ${fmt.slug(slug)} not found or ambiguous: ${taskResolution.reason}.`;
87
- error(msg);
88
- return { ok: false, error: msg };
89
- }
90
-
91
- // Pre-handoff Content Integrity Check
92
- const relativeMissionPath = path.relative(rootDir, path.join(missionDirPath, 'MISSION.md'));
93
- const dirtyFiles = git.getWorktreeStatus(rootDir);
94
- let checkpoints = missionUtils.findCheckpoints(missionDirPath);
95
-
96
- if (dirtyFiles.some(line => line.endsWith(relativeMissionPath))) {
97
- const msg = `${fmt.path('MISSION.md')} is modified but uncommitted at ${fmt.path(relativeMissionPath)}. Commit the mission contract before handoff.`;
98
- error(msg);
99
- return { ok: false, error: msg };
100
- }
101
-
102
- if (checkpoints.length === 0) {
103
- // Auto-remediation (task-1228): rather than hard-failing when no checkpoint
104
- // document exists, generate a minimal default CP-1.md with a valid Goal Check
105
- // table so the handoff can proceed without manual intervention. The generated
106
- // file is clearly marked as auto-generated for reviewer awareness, then we
107
- // re-scan to confirm it is discoverable via findCheckpoints().
108
- const autoCheckpointPath = path.join(missionDirPath, 'CP-1.md');
109
- fs.writeFileSync(autoCheckpointPath, buildAutoCheckpointContent(slug), 'utf8');
110
- log(fmt.status('WARN', `No checkpoint documents found — auto-generated ${fmt.path('CP-1.md')} in ${fmt.path(missionDirPath)}.`));
111
-
112
- checkpoints = missionUtils.findCheckpoints(missionDirPath);
89
+ async function performHandoff(slug, options = {}) {
90
+ /** @type{{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function}} */
91
+ const opts = options;
92
+ const { skipGate = false, worktree = null, force = false, forceWithLease = true, log = fmt.log.info, error = fmt.log.fail, rebaseFn = rebase_js_1.rebaseBeforeReviewRound } = opts;
93
+ const verification = verifyHandoff(slug, { worktree: worktree || undefined });
94
+ if (!verification.ok) {
95
+ error(verification.error);
96
+ return { ok: false, error: verification.error };
97
+ }
98
+ const { area, branch, missionDir } = verification;
99
+ const rootDir = /** @type {string} */ (verification.rootDir);
100
+ const missionDirPath = /** @type {string} */ (missionDir);
101
+ // Step 0: Resolve Backlog task for identity derivation
102
+ const taskResolution = backlog.resolveTaskFile(slug, rootDir);
103
+ if (!taskResolution.ok) {
104
+ const msg = `Backlog task file for ${fmt.slug(slug)} not found or ambiguous: ${taskResolution.reason}.`;
105
+ error(msg);
106
+ return { ok: false, error: msg };
107
+ }
108
+ // Pre-handoff Content Integrity Check
109
+ const relativeMissionPath = path.relative(rootDir, path.join(missionDirPath, 'MISSION.md'));
110
+ const dirtyFiles = git.getWorktreeStatus(rootDir);
111
+ let checkpoints = missionUtils.findCheckpoints(missionDirPath);
112
+ if (dirtyFiles.some(line => line.endsWith(relativeMissionPath))) {
113
+ const msg = `${fmt.path('MISSION.md')} is modified but uncommitted at ${fmt.path(relativeMissionPath)}. Commit the mission contract before handoff.`;
114
+ error(msg);
115
+ return { ok: false, error: msg };
116
+ }
113
117
  if (checkpoints.length === 0) {
114
- const msg = `No checkpoint documents found in ${fmt.path(missionDirPath)} even after auto-remediation. Implementation evidence is mandatory for review.`;
115
- error(msg);
116
- return { ok: false, error: msg };
117
- }
118
-
119
- // Commit the auto-generated checkpoint so it is included in the handoff push
120
- // and does not trip the uncommitted-checkpoint check below.
121
- git.git(['-C', rootDir, 'add', autoCheckpointPath]);
122
- const commitRes = git.git(['-C', rootDir, 'commit', '-m', `docs(${slug}): auto-generate CP-1.md checkpoint (handoff remediation)`]);
123
- if (commitRes.status !== 0) {
124
- log(fmt.status('WARN', `Could not commit auto-generated CP-1.md for ${fmt.slug(slug)}; continuing handoff.`));
125
- }
126
- }
127
-
128
- /** @type {string} */
129
- const finalCheckpoint = checkpoints[checkpoints.length - 1];
130
- const relativeCheckpointPath = path.relative(rootDir, finalCheckpoint);
131
- if (dirtyFiles.some(line => line.endsWith(relativeCheckpointPath))) {
132
- const msg = `The latest checkpoint document is modified but uncommitted at ${fmt.path(relativeCheckpointPath)}. Commit the implementation evidence before handoff.`;
133
- error(msg);
134
- return { ok: false, error: msg };
135
- }
136
-
137
- // Pre-handoff Content Integrity Check: final checkpoint must contain a Goal Check table
138
- // with real evidence. Per review.md step 5, a missing or empty goal-check table
139
- // means the checkpoint has not satisfied the mission's evidence requirement.
140
- // Accept both `## Goal Check` and `## Goal Check Table` heading variants used across repo artifacts.
141
- const checkpointContent = fs.readFileSync(finalCheckpoint, 'utf8');
142
- const goalCheckMatch = checkpointContent.match(/^## Goal Check(?: Table)?\s*$/m);
143
- if (!goalCheckMatch) {
144
- const msg = `The final checkpoint at ${fmt.path(relativeCheckpointPath)} is missing a "## Goal Check" section. Review requires a goal-check table with real evidence before handoff.`;
145
- error(msg);
146
- return { ok: false, error: msg };
147
- }
148
-
149
- // Verify the goal-check table has at least one row of evidence (table row after header).
150
- // Must exclude table separator rows (|---|---|---|) and the header row itself —
151
- // only real evidence rows (with pipe-separated content that is not all dashes) count.
152
- const goalCheckMatchIndex = goalCheckMatch.index ?? 0;
153
- const afterHeader = checkpointContent.slice(goalCheckMatchIndex + goalCheckMatch[0].length);
154
- const separatorPattern = /^\|\s*:?-+:?\s*\|/;
155
- const headerPattern = /^\| .+\| .+\| .+\|$/;
156
- const evidenceLinePattern = /^\| .+\| .+\| .+\|$/;
157
- const linesAfterHeader = afterHeader.split('\n');
158
- let foundEvidence = false;
159
- let pastHeader = false;
160
- for (const line of linesAfterHeader) {
161
- const trimmed = line.trim();
162
- if (trimmed === '') {continue;}
163
- if (!pastHeader && headerPattern.test(trimmed)) {
164
- pastHeader = true;
165
- continue;
166
- }
167
- if (separatorPattern.test(trimmed)) {continue;}
168
- if (pastHeader && evidenceLinePattern.test(trimmed)) {
169
- foundEvidence = true;
170
- break;
171
- }
172
- // Non-empty, non-separator, non-evidence row after header = no valid table
173
- break;
174
- }
175
- if (!foundEvidence) {
176
- const msg = `The final checkpoint at ${fmt.path(relativeCheckpointPath)} has a "## Goal Check" section but no evidence rows. A goal-check table with real evidence is required before handoff.`;
177
- error(msg);
178
- return { ok: false, error: msg };
179
- }
180
-
181
- const isForgejoReviewEnabledFn = opts.isForgejoReviewEnabledFn || isForgejoReviewEnabled;
182
- const forgejoEnabled = isForgejoReviewEnabledFn(rootDir);
183
-
184
- const { forgejoUser: reviewStateUser } = resolveReviewIdentity(slug, rootDir, {
185
- });
186
- const forgejoUser = reviewStateUser || backlog.getTaskImplementer(/** @type {string} */(taskResolution.taskFile));
187
-
188
- if (!forgejoUser) {
189
- error('forgejoUser is required for performHandoff. Ensure review-state.json or the Backlog task has an agent family assigned.');
190
- return { ok: false, error: 'forgejoUser is required' };
191
- }
192
-
193
- log(`Starting handoff for mission ${fmt.slug(slug)}...`);
194
-
195
- // Step 1: Final Gate Run
196
- if (skipGate) {
197
- fmt.log.warn('Step 1: Skipping final verification gate (--no-gate)');
198
- } else {
199
- log(`Step 1: Running final verification gate for area: ${fmt.bold(area || 'docs')}...`);
200
- const verifyResult = runVerificationGate(area || 'docs', {
201
- rootDir,
202
- stdio: 'inherit',
203
- runFn: git.run
204
- });
205
- if (verifyResult.status !== 0) {
206
- const msg = 'Final verification gate failed. Fix errors before submitting or use --no-gate if appropriate.';
207
- error(msg);
208
- return { ok: false, error: msg };
209
- }
210
- }
211
-
212
- // Step 1.5: Rebase mission branch onto latest primary before PR creation
213
- log('Step 1.5: Rebasing onto primary branch before handoff...');
214
- const rebaseResult = await rebaseFn(slug, {
215
- worktree: worktree || undefined,
216
- log,
217
- error,
218
- isForgejoReviewEnabledFn: isForgejoReviewEnabledFn,
219
- });
220
- if (!rebaseResult.ok) {
221
- if (rebaseResult.sharedFileConflicts) {
222
- const msg = 'Rebase encountered shared-file conflicts. Resolve the conflicts in the worktree, then re-run handoff.';
223
- error(msg);
224
- return { ok: false, error: msg };
225
- } else {
226
- const msg = 'Rebase failed before handoff. Ensure the mission branch can be rebased onto the latest primary branch.';
227
- error(msg);
228
- return { ok: false, error: msg };
229
- }
230
- }
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
-
241
- // Step 2: Forgejo PR Update/Create (optional mirror when Forgejo is enabled)
242
- let token = null;
243
- let fallbackUser = null;
244
- let bootstrapFailureReason = null;
245
- if (forgejoEnabled) {
246
- log(`Step 2: Updating/Creating Forgejo PR as user ${fmt.agent(forgejoUser)}...`);
247
- token = forgejo.readToken(forgejoUser);
248
- if (!token) {
249
- // Token missing for the agent user — attempt non-interactive bootstrap
250
- error(`Token not found for ${fmt.agent(forgejoUser)}. Attempting non-interactive bootstrap...`);
251
-
252
- const reviewSettings = forgejo.resolveForgejoSettings(rootDir);
253
- const bootstrapSetup = {
254
- baseUrl: reviewSettings.url,
255
- repo: reviewSettings.repo,
256
- ownerLogin: 'human',
257
- ownerPassword: '',
258
- agentPasswords: [{ user: forgejoUser, password: '' }],
259
- };
260
- const bootstrapResult = await setupReview.bootstrapReviewSurface(rootDir, bootstrapSetup, {
261
- interactive: false,
262
- requestFn: setupReview.apiRequest,
118
+ // Auto-remediation (task-1228): rather than hard-failing when no checkpoint
119
+ // document exists, generate a minimal default CP-1.md with a valid Goal Check
120
+ // table so the handoff can proceed without manual intervention. The generated
121
+ // file is clearly marked as auto-generated for reviewer awareness, then we
122
+ // re-scan to confirm it is discoverable via findCheckpoints().
123
+ const autoCheckpointPath = path.join(missionDirPath, 'CP-1.md');
124
+ fs.writeFileSync(autoCheckpointPath, buildAutoCheckpointContent(slug), 'utf8');
125
+ log(fmt.status('WARN', `No checkpoint documents found — auto-generated ${fmt.path('CP-1.md')} in ${fmt.path(missionDirPath)}.`));
126
+ checkpoints = missionUtils.findCheckpoints(missionDirPath);
127
+ if (checkpoints.length === 0) {
128
+ const msg = `No checkpoint documents found in ${fmt.path(missionDirPath)} even after auto-remediation. Implementation evidence is mandatory for review.`;
129
+ error(msg);
130
+ return { ok: false, error: msg };
131
+ }
132
+ // Commit the auto-generated checkpoint so it is included in the handoff push
133
+ // and does not trip the uncommitted-checkpoint check below.
134
+ git.git(['-C', rootDir, 'add', autoCheckpointPath]);
135
+ const commitRes = git.git(['-C', rootDir, 'commit', '-m', `docs(${slug}): auto-generate CP-1.md checkpoint (handoff remediation)`]);
136
+ if (commitRes.status !== 0) {
137
+ log(fmt.status('WARN', `Could not commit auto-generated CP-1.md for ${fmt.slug(slug)}; continuing handoff.`));
138
+ }
139
+ }
140
+ /** @type {string} */
141
+ const finalCheckpoint = checkpoints[checkpoints.length - 1];
142
+ const relativeCheckpointPath = path.relative(rootDir, finalCheckpoint);
143
+ if (dirtyFiles.some(line => line.endsWith(relativeCheckpointPath))) {
144
+ const msg = `The latest checkpoint document is modified but uncommitted at ${fmt.path(relativeCheckpointPath)}. Commit the implementation evidence before handoff.`;
145
+ error(msg);
146
+ return { ok: false, error: msg };
147
+ }
148
+ // Pre-handoff Content Integrity Check: final checkpoint must contain a Goal Check table
149
+ // with real evidence. Per review.md step 5, a missing or empty goal-check table
150
+ // means the checkpoint has not satisfied the mission's evidence requirement.
151
+ // Accept both `## Goal Check` and `## Goal Check Table` heading variants used across repo artifacts.
152
+ const checkpointContent = fs.readFileSync(finalCheckpoint, 'utf8');
153
+ const goalCheckMatch = checkpointContent.match(/^## Goal Check(?: Table)?\s*$/m);
154
+ if (!goalCheckMatch) {
155
+ const msg = `The final checkpoint at ${fmt.path(relativeCheckpointPath)} is missing a "## Goal Check" section. Review requires a goal-check table with real evidence before handoff.`;
156
+ error(msg);
157
+ return { ok: false, error: msg };
158
+ }
159
+ // Verify the goal-check table has at least one row of evidence (table row after header).
160
+ // Must exclude table separator rows (|---|---|---|) and the header row itself —
161
+ // only real evidence rows (with pipe-separated content that is not all dashes) count.
162
+ const goalCheckMatchIndex = goalCheckMatch.index ?? 0;
163
+ const afterHeader = checkpointContent.slice(goalCheckMatchIndex + goalCheckMatch[0].length);
164
+ const separatorPattern = /^\|\s*:?-+:?\s*\|/;
165
+ const headerPattern = /^\| .+\| .+\| .+\|$/;
166
+ const evidenceLinePattern = /^\| .+\| .+\| .+\|$/;
167
+ const linesAfterHeader = afterHeader.split('\n');
168
+ let foundEvidence = false;
169
+ let pastHeader = false;
170
+ for (const line of linesAfterHeader) {
171
+ const trimmed = line.trim();
172
+ if (trimmed === '') {
173
+ continue;
174
+ }
175
+ if (!pastHeader && headerPattern.test(trimmed)) {
176
+ pastHeader = true;
177
+ continue;
178
+ }
179
+ if (separatorPattern.test(trimmed)) {
180
+ continue;
181
+ }
182
+ if (pastHeader && evidenceLinePattern.test(trimmed)) {
183
+ foundEvidence = true;
184
+ break;
185
+ }
186
+ // Non-empty, non-separator, non-evidence row after header = no valid table
187
+ break;
188
+ }
189
+ if (!foundEvidence) {
190
+ const msg = `The final checkpoint at ${fmt.path(relativeCheckpointPath)} has a "## Goal Check" section but no evidence rows. A goal-check table with real evidence is required before handoff.`;
191
+ error(msg);
192
+ return { ok: false, error: msg };
193
+ }
194
+ const isForgejoReviewEnabledFn = opts.isForgejoReviewEnabledFn || product_config_js_1.isForgejoReviewEnabled;
195
+ const forgejoEnabled = isForgejoReviewEnabledFn(rootDir);
196
+ const { forgejoUser: reviewStateUser } = (0, review_state_js_1.resolveReviewIdentity)(slug, rootDir, {});
197
+ const forgejoUser = reviewStateUser || backlog.getTaskImplementer(/** @type {string} */ (taskResolution.taskFile));
198
+ if (!forgejoUser) {
199
+ error('forgejoUser is required for performHandoff. Ensure review-state.json or the Backlog task has an agent family assigned.');
200
+ return { ok: false, error: 'forgejoUser is required' };
201
+ }
202
+ log(`Starting handoff for mission ${fmt.slug(slug)}...`);
203
+ // Step 1: Final Gate Run
204
+ if (skipGate) {
205
+ fmt.log.warn('Step 1: Skipping final verification gate (--no-gate)');
206
+ }
207
+ else {
208
+ log(`Step 1: Running final verification gate for area: ${fmt.bold(area || 'docs')}...`);
209
+ const verifyResult = (0, verification_js_1.runVerificationGate)(area || 'docs', {
210
+ rootDir,
211
+ stdio: 'inherit',
212
+ runFn: git.run
213
+ });
214
+ if (verifyResult.status !== 0) {
215
+ const msg = 'Final verification gate failed. Fix errors before submitting or use --no-gate if appropriate.';
216
+ error(msg);
217
+ return { ok: false, error: msg };
218
+ }
219
+ }
220
+ // Step 1.5: Rebase mission branch onto latest primary before PR creation
221
+ log('Step 1.5: Rebasing onto primary branch before handoff...');
222
+ const rebaseResult = await rebaseFn(slug, {
223
+ worktree: worktree || undefined,
263
224
  log,
264
- });
265
-
266
- if (bootstrapResult.ok) {
267
- log(fmt.status('PASS', `Bootstrap succeeded for ${fmt.agent(forgejoUser)}.`));
225
+ error,
226
+ isForgejoReviewEnabledFn: isForgejoReviewEnabledFn,
227
+ });
228
+ if (!rebaseResult.ok) {
229
+ if (rebaseResult.sharedFileConflicts) {
230
+ const msg = 'Rebase encountered shared-file conflicts. Resolve the conflicts in the worktree, then re-run handoff.';
231
+ error(msg);
232
+ return { ok: false, error: msg };
233
+ }
234
+ else {
235
+ const msg = 'Rebase failed before handoff. Ensure the mission branch can be rebased onto the latest primary branch.';
236
+ error(msg);
237
+ return { ok: false, error: msg };
238
+ }
239
+ }
240
+ // Step 1.7: NEL capture — compute actual NEL from merge diff and persist record
241
+ log('Step 1.7: Capturing Net Engineering Lines (NEL) at handoff...');
242
+ const nelResult = captureNelAtHandoff(slug, { rootDir, missionDir: missionDirPath, log, error });
243
+ if (nelResult.ok) {
244
+ log(fmt.status('PASS', `NEL captured: ${nelResult.nel} NEL (${nelResult.bucket.label} bucket)`));
245
+ }
246
+ else {
247
+ log(fmt.status('WARN', `NEL capture skipped: ${nelResult.error}`));
248
+ }
249
+ // Step 2: Forgejo PR Update/Create (optional mirror when Forgejo is enabled)
250
+ let token = null;
251
+ let fallbackUser = null;
252
+ let bootstrapFailureReason = null;
253
+ if (forgejoEnabled) {
254
+ log(`Step 2: Updating/Creating Forgejo PR as user ${fmt.agent(forgejoUser)}...`);
268
255
  token = forgejo.readToken(forgejoUser);
269
256
  if (!token) {
270
- bootstrapFailureReason = 'bootstrap completed but token file for the agent user was not found';
271
- error('Bootstrap completed but token file for the agent user was not found. Falling back to default user.');
257
+ // Token missing for the agent user — attempt non-interactive bootstrap
258
+ error(`Token not found for ${fmt.agent(forgejoUser)}. Attempting non-interactive bootstrap...`);
259
+ const reviewSettings = forgejo.resolveForgejoSettings(rootDir);
260
+ const bootstrapSetup = {
261
+ baseUrl: reviewSettings.url,
262
+ repo: reviewSettings.repo,
263
+ ownerLogin: 'human',
264
+ ownerPassword: '',
265
+ agentPasswords: [{ user: forgejoUser, password: '' }],
266
+ };
267
+ const bootstrapResult = await setupReview.bootstrapReviewSurface(rootDir, bootstrapSetup, {
268
+ interactive: false,
269
+ requestFn: setupReview.apiRequest,
270
+ log,
271
+ });
272
+ if (bootstrapResult.ok) {
273
+ log(fmt.status('PASS', `Bootstrap succeeded for ${fmt.agent(forgejoUser)}.`));
274
+ token = forgejo.readToken(forgejoUser);
275
+ if (!token) {
276
+ bootstrapFailureReason = 'bootstrap completed but token file for the agent user was not found';
277
+ error('Bootstrap completed but token file for the agent user was not found. Falling back to default user.');
278
+ }
279
+ }
280
+ else {
281
+ bootstrapFailureReason = bootstrapResult.error || 'unknown';
282
+ error(`Bootstrap for ${fmt.agent(forgejoUser)} failed: ${bootstrapFailureReason}. Falling back to default user.`);
283
+ }
284
+ // Handle bootstrap result that may not have error property
285
+ /** @type{{error?: string}} */
286
+ const br = bootstrapResult;
287
+ // Magnus fallback if bootstrap didn't produce a token
288
+ if (!token) {
289
+ token = forgejo.readToken('human');
290
+ if (token) {
291
+ fallbackUser = 'human';
292
+ log(`Token not found for ${fmt.agent(forgejoUser)} and bootstrap did not succeed. Falling back to PR creation as ${fmt.agent(fallbackUser)}.`);
293
+ }
294
+ else {
295
+ 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.`;
296
+ error(msg);
297
+ return { ok: false, error: msg };
298
+ }
299
+ }
272
300
  }
273
- } else {
274
- bootstrapFailureReason = bootstrapResult.error || 'unknown';
275
- error(`Bootstrap for ${fmt.agent(forgejoUser)} failed: ${bootstrapFailureReason}. Falling back to default user.`);
276
- }
277
-
278
- // Handle bootstrap result that may not have error property
279
- /** @type{{error?: string}} */
280
- const br = bootstrapResult;
281
-
282
- // Magnus fallback if bootstrap didn't produce a token
283
- if (!token) {
284
- token = forgejo.readToken('human');
285
- if (token) {
286
- fallbackUser = 'human';
287
- log(`Token not found for ${fmt.agent(forgejoUser)} and bootstrap did not succeed. Falling back to PR creation as ${fmt.agent(fallbackUser)}.`);
288
- } else {
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.`;
290
- error(msg);
291
- return { ok: false, error: msg };
301
+ // Persist durable fallback summary when we fell back to the default user
302
+ if (fallbackUser === 'human') {
303
+ const reason = bootstrapFailureReason || 'agent token was missing and bootstrap did not provide a replacement token';
304
+ const fallbackSummary = `## Fallback: PR submitted as ${fmt.agent(fallbackUser)}\n\nOriginal user: ${fmt.agent(forgejoUser)}\nBootstrap failure reason: ${reason}`;
305
+ if (!writeFallbackSummary(slug, fallbackSummary, { rootDir, log })) {
306
+ log(fmt.status('WARN', `Could not persist fallback summary for ${fmt.slug(slug)}`));
307
+ }
292
308
  }
293
- }
294
- }
295
-
296
- // Persist durable fallback summary when we fell back to the default user
297
- if (fallbackUser === 'human') {
298
- const reason = bootstrapFailureReason || 'agent token was missing and bootstrap did not provide a replacement token';
299
- const fallbackSummary = `## Fallback: PR submitted as ${fmt.agent(fallbackUser)}\n\nOriginal user: ${fmt.agent(forgejoUser)}\nBootstrap failure reason: ${reason}`;
300
- if (!writeFallbackSummary(slug, fallbackSummary, { rootDir, log })) {
301
- log(fmt.status('WARN', `Could not persist fallback summary for ${fmt.slug(slug)}`));
302
- }
303
- }
304
-
305
- const prResult = forgejo.createPr(branch || '', String(fallbackUser || forgejoUser || 'default'), String(token || ''), {
306
- rootDir,
307
- log,
308
- forceWithLease
309
- });
310
- if (!prResult.ok) {
311
- const msg = `Forgejo PR creation/update failed: ${prResult.error}`;
312
- error(msg);
313
- return { ok: false, error: msg };
314
- }
315
- } else {
316
- log('Step 2: Skipping Forgejo PR (review provider is not forgejo).');
317
- }
318
-
319
- // Step 2.5: Gatekeeper pre-review validation
320
- // Run before transitioning Backlog to 'review' so missing artifacts are
321
- // flagged as a request-changes review instead of consuming a reviewer cycle.
322
- log('Step 2.5: Running gatekeeper pre-review validation...');
323
- const gatekeeperResult = gatekeeper.runGatekeeper(slug, { rootDir, log });
324
- let gatekeeperPushedBack = false;
325
- if (!gatekeeperResult.ok && gatekeeperResult.posted) {
326
- fmt.log.warn(`Gatekeeper posted pushback for ${fmt.slug(slug)}: missing ${gatekeeperResult.missing.join(', ')}.`);
327
- gatekeeperPushedBack = true;
328
- log(`Keeping task ${fmt.slug(slug)} in active — not transitioning to review while artifacts are missing.`);
329
- } else if (!gatekeeperResult.ok && (gatekeeperResult.skipped || !gatekeeperResult.posted)) {
330
- fmt.log.fail(`Gatekeeper detected missing artifacts for ${fmt.slug(slug)} but could not post pushback: skipped=${gatekeeperResult.skipped}, posted=${gatekeeperResult.posted}. Blocking handoff — task remains in active until artifacts are present.`);
331
- error(`Missing mandatory artifacts: ${gatekeeperResult.missing.join(', ')}.`);
332
- return { ok: false, error: `Gatekeeper detected missing artifacts but could not post pushback (skipped=${gatekeeperResult.skipped}, posted=${gatekeeperResult.posted}). Fix missing artifacts before handoff: ${gatekeeperResult.missing.join(', ')}.` };
333
- } else {
334
- log('Gatekeeper: all mandatory artifacts present.');
335
- }
336
-
337
- if (gatekeeperPushedBack) {
338
- log(`Gatekeeper pushback posted for ${fmt.slug(slug)} — skipping Backlog transition to review.`);
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)}.`);
354
- }
355
-
356
- // Step 3 & 4: Backlog Transition and Commit
357
- log('Step 3 & 4: Transitioning and committing Backlog task to review...');
358
-
359
- const taskImplementer = forgejoUser;
360
- if (!backlog.transitionTask(slug, 'review', { implementer: taskImplementer, rootDir, log })) {
361
- const msg = `Could not transition task ${fmt.slug(slug)} to review.`;
362
- error(msg);
363
- return { ok: false, error: msg };
364
- }
365
-
366
- if (forgejoEnabled && token) {
367
- log('Pushing state change to Forgejo...');
368
- const reviewSettings = forgejo.resolveForgejoSettings(rootDir);
369
- const repoOwner = (reviewSettings.repo && reviewSettings.repo.split('/')[0]) || null;
370
- const ownerToken = repoOwner ? forgejo.readToken(repoOwner) : null;
371
- const pushUser = ownerToken && repoOwner ? repoOwner : (fallbackUser || forgejoUser);
372
- const pushToken = ownerToken || token;
373
- const remoteUrl = forgejo.authenticatedReviewUrl(pushUser, pushToken, rootDir);
374
- let pushLeaseArg = null;
375
- if (force) {
376
- const fetchArgs = ['-C', rootDir, 'fetch', remoteUrl, `+refs/heads/${branch}:refs/remotes/review/${branch}`];
377
- const fetchResult = git.git(fetchArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
378
- if (fetchResult.status !== 0) {
379
- const fetchError = (fetchResult.stderr || fetchResult.stdout || '').trim();
380
- const msg = `Failed to refresh Backlog transition lease for ${fmt.slug(slug)} before Forgejo push.`;
381
- error(`${msg}${fetchError ? ` ${fetchError}` : ''}`);
309
+ const prResult = forgejo.createPr(branch || '', String(fallbackUser || forgejoUser || 'default'), String(token || ''), {
310
+ rootDir,
311
+ log,
312
+ forceWithLease
313
+ });
314
+ if (!prResult.ok) {
315
+ const msg = `Forgejo PR creation/update failed: ${prResult.error}`;
316
+ error(msg);
317
+ return { ok: false, error: msg };
318
+ }
319
+ }
320
+ else {
321
+ log('Step 2: Skipping Forgejo PR (review provider is not forgejo).');
322
+ }
323
+ // Step 2.5: Gatekeeper pre-review validation
324
+ // Run before transitioning Backlog to 'review' so missing artifacts are
325
+ // flagged as a request-changes review instead of consuming a reviewer cycle.
326
+ log('Step 2.5: Running gatekeeper pre-review validation...');
327
+ const gatekeeperResult = gatekeeper.runGatekeeper(slug, { rootDir, log });
328
+ let gatekeeperPushedBack = false;
329
+ if (!gatekeeperResult.ok && gatekeeperResult.posted) {
330
+ fmt.log.warn(`Gatekeeper posted pushback for ${fmt.slug(slug)}: missing ${gatekeeperResult.missing.join(', ')}.`);
331
+ gatekeeperPushedBack = true;
332
+ log(`Keeping task ${fmt.slug(slug)} in active — not transitioning to review while artifacts are missing.`);
333
+ }
334
+ else if (!gatekeeperResult.ok && (gatekeeperResult.skipped || !gatekeeperResult.posted)) {
335
+ fmt.log.fail(`Gatekeeper detected missing artifacts for ${fmt.slug(slug)} but could not post pushback: skipped=${gatekeeperResult.skipped}, posted=${gatekeeperResult.posted}. Blocking handoff — task remains in active until artifacts are present.`);
336
+ error(`Missing mandatory artifacts: ${gatekeeperResult.missing.join(', ')}.`);
337
+ return { ok: false, error: `Gatekeeper detected missing artifacts but could not post pushback (skipped=${gatekeeperResult.skipped}, posted=${gatekeeperResult.posted}). Fix missing artifacts before handoff: ${gatekeeperResult.missing.join(', ')}.` };
338
+ }
339
+ else {
340
+ log('Gatekeeper: all mandatory artifacts present.');
341
+ }
342
+ if (gatekeeperPushedBack) {
343
+ log(`Gatekeeper pushback posted for ${fmt.slug(slug)} — skipping Backlog transition to review.`);
344
+ return /** @type{{ok: boolean, gatekeeperPushedBack: boolean}} */ ({ ok: true, gatekeeperPushedBack: true });
345
+ }
346
+ // Step 2.6: Generic ## Gates runner — execute any gates declared in MISSION.md
347
+ log('Step 2.6: Running declared gates from MISSION.md...');
348
+ const gatesResult = runDeclaredGates(verification.missionDir || '', rootDir, { log, error });
349
+ if (!gatesResult.ok) {
350
+ const msg = `Declared gate "${gatesResult.gate}" failed for ${fmt.slug(slug)}: ${gatesResult.error || gatesResult.reason}. Blocking handoff — task remains in active.`;
351
+ error(msg);
382
352
  return { ok: false, error: msg };
383
- }
384
- const tracking = forgejo.resolveTrackingBranchSha(branch || '', rootDir);
385
- if (!tracking.ok) {
386
- const msg = `Failed to resolve Backlog transition lease for ${fmt.slug(slug)} before Forgejo push.`;
353
+ }
354
+ if (gatesResult.skipped) {
355
+ log(`No declared gates for ${fmt.slug(slug)} (${gatesResult.reason}).`);
356
+ }
357
+ else {
358
+ log(`All ${gatesResult.count} declared gate(s) passed for ${fmt.slug(slug)}.`);
359
+ }
360
+ // Step 3 & 4: Backlog Transition and Commit
361
+ log('Step 3 & 4: Transitioning and committing Backlog task to review...');
362
+ const taskImplementer = forgejoUser;
363
+ if (!backlog.transitionTask(slug, 'review', { implementer: taskImplementer, rootDir, log })) {
364
+ const msg = `Could not transition task ${fmt.slug(slug)} to review.`;
387
365
  error(msg);
388
- return { ok: false, error: `${msg} ${tracking.error || ''}`.trim() };
389
- }
390
- pushLeaseArg = `--force-with-lease=refs/heads/${branch}:${String(tracking.sha || '')}`;
391
- }
392
- const pushArgs = ['-C', rootDir, 'push'];
393
- if (pushLeaseArg) {
394
- pushArgs.push(pushLeaseArg);
395
- }
396
- pushArgs.push(String(remoteUrl || ''), branch || '');
397
- const pushBacklogForgejo = git.git(pushArgs);
398
- if (pushBacklogForgejo.status !== 0) {
399
- const pushError = [pushBacklogForgejo.stderr, pushBacklogForgejo.stdout].filter(Boolean).join('\n');
400
- if (force && /non-fast-forward|stale info|fetch first/i.test(pushError || '')) {
401
- const forceArgs = ['-C', rootDir, 'push', '--force', String(remoteUrl || ''), branch || ''];
402
- const forceResult = git.git(forceArgs);
403
- if (forceResult.status === 0) {
404
- fmt.log.info(`Backlog transition for ${fmt.slug(slug)} required plain force after stale lease.`);
405
- } else {
406
- const forceError = (forceResult.stderr || forceResult.stdout || '').trim();
407
- const msg = `Failed to push Backlog transition for ${fmt.slug(slug)} to Forgejo.`;
408
- error(msg);
409
- return { ok: false, error: forceError ? `${msg} ${forceError}` : msg };
366
+ return { ok: false, error: msg };
367
+ }
368
+ if (forgejoEnabled && token) {
369
+ log('Pushing state change to Forgejo...');
370
+ const reviewSettings = forgejo.resolveForgejoSettings(rootDir);
371
+ const repoOwner = (reviewSettings.repo && reviewSettings.repo.split('/')[0]) || null;
372
+ const ownerToken = repoOwner ? forgejo.readToken(repoOwner) : null;
373
+ const pushUser = ownerToken && repoOwner ? repoOwner : (fallbackUser || forgejoUser);
374
+ const pushToken = ownerToken || token;
375
+ const remoteUrl = forgejo.authenticatedReviewUrl(pushUser, pushToken, rootDir);
376
+ let pushLeaseArg = null;
377
+ if (force) {
378
+ const fetchArgs = ['-C', rootDir, 'fetch', remoteUrl, `+refs/heads/${branch}:refs/remotes/review/${branch}`];
379
+ const fetchResult = git.git(fetchArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
380
+ if (fetchResult.status !== 0) {
381
+ const fetchError = (fetchResult.stderr || fetchResult.stdout || '').trim();
382
+ const msg = `Failed to refresh Backlog transition lease for ${fmt.slug(slug)} before Forgejo push.`;
383
+ error(`${msg}${fetchError ? ` ${fetchError}` : ''}`);
384
+ return { ok: false, error: msg };
385
+ }
386
+ const tracking = forgejo.resolveTrackingBranchSha(branch || '', rootDir);
387
+ if (!tracking.ok) {
388
+ const msg = `Failed to resolve Backlog transition lease for ${fmt.slug(slug)} before Forgejo push.`;
389
+ error(msg);
390
+ return { ok: false, error: `${msg} ${tracking.error || ''}`.trim() };
391
+ }
392
+ pushLeaseArg = `--force-with-lease=refs/heads/${branch}:${String(tracking.sha || '')}`;
393
+ }
394
+ const pushArgs = ['-C', rootDir, 'push'];
395
+ if (pushLeaseArg) {
396
+ pushArgs.push(pushLeaseArg);
397
+ }
398
+ pushArgs.push(String(remoteUrl || ''), branch || '');
399
+ const pushBacklogForgejo = git.git(pushArgs);
400
+ if (pushBacklogForgejo.status !== 0) {
401
+ const pushError = [pushBacklogForgejo.stderr, pushBacklogForgejo.stdout].filter(Boolean).join('\n');
402
+ if (force && /non-fast-forward|stale info|fetch first/i.test(pushError || '')) {
403
+ const forceArgs = ['-C', rootDir, 'push', '--force', String(remoteUrl || ''), branch || ''];
404
+ const forceResult = git.git(forceArgs);
405
+ if (forceResult.status === 0) {
406
+ fmt.log.info(`Backlog transition for ${fmt.slug(slug)} required plain force after stale lease.`);
407
+ }
408
+ else {
409
+ const forceError = (forceResult.stderr || forceResult.stdout || '').trim();
410
+ const msg = `Failed to push Backlog transition for ${fmt.slug(slug)} to Forgejo.`;
411
+ error(msg);
412
+ return { ok: false, error: forceError ? `${msg} ${forceError}` : msg };
413
+ }
414
+ return { ok: true, gatekeeperPushedBack };
415
+ }
416
+ const msg = `Failed to push Backlog transition for ${fmt.slug(slug)} to Forgejo.`;
417
+ error(msg);
418
+ return { ok: false, error: msg };
410
419
  }
411
- return { ok: true, gatekeeperPushedBack };
412
- }
413
- const msg = `Failed to push Backlog transition for ${fmt.slug(slug)} to Forgejo.`;
414
- error(msg);
415
- return { ok: false, error: msg };
416
- }
417
- }
418
-
419
- fmt.log.pass(`Mission ${fmt.slug(slug)} handed off successfully.`);
420
- return /** @type{{ok: boolean, gatekeeperPushedBack?: boolean}} */({ ok: true, gatekeeperPushedBack });
420
+ }
421
+ fmt.log.pass(`Mission ${fmt.slug(slug)} handed off successfully.`);
422
+ return /** @type{{ok: boolean, gatekeeperPushedBack?: boolean}} */ ({ ok: true, gatekeeperPushedBack });
421
423
  }
422
-
423
424
  /**
424
425
  * Parse and execute declared gates from a mission's MISSION.md `## Gates` section.
425
426
  * Each gate line is treated as a shell command to be executed via spawnSync.
@@ -427,58 +428,49 @@ const nels = require('../core/nels');
427
428
  */
428
429
  /** @param {string} missionDir @param {string} rootDir @param {{log?: Function, error?: Function}} [options] */
429
430
  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' };
431
+ const { log = fmt.log.plain } = /** @type {{log?: Function, error?: Function}} */ (options);
432
+ const missionPath = path.join(missionDir, 'MISSION.md');
433
+ if (!fs.existsSync(missionPath)) {
434
+ return { ok: true, skipped: true, reason: 'no-mission-file' };
435
+ }
436
+ const content = fs.readFileSync(missionPath, 'utf8');
437
+ // Extract the ## Gates section
438
+ const gatesSectionMatch = content.match(/^## Gates\s*\n([\s\S]*?)(?=\n## |\n$)/m);
439
+ if (!gatesSectionMatch) {
440
+ return { ok: true, skipped: true, reason: 'no-gates-section' };
441
+ }
442
+ const gatesBlock = gatesSectionMatch[1];
443
+ const gateLines = gatesBlock.split('\n')
444
+ .map(line => line.trim())
445
+ .filter(line => line.startsWith('- [ ]') || line.startsWith('- [x]') || line.startsWith('- '));
446
+ // Strip the checkbox prefix to get the command
447
+ const commands = gateLines.map(line => {
448
+ // Remove "- [ ] ", "- [x] ", or "- " prefix
449
+ return line.replace(/^- \[[ x]\]\s*/, '').replace(/^- \s*/, '');
450
+ }).filter(cmd => cmd.length > 0);
451
+ if (commands.length === 0) {
452
+ return { ok: true, skipped: true, reason: 'no-gates-declared' };
453
+ }
454
+ // Execute each gate command
455
+ for (const cmd of commands) {
456
+ log(` Gate: ${cmd}`);
457
+ const result = (0, node_child_process_1.spawnSync)('bash', ['-c', cmd], {
458
+ cwd: rootDir,
459
+ encoding: 'utf8',
460
+ stdio: ['inherit', 'pipe', 'pipe']
461
+ });
462
+ if (result.status !== 0) {
463
+ const stderr = (result.stderr || '').trim();
464
+ return {
465
+ ok: false,
466
+ gate: cmd,
467
+ reason: 'gate-failed',
468
+ error: stderr || `Gate exited with status ${result.status}`
469
+ };
470
+ }
471
+ }
472
+ return { ok: true, skipped: false, count: commands.length, reason: 'all-gates-passed' };
480
473
  }
481
-
482
474
  /**
483
475
  * Build the content for an auto-generated CP-1.md checkpoint (task-1228).
484
476
  *
@@ -492,26 +484,25 @@ function runDeclaredGates(missionDir, rootDir, options = {}) {
492
484
  * @returns {string}
493
485
  */
494
486
  function buildAutoCheckpointContent(slug) {
495
- return [
496
- `# CP-1: Auto-generated checkpoint (handoff remediation for ${slug})`,
497
- '',
498
- '> **Auto-generated by `node parallix handoff`** because no checkpoint document',
499
- '> was present in the mission directory at handoff time. This file provides the',
500
- '> minimum required structure so the handoff can proceed. A reviewer should',
501
- '> replace it with real implementation evidence.',
502
- '',
503
- '## Goal Check',
504
- '',
505
- '| Criterion | Evidence | Status |',
506
- '|-----------|----------|--------|',
507
- '| Auto-generated checkpoint CP-1.md present | handoff.js auto-remediation creates CP-1.md when checkpoints.length === 0 | PASS |',
508
- '| Mission contract exists for review | MISSION.md present in the mission directory | PASS |',
509
- '',
510
- 'Next action: Reviewer to replace this placeholder with real implementation evidence before approval.',
511
- ''
512
- ].join('\n');
487
+ return [
488
+ `# CP-1: Auto-generated checkpoint (handoff remediation for ${slug})`,
489
+ '',
490
+ '> **Auto-generated by `node parallix handoff`** because no checkpoint document',
491
+ '> was present in the mission directory at handoff time. This file provides the',
492
+ '> minimum required structure so the handoff can proceed. A reviewer should',
493
+ '> replace it with real implementation evidence.',
494
+ '',
495
+ '## Goal Check',
496
+ '',
497
+ '| Criterion | Evidence | Status |',
498
+ '|-----------|----------|--------|',
499
+ '| Auto-generated checkpoint CP-1.md present | handoff.js auto-remediation creates CP-1.md when checkpoints.length === 0 | PASS |',
500
+ '| Mission contract exists for review | MISSION.md present in the mission directory | PASS |',
501
+ '',
502
+ 'Next action: Reviewer to replace this placeholder with real implementation evidence before approval.',
503
+ ''
504
+ ].join('\n');
513
505
  }
514
-
515
506
  /**
516
507
  * Write a fallback summary (`## Fallback:` heading) into the backlog task file.
517
508
  * This is the in-scope equivalent of backlog.setTaskFinalSummary, implemented
@@ -521,65 +512,59 @@ function buildAutoCheckpointContent(slug) {
521
512
  * @param {{rootDir?: string, log?: Function}} options
522
513
  * @returns {boolean}
523
514
  */
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;
529
- const resolution = backlog.resolveTaskFile(slug, rootDir);
530
- if (!resolution.ok) {
531
- log(fmt.status('WARN', `Could not write fallback summary for ${fmt.slug(slug)}: ${resolution.reason}`));
532
- return false;
533
- }
534
-
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} */
541
- let content = fs.readFileSync(taskFile, 'utf8');
542
-
543
- // Already present — no-op
544
- if (content.includes('## Fallback:')) {
515
+ function writeFallbackSummary(slug, summary, options = {}) {
516
+ /** @type{{rootDir?: string, log?: Function}} */
517
+ const opts = options;
518
+ const rootDir = opts.rootDir || process.cwd();
519
+ const log = opts.log || fmt.log.plain;
520
+ const resolution = backlog.resolveTaskFile(slug, rootDir);
521
+ if (!resolution.ok) {
522
+ log(fmt.status('WARN', `Could not write fallback summary for ${fmt.slug(slug)}: ${resolution.reason}`));
523
+ return false;
524
+ }
525
+ const taskFile = resolution.taskFile;
526
+ if (!taskFile) {
527
+ log(fmt.status('WARN', `Could not write fallback summary for ${fmt.slug(slug)}: task file not found.`));
528
+ return false;
529
+ }
530
+ /** @type{string} */
531
+ let content = fs.readFileSync(taskFile, 'utf8');
532
+ // Already present — no-op
533
+ if (content.includes('## Fallback:')) {
534
+ return true;
535
+ }
536
+ // Insert after frontmatter block (first occurrence of "---" closing)
537
+ const firstDash = content.indexOf('---');
538
+ if (firstDash === -1) {
539
+ content = summary + '\n\n' + content;
540
+ }
541
+ else {
542
+ // Find the closing --- of frontmatter
543
+ const closingDash = content.indexOf('---', firstDash + 3);
544
+ if (closingDash === -1) {
545
+ content = summary + '\n\n' + content;
546
+ }
547
+ else {
548
+ const insertPos = closingDash + 3;
549
+ content = content.slice(0, insertPos) + '\n\n' + summary + content.slice(insertPos);
550
+ }
551
+ }
552
+ // Write the file
553
+ fs.writeFileSync(taskFile, content, 'utf8');
554
+ // Commit via the git module
555
+ const gitResult = git.git(['add', taskFile]);
556
+ if (gitResult.status !== 0) {
557
+ log(fmt.status('WARN', `Failed to stage fallback summary for ${fmt.slug(slug)}`));
558
+ return false;
559
+ }
560
+ const commitResult = git.git(['commit', '-m', `backlog(${slug}): set fallback summary`]);
561
+ if (commitResult.status !== 0) {
562
+ log(fmt.status('WARN', `Failed to commit fallback summary for ${fmt.slug(slug)}`));
563
+ return false;
564
+ }
565
+ log(fmt.status('PASS', `Set fallback summary on ${fmt.slug(slug)}.`));
545
566
  return true;
546
- }
547
-
548
- // Insert after frontmatter block (first occurrence of "---" closing)
549
- const firstDash = content.indexOf('---');
550
- if (firstDash === -1) {
551
- content = summary + '\n\n' + content;
552
- } else {
553
- // Find the closing --- of frontmatter
554
- const closingDash = content.indexOf('---', firstDash + 3);
555
- if (closingDash === -1) {
556
- content = summary + '\n\n' + content;
557
- } else {
558
- const insertPos = closingDash + 3;
559
- content = content.slice(0, insertPos) + '\n\n' + summary + content.slice(insertPos);
560
- }
561
- }
562
-
563
- // Write the file
564
- fs.writeFileSync(taskFile, content, 'utf8');
565
-
566
- // Commit via the git module
567
- const gitResult = git.git(['add', taskFile]);
568
- if (gitResult.status !== 0) {
569
- log(fmt.status('WARN', `Failed to stage fallback summary for ${fmt.slug(slug)}`));
570
- return false;
571
- }
572
-
573
- const commitResult = git.git(['commit', '-m', `backlog(${slug}): set fallback summary`]);
574
- if (commitResult.status !== 0) {
575
- log(fmt.status('WARN', `Failed to commit fallback summary for ${fmt.slug(slug)}`));
576
- return false;
577
- }
578
-
579
- log(fmt.status('PASS', `Set fallback summary on ${fmt.slug(slug)}.`));
580
- return true;
581
567
  }
582
-
583
568
  /**
584
569
  * Capture NEL (Net Engineering Lines) at handoff time.
585
570
  *
@@ -594,96 +579,91 @@ function buildAutoCheckpointContent(slug) {
594
579
  * @returns {{ ok: boolean, nel?: number, bucket?: string, error?: string }}
595
580
  */
596
581
  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)) {
582
+ const { rootDir, missionDir, error } = options;
583
+ // 1. Determine primary branch for diff range
584
+ let primaryBranch;
637
585
  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 };
586
+ primaryBranch = missionUtils.getPrimaryBranch(rootDir);
587
+ }
588
+ catch (_) {
589
+ return { ok: false, error: 'could not detect primary branch for NEL diff range' };
590
+ }
591
+ if (!primaryBranch) {
592
+ return { ok: false, error: 'primary branch is empty' };
593
+ }
594
+ // 2. Compute actual NEL from primary..HEAD
595
+ let nelRecord;
596
+ try {
597
+ nelRecord = nels.computeNELRecord(`${primaryBranch}..HEAD`, { cwd: rootDir });
598
+ }
599
+ catch (_) {
600
+ return { ok: false, error: 'NEL computation failed' };
601
+ }
602
+ const actualNel = nelRecord.nel;
603
+ const actualBucket = nelRecord.bucket.label;
604
+ // 3. Read predicted bucket from MISSION.md Refinement Signals
605
+ const missionMdPath = path.join(missionDir, 'MISSION.md');
606
+ let predictedBucket = 'Unknown';
607
+ if (fs.existsSync(missionMdPath)) {
608
+ const content = fs.readFileSync(missionMdPath, 'utf8');
609
+ const predictedMatch = content.match(/Predicted NEL bucket:\s*(Small|Medium|Large)/i);
610
+ if (predictedMatch) {
611
+ predictedBucket = predictedMatch[1];
612
+ }
613
+ }
614
+ // 4. Read review rounds from review-state.json
615
+ let reviewRounds = 1;
616
+ const reviewStatePath = path.join(missionDir, 'review-state.json');
617
+ if (fs.existsSync(reviewStatePath)) {
618
+ try {
619
+ const rs = JSON.parse(fs.readFileSync(reviewStatePath, 'utf8'));
620
+ reviewRounds = rs.round || 1;
621
+ }
622
+ catch (_) {
623
+ // ignore parse errors
624
+ }
625
+ }
626
+ // 5. Persist NEL record
627
+ const nelRecordPath = path.join(missionDir, 'nel-record.json');
628
+ const record = {
629
+ slug,
630
+ predictedBucket,
631
+ actualNel,
632
+ actualBucket,
633
+ reviewRounds,
634
+ capturedAt: new Date().toISOString(),
635
+ };
636
+ try {
637
+ fs.writeFileSync(nelRecordPath, JSON.stringify(record, null, 2) + '\n', 'utf8');
638
+ }
639
+ catch (err) {
640
+ error(`Failed to write NEL record: ${err.message}`);
641
+ return { ok: false, error: `failed to write NEL record: ${err.message}` };
642
+ }
643
+ return { ok: true, nel: actualNel, bucket: actualBucket };
664
644
  }
665
-
666
645
  /** @param {string[]} args */
667
646
  async function handoffCommand(args) {
668
- const explicitSlug = args[0];
669
- const slug = missionUtils.inferSlug(explicitSlug);
670
- const skipGate = args.includes('--no-gate');
671
- const force = args.includes('--force');
672
-
673
- if (!slug) {
674
- fmt.log.fail('Usage: node parallix handoff [<slug>] [--no-gate] [--force]');
675
- process.exit(1);
676
- }
677
-
678
- const result = await module.exports.performHandoff(slug, { skipGate, force });
679
- if (!result.ok) {
680
- process.exit(1);
681
- }
647
+ const explicitSlug = args[0];
648
+ const slug = missionUtils.inferSlug(explicitSlug);
649
+ const skipGate = args.includes('--no-gate');
650
+ const force = args.includes('--force');
651
+ if (!slug) {
652
+ fmt.log.fail('Usage: node parallix handoff [<slug>] [--no-gate] [--force]');
653
+ process.exit(1);
654
+ }
655
+ const result = await _exports.performHandoff(slug, { skipGate, force });
656
+ if (!result.ok) {
657
+ process.exit(1);
658
+ }
682
659
  }
683
-
684
- module.exports = handoffCommand;
685
- module.exports.verifyHandoff = verifyHandoff;
686
- module.exports.performHandoff = performHandoff;
687
- module.exports.gatekeeper = gatekeeper;
688
- module.exports.runDeclaredGates = runDeclaredGates;
689
- module.exports.captureNelAtHandoff = captureNelAtHandoff;
660
+ /** @type {{performHandoff: Function}} */
661
+ const _exports = {
662
+ /** @returns {...} */
663
+ get performHandoff() { return _handoffExport.performHandoff; }
664
+ };
665
+ /** @type {{verifyHandoff: typeof verifyHandoff, performHandoff: typeof performHandoff, gatekeeper: typeof gatekeeper, runDeclaredGates: typeof runDeclaredGates, captureNelAtHandoff: typeof captureNelAtHandoff}} */
666
+ const _namedExports = { verifyHandoff, performHandoff, gatekeeper, runDeclaredGates, captureNelAtHandoff };
667
+ /** @type {typeof handoffCommand & {verifyHandoff: typeof verifyHandoff, performHandoff: typeof performHandoff, gatekeeper: typeof gatekeeper, runDeclaredGates: typeof runDeclaredGates, captureNelAtHandoff: typeof captureNelAtHandoff}} */
668
+ const _handoffExport = Object.assign(handoffCommand, _namedExports);
669
+ module.exports = _handoffExport;