@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,8 +1,41 @@
1
- const { git } = require('../core/git');
2
- const missionUtils = require('../core/mission-utils');
3
- const rebase = require('./rebase');
4
- const fmt = require('../core/fmt');
5
-
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
+ const git_js_1 = require("../core/git.js");
36
+ const missionUtils = __importStar(require("../core/mission-utils.js"));
37
+ const rebase = require("./rebase.js");
38
+ const fmt = __importStar(require("../core/fmt.js"));
6
39
  /**
7
40
  * Check if an error message indicates a relaunchable content error (missing/empty goal-check table).
8
41
  *
@@ -10,14 +43,13 @@ const fmt = require('../core/fmt');
10
43
  * @returns {boolean} True if the error is relaunchable
11
44
  */
12
45
  function isRelaunchableError(errorMsg) {
13
- if (!errorMsg || typeof errorMsg !== 'string') {
14
- return false;
15
- }
16
- // Match the exact error message from handoff.js when final checkpoint has no evidence rows
17
- return errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
18
- errorMsg.includes('A goal-check table with real evidence is required before handoff');
46
+ if (!errorMsg || typeof errorMsg !== 'string') {
47
+ return false;
48
+ }
49
+ // Match the exact error message from handoff.js when final checkpoint has no evidence rows
50
+ return errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
51
+ errorMsg.includes('A goal-check table with real evidence is required before handoff');
19
52
  }
20
-
21
53
  /**
22
54
  * Build a relaunch prompt for an agent to fix a relaunchable error.
23
55
  *
@@ -25,49 +57,46 @@ function isRelaunchableError(errorMsg) {
25
57
  * @param {string} slug - Mission slug
26
58
  * @param {string} worktree - Path to the mission worktree
27
59
  * @returns {string} The relaunch prompt
28
- */
29
- /** @param {string} errorMsg @param {string} slug @param {string} worktree */
60
+ */
30
61
  function buildRelaunchPrompt(errorMsg, slug, worktree) {
31
- const year = missionUtils.getMissionYear(slug, worktree);
32
- const missionDir = missionUtils.findMissionDir(slug, worktree) || missionUtils.missionDirForSlug(worktree, slug);
33
-
34
- return `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
62
+ const year = missionUtils.getMissionYear(slug, worktree);
63
+ const missionDir = missionUtils.findMissionDir(slug, worktree) || missionUtils.missionDirForSlug(worktree, slug);
64
+ return `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
35
65
 
36
66
  ` +
37
- `Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
38
- `with real evidence rows (file:line references, test names). The Goal Check table must have ` +
39
- `a header row and at least one evidence row using pipe syntax (|).
67
+ `Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
68
+ `with real evidence rows (file:line references, test names). The Goal Check table must have ` +
69
+ `a header row and at least one evidence row using pipe syntax (|).
40
70
 
41
71
  ` +
42
- `Steps:
72
+ `Steps:
43
73
  ` +
44
- `1. Open the final checkpoint document (CP-N.md) in ${missionDir}
74
+ `1. Open the final checkpoint document (CP-N.md) in ${missionDir}
45
75
  ` +
46
- `2. Add or update the "## Goal Check" section
76
+ `2. Add or update the "## Goal Check" section
47
77
  ` +
48
- `3. Create a markdown table with columns for: Goal Check description | Evidence | Status
78
+ `3. Create a markdown table with columns for: Goal Check description | Evidence | Status
49
79
  ` +
50
- `4. Add at least one evidence row with real file:line or test name references
80
+ `4. Add at least one evidence row with real file:line or test name references
51
81
  ` +
52
- `5. Commit the updated checkpoint with a descriptive commit message
82
+ `5. Commit the updated checkpoint with a descriptive commit message
53
83
  ` +
54
- `6. Re-run: node parallix review ${slug} --submit
84
+ `6. Re-run: node parallix review ${slug} --submit
55
85
 
56
86
  ` +
57
- `Example Goal Check table:
87
+ `Example Goal Check table:
58
88
  ` +
59
- `| Goal Check | Evidence | Status |
89
+ `| Goal Check | Evidence | Status |
60
90
  ` +
61
- `|---|---|---|
91
+ `|---|---|---|
62
92
  ` +
63
- `| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
93
+ `| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
64
94
  ` +
65
- `| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
95
+ `| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
66
96
 
67
97
  ` +
68
- `Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
98
+ `Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
69
99
  }
70
-
71
100
  /**
72
101
  * Attempt to repair a failed automated handoff by auto-committing mission
73
102
  * artifacts or rebasing.
@@ -77,154 +106,127 @@ function buildRelaunchPrompt(errorMsg, slug, worktree) {
77
106
  * @param {string} errorMsg - The error message from the failed handoff
78
107
  * @param {object} [options]
79
108
  * @returns {Promise<{repaired: boolean, blocker: string|null}>} Result and optional blocker reason
80
- */
81
- /** @param {string} slug @param {string} worktree @param {string} errorMsg @param {{gitFn?: Function, rebaseFn?: Function, log?: Function, error?: Function}} options */
109
+ */
82
110
  async function repairHandoff(slug, worktree, errorMsg, options = {}) {
83
- /** @type {{gitFn?: Function, rebaseFn?: Function, log?: Function, error?: Function}} */
84
- const opts = options;
85
- const {
86
- gitFn = git,
87
- rebaseFn = rebase,
88
- log = fmt.log.plain,
89
- error = fmt.log.plainError
90
- } = opts;
91
-
92
- const rootDir = worktree || process.cwd();
93
- let repaired = false;
94
- let blocker = null;
95
-
96
- /** @param {string} line */
97
- function parsePorcelainPath(line) {
98
- const xy = line.slice(0, 2);
99
- const rawPath = line.slice(3).trim();
100
- const pathPart = rawPath.includes('->') ? (rawPath.split('->').pop() || '').trim() : rawPath;
101
- const cleanPath = (pathPart.startsWith('"') && pathPart.endsWith('"')) ? pathPart.slice(1, -1) : pathPart;
102
- return { xy, file: cleanPath };
103
- }
104
-
105
- // 0. Check if error is repairable
106
- const isDirtyError = errorMsg && (
107
- errorMsg.includes('is modified but uncommitted') ||
108
- errorMsg.includes('Commit the mission contract before handoff') ||
109
- errorMsg.includes('Commit the implementation evidence before handoff')
110
- );
111
-
112
- const isBehind = errorMsg && (
113
- errorMsg.includes('Updates were rejected') ||
114
- errorMsg.includes('fetch first') ||
115
- errorMsg.includes('non-fast-forward') ||
116
- errorMsg.includes('behind its remote') ||
117
- // Ensure we don't match generic git push failed unless it has non-fast-forward hints
118
- (errorMsg.includes('git push failed') && (
119
- errorMsg.includes('rejected') ||
120
- errorMsg.includes('remote contains work')
121
- ))
122
- );
123
-
124
- if (!isDirtyError && !isBehind) {
125
- log(`Handoff error is not automatically repairable: ${errorMsg}`);
126
- return { repaired: false, blocker: null };
127
- }
128
-
129
- // 1. Auto-commit mission artifacts if uncommitted
130
- if (isDirtyError || isBehind) {
131
- const statusResult = gitFn(['-C', rootDir, 'status', '--porcelain']);
132
- if (statusResult.status === 0 && statusResult.stdout) {
133
- const dirtyLines = statusResult.stdout.split('\n')
134
- .filter(/** @param {string} line */ (line) => line.trim().length > 0);
135
-
136
- const dirtyFilesWithStatus = dirtyLines.map(parsePorcelainPath);
137
-
138
- const unmerged = dirtyFilesWithStatus.filter((/** @param {{xy: string, file: string}} f */ f =>
139
- ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(f.xy)
140
- ));
141
-
142
- if (unmerged.length > 0) {
143
- blocker = `Conflicted files detected:\n${unmerged.map((/** @param {{xy: string, file: string}} f */ f => ` - ${f.file}`)).join('\n')}`;
144
- log(`Cannot auto-commit: ${blocker}`);
145
- return { repaired: false, blocker };
146
- }
147
-
148
- const dirtyFiles = dirtyFilesWithStatus.map((/** @param {{xy: string, file: string}} f */ f => f.file));
149
-
150
- const isSafeToCommit = (/** @type{string} */ file) =>
151
- missionUtils.isWorkflowGeneratedArtifact(file)
152
- || missionUtils.isMissionArtifact(file, slug, rootDir);
153
-
154
- const safeFiles = dirtyFiles.filter(isSafeToCommit);
155
- const unsafeFiles = dirtyFiles.filter((/** @param {string} f */ f => !isSafeToCommit(f)));
156
-
157
- if (unsafeFiles.length > 0) {
158
- log(`Cannot auto-commit: dirty files include non-mission paths:`);
159
- unsafeFiles.forEach((/** @type {string} */ f) => log(` - ${f}`));
160
- blocker = `dirty files include non-mission paths: ${unsafeFiles.join(', ')}`;
161
- return { repaired: false, blocker };
162
- } else if (safeFiles.length > 0) {
163
- log(`Auto-committing mission artifacts:`);
164
- const stageFailures = /** @type {string[]} */ ([]);
165
- safeFiles.forEach((/** @type {string} */ f) => {
166
- log(` - ${f}`);
167
- const addResult = gitFn(['-C', rootDir, 'add', '--', f]);
168
- if (addResult.status === 0) {
169
- return;
170
- } else {
171
- const failureText = [addResult.stderr, addResult.stdout].filter(Boolean).join('\n').trim();
172
- stageFailures.push(`${f}${failureText ? `: ${failureText}` : ''}`);
173
- }
174
- });
175
- if (stageFailures.length > 0) {
176
- blocker = `failed to stage mission artifacts: ${stageFailures.join(', ')}`;
177
- error(fmt.status('FAIL', blocker));
178
- return { repaired: false, blocker };
179
- }
180
- const commitRes = gitFn(['-C', rootDir, 'commit', '-m', `workflow(${slug}): auto-commit mission artifacts before handoff`]);
181
- if (commitRes.status === 0) {
182
- log(fmt.status('PASS', 'Mission artifacts committed.'));
183
- repaired = true;
184
- } else {
185
- error(fmt.status('WARN', `Failed to commit mission artifacts: ${commitRes.stderr}`));
186
- blocker = `failed to commit mission artifacts: ${commitRes.stderr}`;
187
- return { repaired: false, blocker };
188
- }
189
- }
111
+ /** @type {{gitFn?: Function, rebaseFn?: Function, log?: Function, error?: Function}} */
112
+ const opts = options;
113
+ const { gitFn = git_js_1.git, rebaseFn = rebase, log = fmt.log.plain, error = fmt.log.plainError } = opts;
114
+ const rootDir = worktree || process.cwd();
115
+ let repaired = false;
116
+ let blocker = null;
117
+ /** @param {string} line */
118
+ function parsePorcelainPath(line) {
119
+ const xy = line.slice(0, 2);
120
+ const rawPath = line.slice(3).trim();
121
+ const pathPart = rawPath.includes('->') ? (rawPath.split('->').pop() || '').trim() : rawPath;
122
+ const cleanPath = (pathPart.startsWith('"') && pathPart.endsWith('"')) ? pathPart.slice(1, -1) : pathPart;
123
+ return { xy, file: cleanPath };
190
124
  }
191
- }
192
-
193
- // 2. Auto-rebase if branch is behind
194
- if (isBehind) {
195
- log('Branch appears behind primary branch. Calling rebase...');
196
- let rebaseSuccess = false;
197
- let rebaseError = null;
198
- try {
199
- const { getCurrentBranch } = require('../core/git');
200
- await rebaseFn([slug], {
201
- gitFn: (/** @type {string[]} */ args, /** @type {{cwd?: string}} */ opts) => gitFn(args, { ...opts, cwd: rootDir }),
202
- getCurrentBranchFn: () => getCurrentBranch(rootDir),
203
- exitFn: (/** @type {number} */ code) => {
204
- if (code === 0) {
205
- rebaseSuccess = true;
206
- } else {
207
- rebaseError = `rebase exited with code ${code}`;
208
- }
125
+ // 0. Check if error is repairable
126
+ const isDirtyError = errorMsg && (errorMsg.includes('is modified but uncommitted') ||
127
+ errorMsg.includes('Commit the mission contract before handoff') ||
128
+ errorMsg.includes('Commit the implementation evidence before handoff'));
129
+ const isBehind = errorMsg && (errorMsg.includes('Updates were rejected') ||
130
+ errorMsg.includes('fetch first') ||
131
+ errorMsg.includes('non-fast-forward') ||
132
+ errorMsg.includes('behind its remote') ||
133
+ // Ensure we don't match generic git push failed unless it has non-fast-forward hints
134
+ (errorMsg.includes('git push failed') && (errorMsg.includes('rejected') ||
135
+ errorMsg.includes('remote contains work'))));
136
+ if (!isDirtyError && !isBehind) {
137
+ log(`Handoff error is not automatically repairable: ${errorMsg}`);
138
+ return { repaired: false, blocker: null };
139
+ }
140
+ // 1. Auto-commit mission artifacts if uncommitted
141
+ if (isDirtyError || isBehind) {
142
+ const statusResult = gitFn(['-C', rootDir, 'status', '--porcelain']);
143
+ if (statusResult.status === 0 && statusResult.stdout) {
144
+ const dirtyLines = statusResult.stdout.split('\n')
145
+ .filter((line) => line.trim().length > 0);
146
+ const dirtyFilesWithStatus = dirtyLines.map(parsePorcelainPath);
147
+ const unmerged = dirtyFilesWithStatus.filter((f) => ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(f.xy));
148
+ if (unmerged.length > 0) {
149
+ blocker = `Conflicted files detected:\n${unmerged.map((f) => ` - ${f.file}`).join('\n')}`;
150
+ log(`Cannot auto-commit: ${blocker}`);
151
+ return { repaired: false, blocker };
152
+ }
153
+ const dirtyFiles = dirtyFilesWithStatus.map((f) => f.file);
154
+ const isSafeToCommit = (/** @type{string} */ file) => missionUtils.isWorkflowGeneratedArtifact(file)
155
+ || missionUtils.isMissionArtifact(file, slug, rootDir);
156
+ const safeFiles = dirtyFiles.filter(isSafeToCommit);
157
+ const unsafeFiles = dirtyFiles.filter((f) => !isSafeToCommit(f));
158
+ if (unsafeFiles.length > 0) {
159
+ log(`Cannot auto-commit: dirty files include non-mission paths:`);
160
+ unsafeFiles.forEach((f) => log(` - ${f}`));
161
+ blocker = `dirty files include non-mission paths: ${unsafeFiles.join(', ')}`;
162
+ return { repaired: false, blocker };
163
+ }
164
+ else if (safeFiles.length > 0) {
165
+ log(`Auto-committing mission artifacts:`);
166
+ const stageFailures = [];
167
+ safeFiles.forEach((f) => {
168
+ log(` - ${f}`);
169
+ const addResult = gitFn(['-C', rootDir, 'add', '--', f]);
170
+ if (addResult.status === 0) {
171
+ return;
172
+ }
173
+ else {
174
+ const failureText = [addResult.stderr, addResult.stdout].filter(Boolean).join('\n').trim();
175
+ stageFailures.push(`${f}${failureText ? `: ${failureText}` : ''}`);
176
+ }
177
+ });
178
+ if (stageFailures.length > 0) {
179
+ blocker = `failed to stage mission artifacts: ${stageFailures.join(', ')}`;
180
+ error(fmt.status('FAIL', blocker));
181
+ return { repaired: false, blocker };
182
+ }
183
+ const commitRes = gitFn(['-C', rootDir, 'commit', '-m', `workflow(${slug}): auto-commit mission artifacts before handoff`]);
184
+ if (commitRes.status === 0) {
185
+ log(fmt.status('PASS', 'Mission artifacts committed.'));
186
+ repaired = true;
187
+ }
188
+ else {
189
+ error(fmt.status('WARN', `Failed to commit mission artifacts: ${commitRes.stderr}`));
190
+ blocker = `failed to commit mission artifacts: ${commitRes.stderr}`;
191
+ return { repaired: false, blocker };
192
+ }
193
+ }
209
194
  }
210
- });
211
- } catch (/** @type {unknown} */ err) {
212
- rebaseError = /** @type{Error} */(err).message;
213
195
  }
214
-
215
- if (!rebaseSuccess) {
216
- const msg = rebaseError || 'unknown rebase failure';
217
- error(fmt.status('WARN', `Auto-rebase failed: ${msg}`));
218
- blocker = `Auto-rebase failed: ${msg}`;
219
- repaired = false; // Reset repaired if rebase fails, even if auto-commit succeeded
220
- } else {
221
- repaired = true;
196
+ // 2. Auto-rebase if branch is behind
197
+ if (isBehind) {
198
+ log('Branch appears behind primary branch. Calling rebase...');
199
+ let rebaseSuccess = false;
200
+ let rebaseError = null;
201
+ try {
202
+ await rebaseFn([slug], {
203
+ gitFn: (args, opts) => gitFn(args, { ...opts, cwd: rootDir }),
204
+ getCurrentBranchFn: () => (0, git_js_1.getCurrentBranch)(rootDir),
205
+ exitFn: (code) => {
206
+ if (code === 0) {
207
+ rebaseSuccess = true;
208
+ }
209
+ else {
210
+ rebaseError = `rebase exited with code ${code}`;
211
+ }
212
+ }
213
+ });
214
+ }
215
+ catch (err) {
216
+ rebaseError = (err instanceof Error) ? err.message : String(err);
217
+ }
218
+ if (!rebaseSuccess) {
219
+ const msg = rebaseError || 'unknown rebase failure';
220
+ error(fmt.status('WARN', `Auto-rebase failed: ${msg}`));
221
+ blocker = `Auto-rebase failed: ${msg}`;
222
+ repaired = false; // Reset repaired if rebase fails, even if auto-commit succeeded
223
+ }
224
+ else {
225
+ repaired = true;
226
+ }
222
227
  }
223
- }
224
-
225
- return { repaired, blocker };
228
+ return { repaired, blocker };
226
229
  }
227
-
230
+ repairHandoff.isRelaunchableError = isRelaunchableError;
231
+ repairHandoff.buildRelaunchPrompt = buildRelaunchPrompt;
228
232
  module.exports = repairHandoff;
229
- module.exports.isRelaunchableError = isRelaunchableError;
230
- module.exports.buildRelaunchPrompt = buildRelaunchPrompt;
@@ -0,0 +1,228 @@
1
+ import { git, getCurrentBranch } from '../core/git.js';
2
+ import * as missionUtils from '../core/mission-utils.js';
3
+ import rebase = require('./rebase.js');
4
+ import * as fmt from '../core/fmt.js';
5
+
6
+ /**
7
+ * Check if an error message indicates a relaunchable content error (missing/empty goal-check table).
8
+ *
9
+ * @param {string} errorMsg - The error message to check
10
+ * @returns {boolean} True if the error is relaunchable
11
+ */
12
+ function isRelaunchableError(errorMsg: string) {
13
+ if (!errorMsg || typeof errorMsg !== 'string') {
14
+ return false;
15
+ }
16
+ // Match the exact error message from handoff.js when final checkpoint has no evidence rows
17
+ return errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
18
+ errorMsg.includes('A goal-check table with real evidence is required before handoff');
19
+ }
20
+
21
+ /**
22
+ * Build a relaunch prompt for an agent to fix a relaunchable error.
23
+ *
24
+ * @param {string} errorMsg - The error message from the failed handoff
25
+ * @param {string} slug - Mission slug
26
+ * @param {string} worktree - Path to the mission worktree
27
+ * @returns {string} The relaunch prompt
28
+ */
29
+ function buildRelaunchPrompt(errorMsg: string, slug: string, worktree: string) {
30
+ const year = missionUtils.getMissionYear(slug, worktree);
31
+ const missionDir = missionUtils.findMissionDir(slug, worktree) || missionUtils.missionDirForSlug(worktree, slug);
32
+
33
+ return `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
34
+
35
+ ` +
36
+ `Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
37
+ `with real evidence rows (file:line references, test names). The Goal Check table must have ` +
38
+ `a header row and at least one evidence row using pipe syntax (|).
39
+
40
+ ` +
41
+ `Steps:
42
+ ` +
43
+ `1. Open the final checkpoint document (CP-N.md) in ${missionDir}
44
+ ` +
45
+ `2. Add or update the "## Goal Check" section
46
+ ` +
47
+ `3. Create a markdown table with columns for: Goal Check description | Evidence | Status
48
+ ` +
49
+ `4. Add at least one evidence row with real file:line or test name references
50
+ ` +
51
+ `5. Commit the updated checkpoint with a descriptive commit message
52
+ ` +
53
+ `6. Re-run: node parallix review ${slug} --submit
54
+
55
+ ` +
56
+ `Example Goal Check table:
57
+ ` +
58
+ `| Goal Check | Evidence | Status |
59
+ ` +
60
+ `|---|---|---|
61
+ ` +
62
+ `| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
63
+ ` +
64
+ `| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
65
+
66
+ ` +
67
+ `Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
68
+ }
69
+
70
+ /**
71
+ * Attempt to repair a failed automated handoff by auto-committing mission
72
+ * artifacts or rebasing.
73
+ *
74
+ * @param {string} slug - Mission slug
75
+ * @param {string} worktree - Path to the mission worktree
76
+ * @param {string} errorMsg - The error message from the failed handoff
77
+ * @param {object} [options]
78
+ * @returns {Promise<{repaired: boolean, blocker: string|null}>} Result and optional blocker reason
79
+ */
80
+ async function repairHandoff(slug: string, worktree: string, errorMsg: string, options: { gitFn?: Function, rebaseFn?: Function, log?: Function, error?: Function } = {}) {
81
+ /** @type {{gitFn?: Function, rebaseFn?: Function, log?: Function, error?: Function}} */
82
+ const opts = options;
83
+ const {
84
+ gitFn = git,
85
+ rebaseFn = rebase,
86
+ log = fmt.log.plain,
87
+ error = fmt.log.plainError
88
+ } = opts;
89
+
90
+ const rootDir = worktree || process.cwd();
91
+ let repaired = false;
92
+ let blocker: string | null = null;
93
+
94
+ /** @param {string} line */
95
+ function parsePorcelainPath(line: string) {
96
+ const xy = line.slice(0, 2);
97
+ const rawPath = line.slice(3).trim();
98
+ const pathPart = rawPath.includes('->') ? (rawPath.split('->').pop() || '').trim() : rawPath;
99
+ const cleanPath = (pathPart.startsWith('"') && pathPart.endsWith('"')) ? pathPart.slice(1, -1) : pathPart;
100
+ return { xy, file: cleanPath };
101
+ }
102
+
103
+ // 0. Check if error is repairable
104
+ const isDirtyError = errorMsg && (
105
+ errorMsg.includes('is modified but uncommitted') ||
106
+ errorMsg.includes('Commit the mission contract before handoff') ||
107
+ errorMsg.includes('Commit the implementation evidence before handoff')
108
+ );
109
+
110
+ const isBehind = errorMsg && (
111
+ errorMsg.includes('Updates were rejected') ||
112
+ errorMsg.includes('fetch first') ||
113
+ errorMsg.includes('non-fast-forward') ||
114
+ errorMsg.includes('behind its remote') ||
115
+ // Ensure we don't match generic git push failed unless it has non-fast-forward hints
116
+ (errorMsg.includes('git push failed') && (
117
+ errorMsg.includes('rejected') ||
118
+ errorMsg.includes('remote contains work')
119
+ ))
120
+ );
121
+
122
+ if (!isDirtyError && !isBehind) {
123
+ log(`Handoff error is not automatically repairable: ${errorMsg}`);
124
+ return { repaired: false, blocker: null };
125
+ }
126
+
127
+ // 1. Auto-commit mission artifacts if uncommitted
128
+ if (isDirtyError || isBehind) {
129
+ const statusResult = gitFn(['-C', rootDir, 'status', '--porcelain']);
130
+ if (statusResult.status === 0 && statusResult.stdout) {
131
+ const dirtyLines = statusResult.stdout.split('\n')
132
+ .filter((line: string) => line.trim().length > 0);
133
+
134
+ const dirtyFilesWithStatus = dirtyLines.map(parsePorcelainPath);
135
+
136
+ const unmerged = dirtyFilesWithStatus.filter((f: { xy: string; file: string }) =>
137
+ ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(f.xy)
138
+ );
139
+
140
+ if (unmerged.length > 0) {
141
+ blocker = `Conflicted files detected:\n${unmerged.map((f: { xy: string; file: string }) => ` - ${f.file}`).join('\n')}`;
142
+ log(`Cannot auto-commit: ${blocker}`);
143
+ return { repaired: false, blocker };
144
+ }
145
+
146
+ const dirtyFiles = dirtyFilesWithStatus.map((f: { xy: string; file: string }) => f.file);
147
+
148
+ const isSafeToCommit = (/** @type{string} */ file: string) =>
149
+ missionUtils.isWorkflowGeneratedArtifact(file)
150
+ || missionUtils.isMissionArtifact(file, slug, rootDir);
151
+
152
+ const safeFiles = dirtyFiles.filter(isSafeToCommit);
153
+ const unsafeFiles = dirtyFiles.filter((f: string) => !isSafeToCommit(f));
154
+
155
+ if (unsafeFiles.length > 0) {
156
+ log(`Cannot auto-commit: dirty files include non-mission paths:`);
157
+ unsafeFiles.forEach((f: string) => log(` - ${f}`));
158
+ blocker = `dirty files include non-mission paths: ${unsafeFiles.join(', ')}`;
159
+ return { repaired: false, blocker };
160
+ } else if (safeFiles.length > 0) {
161
+ log(`Auto-committing mission artifacts:`);
162
+ const stageFailures: string[] = [];
163
+ safeFiles.forEach((f: string) => {
164
+ log(` - ${f}`);
165
+ const addResult = gitFn(['-C', rootDir, 'add', '--', f]);
166
+ if (addResult.status === 0) {
167
+ return;
168
+ } else {
169
+ const failureText = [addResult.stderr, addResult.stdout].filter(Boolean).join('\n').trim();
170
+ stageFailures.push(`${f}${failureText ? `: ${failureText}` : ''}`);
171
+ }
172
+ });
173
+ if (stageFailures.length > 0) {
174
+ blocker = `failed to stage mission artifacts: ${stageFailures.join(', ')}`;
175
+ error(fmt.status('FAIL', blocker));
176
+ return { repaired: false, blocker };
177
+ }
178
+ const commitRes = gitFn(['-C', rootDir, 'commit', '-m', `workflow(${slug}): auto-commit mission artifacts before handoff`]);
179
+ if (commitRes.status === 0) {
180
+ log(fmt.status('PASS', 'Mission artifacts committed.'));
181
+ repaired = true;
182
+ } else {
183
+ error(fmt.status('WARN', `Failed to commit mission artifacts: ${commitRes.stderr}`));
184
+ blocker = `failed to commit mission artifacts: ${commitRes.stderr}`;
185
+ return { repaired: false, blocker };
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ // 2. Auto-rebase if branch is behind
192
+ if (isBehind) {
193
+ log('Branch appears behind primary branch. Calling rebase...');
194
+ let rebaseSuccess = false;
195
+ let rebaseError: string | null = null;
196
+ try {
197
+ await rebaseFn([slug], {
198
+ gitFn: (args: string[], opts: { cwd?: string }) => gitFn(args, { ...opts, cwd: rootDir }),
199
+ getCurrentBranchFn: () => getCurrentBranch(rootDir),
200
+ exitFn: (code: number) => {
201
+ if (code === 0) {
202
+ rebaseSuccess = true;
203
+ } else {
204
+ rebaseError = `rebase exited with code ${code}`;
205
+ }
206
+ }
207
+ });
208
+ } catch (err) {
209
+ rebaseError = (err instanceof Error) ? err.message : String(err);
210
+ }
211
+
212
+ if (!rebaseSuccess) {
213
+ const msg = rebaseError || 'unknown rebase failure';
214
+ error(fmt.status('WARN', `Auto-rebase failed: ${msg}`));
215
+ blocker = `Auto-rebase failed: ${msg}`;
216
+ repaired = false; // Reset repaired if rebase fails, even if auto-commit succeeded
217
+ } else {
218
+ repaired = true;
219
+ }
220
+ }
221
+
222
+ return { repaired, blocker };
223
+ }
224
+
225
+ (repairHandoff as any).isRelaunchableError = isRelaunchableError;
226
+ (repairHandoff as any).buildRelaunchPrompt = buildRelaunchPrompt;
227
+
228
+ export = repairHandoff;