@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
@@ -0,0 +1,665 @@
1
+ // @ts-nocheck
2
+ import { git, getWorktreeStatus } from '../core/git.js';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import * as fmt from '../core/fmt.js';
6
+ import * as missionStart from './mission-start.js';
7
+ import * as agents from '../agents/agents.js';
8
+ import { findMissionDir, findCheckpoints, getFirstLine, resolveWorktree, inferSlug, getMissionYear, missionDirForSlug, isWorkflowGeneratedArtifact } from '../core/mission-utils.js';
9
+ import * as handoff from './handoff.js';
10
+ import { resolveTaskFile, transitionTask, getTaskStatus, getTaskImplementer } from '../tools/backlog.js';
11
+ import { resolveAgentModel } from '../core/product-config.js';
12
+ import * as review from '../review/review.js';
13
+ import * as repairHandoff from './repair-handoff.js';
14
+ import * as stats from './stats.js';
15
+ import { resolveStageTelemetry } from '../agents/stage-telemetry.js';
16
+
17
+ const EXECUTE_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'execute.md');
18
+
19
+ /**
20
+ * @param {string[]} args
21
+ * @param {{inferSlugFn?: Function, missionStartFn?: Function, resolveWorktreeFn?: Function, readAgentConfigOrExitFn?: Function, resolveTaskFileFn?: Function, buildCheckpointContextFn?: Function, buildExecutePromptFn?: Function, selectLaunchAndRecordFn?: Function, enforceExecuteCommitSafetyFn?: Function, runHandoffAndReviewFn?: Function, exitFn?: Function, logFn?: Function, errorFn?: Function}} [options]
22
+ */
23
+ async function active(args, options = {}) {
24
+ const {
25
+ inferSlugFn = inferSlug,
26
+ missionStartFn = missionStart,
27
+ resolveWorktreeFn = resolveWorktree,
28
+ readAgentConfigOrExitFn = agents.readAgentConfigOrExit,
29
+ resolveTaskFileFn = resolveTaskFile,
30
+ buildCheckpointContextFn = buildCheckpointContext,
31
+ buildExecutePromptFn = buildExecutePrompt,
32
+ selectLaunchAndRecordFn = selectLaunchAndRecord,
33
+ enforceExecuteCommitSafetyFn = enforceExecuteCommitSafety,
34
+ runHandoffAndReviewFn = runHandoffAndReview,
35
+ exitFn = process.exit,
36
+ logFn = fmt.log.info,
37
+ errorFn = fmt.log.fail
38
+ } = options;
39
+ const explicitSlug = args[0];
40
+ const slug = inferSlugFn(explicitSlug);
41
+ if (!slug) {
42
+ errorFn('Usage: px active [<slug>] [--implementer <family>]');
43
+ exitFn(1);
44
+ return;
45
+ }
46
+
47
+ const normalizedSlug = slug.toLowerCase();
48
+
49
+ // Allow operators to pin the implementer agent family via CLI flag instead of WORKFLOW_AGENT env var.
50
+ /** @param {string[]} arr @param {string} flag @param {string} name */
51
+ function flagValue(arr, flag, name) {
52
+ const i = arr.indexOf(flag);
53
+ if (i === -1) {return null;}
54
+ const v = arr[i + 1];
55
+ if (!v || v.startsWith('--')) {
56
+ errorFn(fmt.status('FAIL', `Missing value for --${name}. Usage: px active <slug> --${name} <family>`));
57
+ exitFn(1);
58
+ return null;
59
+ }
60
+ return v;
61
+ }
62
+ const preselectedImplementer = flagValue(args, '--implementer', 'implementer');
63
+
64
+ logFn('Running execute preflight...');
65
+ const preflight = missionStartFn([normalizedSlug], { returnResult: true });
66
+ if (!preflight.pass) {
67
+ errorFn('Preflight failed. Fix blockers above before launching the execute agent.');
68
+ exitFn(1);
69
+ return;
70
+ }
71
+
72
+ const worktree = resolveWorktreeFn(normalizedSlug);
73
+ if (!worktree) {
74
+ errorFn(
75
+ `Could not locate dedicated worktree for mission/${fmt.slug(normalizedSlug)}. ` +
76
+ `Run "px draft ${normalizedSlug}" first or create the worktree manually.`
77
+ );
78
+ exitFn(1);
79
+ return;
80
+ }
81
+
82
+ const agentConfig = readAgentConfigOrExitFn();
83
+ const taskResolution = resolveTaskFileFn(normalizedSlug, worktree);
84
+ const checkpointContext = buildCheckpointContextFn(normalizedSlug);
85
+ const prompt = buildExecutePromptFn(normalizedSlug, checkpointContext, { rootDir: worktree });
86
+
87
+ logFn('Launching execute agent...');
88
+ let launchResult;
89
+ try {
90
+ launchResult = await selectLaunchAndRecordFn({
91
+ slug: normalizedSlug,
92
+ worktree,
93
+ preselectedAgent: preselectedImplementer,
94
+ agentConfig,
95
+ taskResolution,
96
+ prompt
97
+ });
98
+ } catch (err) {
99
+ errorFn(`Could not launch execute agent: ${/** @type{Error} */(err).message}`);
100
+ exitFn(1);
101
+ return;
102
+ }
103
+
104
+ const { agent, result } = launchResult;
105
+
106
+ if (result.error) {
107
+ errorFn(`Could not start execute agent (${fmt.agent(agent)}): ${result.error.message}`);
108
+ exitFn(1);
109
+ return;
110
+ }
111
+
112
+ if (typeof result.status === 'number' && result.status !== 0) {
113
+ errorFn(`Execute agent (${fmt.agent(agent)}) exited with status ${result.status}.`);
114
+ exitFn(result.status || 1);
115
+ return;
116
+ }
117
+
118
+ try {
119
+ enforceExecuteCommitSafetyFn({ slug: normalizedSlug, worktree });
120
+ } catch (error) {
121
+ errorFn(fmt.status('FAIL', /** @type{Error} */(error).message));
122
+ exitFn(1);
123
+ return;
124
+ }
125
+
126
+ // Automation: Post-active handoff
127
+ logFn(`\nExecute agent (${fmt.agent(agent)}) completed successfully. Recording execute-phase stats...`);
128
+ try {
129
+ const result = launchResult.result;
130
+ const sinceMs = result && result.startedAt ? Date.parse(result.startedAt) : 0;
131
+ stats.recordActiveStats(
132
+ /** @type{Parameters<typeof stats.recordActiveStats>[0]} */({
133
+ slug: normalizedSlug,
134
+ rootDir: worktree,
135
+ implementer: agent,
136
+ model: resolveAgentModel(agent, worktree),
137
+ // Use the windowed Codex rollout (bounded by sinceMs) when present, else
138
+ // the launcher-attached telemetry — same resolution as the review hook,
139
+ // so the execute phase records this stage's delta, not cumulative usage.
140
+ telemetry: resolveStageTelemetry({ worktree, result, sinceMs }),
141
+ durationMinutes: result && result.startedAt && result.endedAt
142
+ ? (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000
143
+ : 0,
144
+ })
145
+ );
146
+ } catch (err) {
147
+ logFn(fmt.status('WARN', `Could not record execute stats for ${fmt.slug(normalizedSlug)}: ${/** @type{Error} */(err).message}`));
148
+ }
149
+ logFn(`\nExecute agent (${fmt.agent(agent)}) completed successfully. Starting automated handoff...`);
150
+ const ok = await runHandoffAndReviewFn(normalizedSlug, worktree, agent, {
151
+ taskFile: taskResolution.ok ? taskResolution.taskFile : null
152
+ });
153
+ if (!ok) {
154
+ exitFn(1);
155
+ }
156
+ }
157
+
158
+ // Select an active-step agent, launch it, and record the actual implementer in
159
+ // Backlog only after a successful launch. Accepts injectable dependencies so the
160
+ // selection/launch/record sequence can be verified in tests without process.exit.
161
+ //
162
+ // State-ordering contract:
163
+ // selectAgent → startAgent (launch) → record Backlog (status=active, assignee)
164
+ //
165
+ // Backlog status is moved to 'active' from the onLaunch hook, which fires
166
+ // immediately after the launcher successfully spawns the process. If the final
167
+ // launch result later fails, we roll the task status back to the prior status.
168
+ /**
169
+ * @param {{slug: string, worktree: string, preselectedAgent?: string | null, agentConfig: object, taskResolution: object, prompt: string, startAgentFn?: Function, transitionTaskFn?: Function, getTaskStatusFn?: Function, getTaskImplementerFn?: Function, selectAgentFn?: Function, log?: Function}} opts
170
+ */
171
+ async function selectLaunchAndRecord(opts) {
172
+ const {
173
+ slug,
174
+ worktree,
175
+ preselectedAgent = null,
176
+ agentConfig,
177
+ taskResolution,
178
+ prompt,
179
+ startAgentFn = (/** @type{string} */ step, /** @type{any} */ opts) => agents.startAgent(step, opts),
180
+ transitionTaskFn = transitionTask,
181
+ getTaskStatusFn = getTaskStatus,
182
+ getTaskImplementerFn = getTaskImplementer,
183
+ selectAgentFn = agents.selectAgent,
184
+ log = fmt.log.plain
185
+ } = opts;
186
+ const preselected = preselectedAgent || selectAgentFn('active', { config: agentConfig });
187
+ const taskResolutionTyped = /** @type{{ok: boolean, taskFile?: string} | undefined} */(taskResolution);
188
+ const taskFile = taskResolutionTyped && taskResolutionTyped.ok && taskResolutionTyped.taskFile
189
+ ? taskResolutionTyped.taskFile
190
+ : null;
191
+ const priorStatus = taskFile ? getTaskStatusFn(taskFile) : null;
192
+ const priorImplementer = taskFile ? getTaskImplementerFn(taskFile) : null;
193
+ let launchRecorded = false;
194
+ let launchTransitionFailed = false;
195
+ let launchedAgent = null;
196
+ const rollbackIfNeeded = ({ throwOnFailure = true } = {}) => {
197
+ if (!launchRecorded || !priorStatus) {
198
+ return;
199
+ }
200
+
201
+ log(fmt.status('WARN', `Execute launch for ${fmt.slug(slug)} did not complete cleanly; rolling task state back to ${priorStatus} / ${priorImplementer || 'none'}.`));
202
+ /** @type{{rootDir: string, log: Function, implementer?: string, clearAssignee?: boolean}} */
203
+ const rollbackOpts = { rootDir: worktree, log };
204
+ if (priorImplementer) {
205
+ rollbackOpts.implementer = priorImplementer;
206
+ } else {
207
+ rollbackOpts.clearAssignee = true;
208
+ }
209
+ if (!transitionTaskFn(slug, priorStatus, rollbackOpts)) {
210
+ const msg = `Failed to roll back task ${fmt.slug(slug)} to ${priorStatus} after execute launch failure.`;
211
+ if (throwOnFailure) {throw new Error(msg);}
212
+ log(fmt.status('WARN', msg));
213
+ }
214
+ launchRecorded = false;
215
+ };
216
+
217
+ let actual;
218
+ let result;
219
+ try {
220
+ ({ agent: actual, result } = await startAgentFn('active', {
221
+ prompt,
222
+ worktree,
223
+ agent: preselected,
224
+ slug: slug,
225
+ role: 'implementer',
226
+ onLaunch: (/** @type{{agent: string}} */ { agent }) => {
227
+ launchedAgent = agent;
228
+ if (!(taskResolutionTyped && taskResolutionTyped.ok)) {
229
+ return;
230
+ }
231
+
232
+ log(`Recording implementer ${fmt.agent(agent)} and status=active for ${fmt.slug(slug)}...`);
233
+ if (!transitionTaskFn(slug, 'active', { implementer: agent, rootDir: worktree, log })) {
234
+ launchTransitionFailed = true;
235
+ return;
236
+ }
237
+
238
+ launchRecorded = true;
239
+ },
240
+ onLimitHit: () => {
241
+ // Roll back the intermediate active write so the retry's onLaunch starts
242
+ // from a clean state, preventing a spurious committed implementer entry.
243
+ rollbackIfNeeded({ throwOnFailure: false });
244
+ }
245
+ }));
246
+ } catch (err) {
247
+ rollbackIfNeeded();
248
+ throw err;
249
+ }
250
+
251
+ // `actual` is the family that actually ran (may differ from `preselected` after
252
+ // a limit-hit fallback inside startAgent). Use it as the canonical implementer.
253
+ const agent = actual || launchedAgent || preselected;
254
+ const launchSucceeded = !result.error && (typeof result.status !== 'number' || result.status === 0);
255
+
256
+ if (!launchSucceeded) {
257
+ rollbackIfNeeded();
258
+ }
259
+
260
+ if (launchTransitionFailed) {
261
+ throw new Error(`Failed to record task ${fmt.slug(slug)} as active after execute launch.`);
262
+ }
263
+
264
+ return { preselected, agent, result };
265
+ }
266
+
267
+ // When startAgent falls back to a different family after a limit hit, the
268
+ // implementer that actually ran is `actual`, not the originally `preselected`
269
+ // one. Re-record the resolved agent in the Backlog task so the post-active
270
+ // handoff and the autonomous review loop poll the correct Forgejo identity.
271
+ /** @param {{slug: string, preselected: string, actual: string, taskResolution: object, worktree: string, log?: Function, transitionTaskFn?: Function}} opts */
272
+ function applyExecuteFallback(opts) {
273
+ const {
274
+ slug,
275
+ preselected,
276
+ actual,
277
+ taskResolution,
278
+ worktree,
279
+ log = fmt.log.plain,
280
+ transitionTaskFn = transitionTask
281
+ } = opts;
282
+ if (!actual || actual === preselected) {
283
+ return preselected;
284
+ }
285
+ const taskResolutionTyped2 = /** @type{{ok: boolean, taskFile?: string} | undefined} */(taskResolution);
286
+ if (taskResolutionTyped2 && taskResolutionTyped2.ok) {
287
+ log(fmt.status('INFO', `Execute agent fell back from ${fmt.agent(preselected)} to ${fmt.agent(actual)}; enforcing backlog assignee.`));
288
+ transitionTaskFn(slug, 'active', { implementer: actual, rootDir: worktree, log });
289
+ }
290
+ return actual;
291
+ }
292
+
293
+ /**
294
+ * Attempt to relaunch an agent to fix a repairable handoff error.
295
+ * For resume-capable agents, the startAgent function handles resume flags internally.
296
+ *
297
+ * @param {string} slug - Mission slug
298
+ * @param {string} worktree - Path to the mission worktree
299
+ * @param {string} errorMsg - The error message from the failed handoff
300
+ * @param {string} agent - The agent family to relaunch
301
+ * @param {object} [options]
302
+ * @returns {Promise<{relaunched: boolean, error?: string}>} Result of relaunch attempt
303
+ */
304
+ /**
305
+ * @param {string} slug
306
+ * @param {string} worktree
307
+ * @param {string} errorMsg
308
+ * @param {string} agent
309
+ * @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function}} [options]
310
+ */
311
+ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {}) {
312
+ const {
313
+ isRelaunchableErrorFn = repairHandoff.isRelaunchableError,
314
+ buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt,
315
+ workflowLauncherStatusFn = agents.workflowLauncherStatus,
316
+ startAgentFn = agents.startAgent,
317
+ log = fmt.log.plain,
318
+ error = fmt.log.plainError
319
+ } = options;
320
+ // Check if this is a relaunchable error
321
+ if (!isRelaunchableErrorFn(errorMsg)) {
322
+ log(`Error is not relaunchable: ${errorMsg}`);
323
+ return { relaunched: false, error: 'Error is not relaunchable for agent relaunch' };
324
+ }
325
+
326
+ // Check if the agent launcher is available
327
+ const status = workflowLauncherStatusFn(agent);
328
+ if (!status.supported) {
329
+ error(`Agent ${fmt.agent(agent)} is not available for relaunch: ${status.detail || status.reason || 'unknown'}`);
330
+ return { relaunched: false, error: `Agent ${agent} launcher is not available` };
331
+ }
332
+
333
+ // Build the relaunch prompt
334
+ const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree);
335
+
336
+ log(`Attempting to relaunch ${fmt.agent(agent)} to fix repairable handoff error...`);
337
+ // startAgent handles resume flags internally for resume-capable agents (codex, claude, gemini, custom)
338
+
339
+ try {
340
+ const result = await startAgentFn('active', {
341
+ prompt,
342
+ worktree,
343
+ agent,
344
+ slug,
345
+ role: 'implementer',
346
+ // startAgent will use RESUME_CAPABLE set and session markers to decide resume
347
+ onLaunch: (/** @type{{agent: string}} */ { agent: launchedAgent }) => {
348
+ log(`Relaunched ${fmt.agent(launchedAgent)} for repair. Session persistence will be used if available.`);
349
+ }
350
+ });
351
+
352
+ if (result.error) {
353
+ error(`Relaunch failed: ${result.error.message || String(result.error)}`);
354
+ return { relaunched: false, error: result.error.message || String(result.error) };
355
+ }
356
+
357
+ if (typeof result.result.status === 'number' && result.result.status !== 0) {
358
+ error(`Relaunch agent exited with status ${result.result.status}`);
359
+ return { relaunched: false, error: `Agent exited with status ${result.result.status}` };
360
+ }
361
+
362
+ log(`Relaunch successful. ${fmt.agent(agent)} is now running to fix the handoff error.`);
363
+ return { relaunched: true };
364
+ } catch (err) {
365
+ error(`Relaunch failed with exception: ${/** @type{Error} */(err).message}`);
366
+ return { relaunched: false, error: /** @type{Error} */(err).message };
367
+ }
368
+ }
369
+
370
+ /** @param {string} slug @param {string} worktree @param {{findMissionDirFn?: Function, findCheckpointsFn?: Function, runFn?: Function, log?: Function, error?: Function}} [options] */
371
+ function validateCheckpointsBeforeHandoff(slug, worktree, options = {}) {
372
+ const {
373
+ findMissionDirFn = findMissionDir,
374
+ findCheckpointsFn = findCheckpoints,
375
+ runFn = git,
376
+ log = fmt.log.plain,
377
+ error = fmt.log.plainError
378
+ } = options;
379
+ const rootDir = worktree || process.cwd();
380
+ const missionDir = findMissionDirFn(slug, rootDir);
381
+ if (!missionDir) {
382
+ const msg = `Mission directory not found for slug: ${fmt.slug(slug)}. Cannot validate checkpoints.`;
383
+ error(msg);
384
+ return { ok: false, error: msg };
385
+ }
386
+
387
+ const checkpoints = findCheckpointsFn(missionDir);
388
+ if (checkpoints.length === 0) {
389
+ const msg = `No checkpoint documents found in ${fmt.path(missionDir)}. The execute agent must create checkpoint documents (CP-N.md) with a Goal Check table before handoff. Create at least CP-1 documenting your implementation, including a Goal Check table with real evidence (file:line, test names).`;
390
+ error(msg);
391
+ return { ok: false, error: msg };
392
+ }
393
+
394
+ const checkpointArgs = checkpoints.map((/** @type{string} */ checkpoint) => path.relative(rootDir, checkpoint) || checkpoint);
395
+ const statusResult = runFn(['status', '--porcelain', '--', ...checkpointArgs], { cwd: rootDir });
396
+ if (statusResult.status !== 0) {
397
+ const statusError = (statusResult.stderr || statusResult.stdout || '').trim() || 'git status failed';
398
+ const msg = `Could not verify checkpoint commit state for ${fmt.slug(slug)}: ${statusError}`;
399
+ error(msg);
400
+ return { ok: false, error: msg };
401
+ }
402
+
403
+ const dirtyCheckpointLines = (statusResult.stdout || '')
404
+ .split('\n')
405
+ .map((/** @type{string} */ line) => line.trimEnd())
406
+ .filter(Boolean);
407
+
408
+ if (dirtyCheckpointLines.length > 0) {
409
+ const dirtyPaths = dirtyCheckpointLines
410
+ .map((/** @type{string} */ line) => line.slice(3).trim())
411
+ .filter(Boolean);
412
+ const msg = `Checkpoint documents must be committed before handoff. Uncommitted checkpoint files: ${dirtyPaths.map((/** @type{string} */ p) => fmt.path(p)).join(', ')}. Commit the checkpoint update and re-run the handoff.`;
413
+ error(msg);
414
+ return { ok: false, error: msg };
415
+ }
416
+
417
+ log(fmt.status('PASS', `Found ${checkpoints.length} checkpoint document(s) in ${fmt.path(missionDir)}.`));
418
+ return { ok: true };
419
+ }
420
+
421
+ /**
422
+ * @param {string} slug
423
+ * @param {string} worktree
424
+ * @param {string} agent
425
+ * @param {{taskFile?: string | null, validateCheckpointsBeforeHandoffFn?: Function, performHandoff?: Function, startReviewLoop?: Function, repairHandoffFn?: {isRelaunchableError: Function, buildRelaunchPrompt: Function}, attemptAgentRelaunchFn?: Function, log?: Function, error?: Function}} [options]
426
+ */
427
+ async function runHandoffAndReview(slug, worktree, agent, options = {}) {
428
+ const {
429
+ taskFile = null,
430
+ validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff,
431
+ performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o),
432
+ startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o),
433
+ repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */(repairHandoff),
434
+ attemptAgentRelaunchFn = attemptAgentRelaunch,
435
+ log = fmt.log.plain,
436
+ error = fmt.log.plainError
437
+ } = options;
438
+ // Pre-handoff checkpoint enforcement: validate checkpoints exist before calling performHandoff()
439
+ // This catches missing checkpoints immediately after the execute agent exits,
440
+ // before the repair flow runs, and provides an explicit instruction to create them.
441
+ const validation = validateCheckpointsBeforeHandoffFn(slug, worktree, { log, error });
442
+ if (!validation.ok) {
443
+ // Don't attempt repair for missing checkpoints — instruct the agent to create them
444
+ error(` Create a checkpoint document (CP-N.md) in ${fmt.path(missionDirForSlug(worktree, slug))} with a Goal Check table.`);
445
+ error(` Then re-run: ${fmt.command(`px review ${slug} --submit`)}`);
446
+ return false;
447
+ }
448
+
449
+ let handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree });
450
+
451
+ if (!handoffResult.ok) {
452
+ // Attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
453
+ log(`\nAutomated handoff failed: ${handoffResult.error}`);
454
+ log(`Attempting post-execute repair...`);
455
+ const { repaired, blocker } = await /** @type{Function} */(repairHandoffFn)(slug, worktree, /** @type{string} */(handoffResult.error), { taskFile, log, error });
456
+ if (repaired) {
457
+ log(`Repair successful. Retrying automated handoff...`);
458
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
459
+ } else if (blocker) {
460
+ // If repair failed but provided a specific blocker (e.g. rebase failure),
461
+ // report that blocker as the final error instead of the original handoff error.
462
+ handoffResult.error = blocker;
463
+ } else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
464
+ // Attempt agent relaunch for repairable content errors (missing goal-check table)
465
+ log(`Content error detected. Attempting agent relaunch to fix...`);
466
+ const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(
467
+ slug, worktree, /** @type{string} */(handoffResult.error), agent, { log, error }
468
+ );
469
+ if (relaunched) {
470
+ // Agent was relaunched successfully; re-invoke performHandoff to verify
471
+ // the handoff-to-review transition actually completed, matching the
472
+ // contract of the repair-success path above.
473
+ log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
474
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
475
+ if (!handoffResult.ok) {
476
+ handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
477
+ }
478
+ // Fall through to gatekeeper pushback / review loop / failure handling below.
479
+ } else {
480
+ // Relaunch failed or was not possible
481
+ log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
482
+ // Fall through to manual handoff message
483
+ }
484
+ }
485
+ }
486
+
487
+ if (!handoffResult.ok) {
488
+ error(`Automated handoff failed: ${handoffResult.error}`);
489
+ error(' You may need to complete the handoff manually:');
490
+ error(` ${fmt.command(`px review ${slug} --submit`)}`);
491
+ return false;
492
+ }
493
+
494
+ /** @type{{ok: boolean, error?: string, gatekeeperPushedBack?: boolean}} */
495
+ const hr = handoffResult;
496
+ if (hr.gatekeeperPushedBack) {
497
+ log(`\nGatekeeper posted pushback for ${fmt.slug(slug)}; skipping autonomous review loop until artifacts are fixed.`);
498
+ return true;
499
+ }
500
+
501
+ log(`\nStarting autonomous review loop (implementer: ${fmt.agent(agent)})...`);
502
+ _startReviewLoop(slug, { implementer: agent, worktree, recordStageStatsSafeFn: review.recordStageStatsSafe });
503
+ return true;
504
+ }
505
+
506
+ /** @param {string} slug */
507
+ function buildCheckpointContext(slug) {
508
+ const missionDir = findMissionDir(slug);
509
+ if (!missionDir) {return 'No checkpoint documents found. Start from CP-1.';}
510
+
511
+ const checkpoints = findCheckpoints(missionDir);
512
+ if (checkpoints.length === 0) {return 'No checkpoint documents found. Start from CP-1.';}
513
+
514
+ const latest = checkpoints[checkpoints.length - 1];
515
+ const firstLine = getFirstLine(latest);
516
+ return `Most recent checkpoint: ${path.basename(latest)} — ${firstLine}\nResume from there, or start the next checkpoint if that one is complete.`;
517
+ }
518
+
519
+ /** @param {string} slug @param {string} rootDir */
520
+ function resolveExecuteTaskPath(slug, rootDir) {
521
+ const resolution = resolveTaskFile(slug, rootDir);
522
+ if (resolution && resolution.ok && resolution.taskFile) {
523
+ return resolution.taskFile;
524
+ }
525
+ return path.join(rootDir, 'backlog', 'tasks', `<${slug}>.md`);
526
+ }
527
+
528
+ /** @param {string} slug @param {string} checkpointContext @param {{rootDir?: string}} [options] */
529
+ function buildExecutePrompt(slug, checkpointContext, options = {}) {
530
+ const { rootDir = process.cwd() } = options;
531
+ const template = fs.readFileSync(EXECUTE_PROMPT_PATH, 'utf8');
532
+ const year = getMissionYear(slug, rootDir) || String(new Date().getFullYear());
533
+ const missionPath = path.join(missionDirForSlug(rootDir, slug), 'MISSION.md');
534
+ const missionDir = path.dirname(missionPath);
535
+ const taskPath = resolveExecuteTaskPath(slug, rootDir);
536
+ return template
537
+ .replaceAll('{{slug}}', slug)
538
+ .replaceAll('YYYY', year)
539
+ .replaceAll('{{missionPath}}', missionPath)
540
+ .replaceAll('{{missionDir}}', missionDir)
541
+ .replaceAll('{{taskPath}}', taskPath)
542
+ .replaceAll('{{checkpoint_context}}', checkpointContext || '');
543
+ }
544
+
545
+ // git status --porcelain wraps any path containing a space or other unusual
546
+ // byte in double quotes and C-escapes the contents (\\, \", \t, \n, \r, and
547
+ // \NNN octal for bytes >= 0x80 under the default core.quotePath). Those quotes
548
+ // and escapes are display syntax, not part of the on-disk path, so they must be
549
+ // decoded before the value is handed to `git add --` — otherwise git treats the
550
+ // quote-wrapped string as a pathspec that matches no file and aborts staging.
551
+ /** @param {string} rawPath */
552
+ function unquoteGitStatusPath(rawPath) {
553
+ if (rawPath.length < 2 || rawPath[0] !== '"' || rawPath[rawPath.length - 1] !== '"') {
554
+ return rawPath;
555
+ }
556
+ const inner = rawPath.slice(1, -1);
557
+ const simple = { a: 0x07, b: 0x08, t: 0x09, n: 0x0a, v: 0x0b, f: 0x0c, r: 0x0d, '"': 0x22, '\\': 0x5c };
558
+ const chunks = [];
559
+ let literal = '';
560
+ const flush = () => {
561
+ if (literal) {
562
+ chunks.push(Buffer.from(literal, 'utf8'));
563
+ literal = '';
564
+ }
565
+ };
566
+ for (let i = 0; i < inner.length; i++) {
567
+ const ch = inner[i];
568
+ if (ch !== '\\') {
569
+ literal += ch;
570
+ continue;
571
+ }
572
+ const next = inner[i + 1];
573
+ if (next >= '0' && next <= '7') {
574
+ let octal = '';
575
+ let j = i + 1;
576
+ while (j < inner.length && octal.length < 3 && inner[j] >= '0' && inner[j] <= '7') {
577
+ octal += inner[j];
578
+ j++;
579
+ }
580
+ flush();
581
+ chunks.push(Buffer.from([parseInt(octal, 8) & 0xff]));
582
+ i = j - 1;
583
+ continue;
584
+ }
585
+ if (Object.prototype.hasOwnProperty.call(simple, next)) {
586
+ flush();
587
+ chunks.push(Buffer.from([/** @type{number} */(simple[/** @type{keyof typeof simple} */(next)])]));
588
+ i++;
589
+ } else {
590
+ // Unknown or trailing escape: keep the following character literally.
591
+ literal += next === undefined ? '\\' : next;
592
+ if (next !== undefined) {i++;}
593
+ }
594
+ }
595
+ flush();
596
+ return Buffer.concat(chunks).toString('utf8');
597
+ }
598
+
599
+ /** @param {string} entry */
600
+ function parseDirtyEntry(entry) {
601
+ const match = entry.match(/^(.{1,2})\s+(.*)$/);
602
+ const status = (match ? match[1] : entry.slice(0, 2)).padEnd(2, ' ');
603
+ const rawPath = (match ? match[2] : entry.slice(2)).trim();
604
+ // For renames git reports `<old> -> <new>`; each side is independently quoted
605
+ // and the ` -> ` separator is always literal, so split before unquoting and
606
+ // keep staging the destination path.
607
+ const renamed = rawPath.includes(' -> ') ? (rawPath.split(' -> ').pop() || rawPath) : rawPath;
608
+ const filePath = unquoteGitStatusPath(renamed.trim());
609
+ return { status, filePath };
610
+ }
611
+
612
+ /** @param {string} filePath */
613
+ function isExecuteIgnoredPath(filePath) {
614
+ return isWorkflowGeneratedArtifact(filePath);
615
+ }
616
+
617
+ /** @param {{slug: string, worktree: string, dirtyEntries?: Array<{status: string, filePath: string}>, gitImpl?: Function}} opts */
618
+ function enforceExecuteCommitSafety(opts) {
619
+ const { slug, worktree, dirtyEntries = getWorktreeStatus(worktree), gitImpl = git } = opts;
620
+ const parsedEntries = /** @type{Array<{status: string, filePath: string}>} */(/** @type{Array<string>} */(dirtyEntries).map(parseDirtyEntry));
621
+ const relevantEntries = parsedEntries.filter(entry => !isExecuteIgnoredPath(entry.filePath));
622
+ const conflictEntries = relevantEntries.filter(entry =>
623
+ ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(entry.status.trim())
624
+ );
625
+
626
+ if (conflictEntries.length > 0) {
627
+ throw new Error(
628
+ `Execute safety harness found unresolved conflicts: ${conflictEntries.map(entry => entry.filePath).join(', ')}`
629
+ );
630
+ }
631
+
632
+ if (relevantEntries.length === 0) {
633
+ fmt.log.pass('Execute safety harness: no uncommitted mission changes left behind.');
634
+ return false;
635
+ }
636
+
637
+ fmt.log.warn('Execute safety harness: execute agent left uncommitted changes. Creating fallback commit.');
638
+ for (const entry of relevantEntries) {
639
+ fmt.log.plain(` ${entry.status} ${entry.filePath}`);
640
+ }
641
+
642
+ const stagePaths = relevantEntries.map(entry => entry.filePath);
643
+ gitImpl(['-C', worktree, 'add', '--', ...stagePaths]);
644
+ const commitMessage = `execute(${slug}): capture agent output`;
645
+ const commitResult = gitImpl([
646
+ '-C',
647
+ worktree,
648
+ 'commit',
649
+ '-m',
650
+ commitMessage,
651
+ '-m',
652
+ 'Safety harness: capture implementation changes left uncommitted by the execute agent.'
653
+ ]);
654
+
655
+ if (commitResult.status !== 0) {
656
+ throw new Error('Execute safety harness could not create fallback commit.');
657
+ }
658
+
659
+ fmt.log.pass(`Execute safety harness committed remaining changes with "${commitMessage}".`);
660
+ return true;
661
+ }
662
+
663
+ /** @type {typeof active & {buildExecutePrompt: typeof buildExecutePrompt, buildCheckpointContext: typeof buildCheckpointContext, runHandoffAndReview: typeof runHandoffAndReview, applyExecuteFallback: typeof applyExecuteFallback, selectLaunchAndRecord: typeof selectLaunchAndRecord, validateCheckpointsBeforeHandoff: typeof validateCheckpointsBeforeHandoff, attemptAgentRelaunch: typeof attemptAgentRelaunch, enforceExecuteCommitSafety: typeof enforceExecuteCommitSafety, unquoteGitStatusPath: typeof unquoteGitStatusPath}} */
664
+ const _activeExport = Object.assign(active, { buildExecutePrompt, buildCheckpointContext, runHandoffAndReview, applyExecuteFallback, selectLaunchAndRecord, validateCheckpointsBeforeHandoff, attemptAgentRelaunch, enforceExecuteCommitSafety, unquoteGitStatusPath });
665
+ export = _activeExport;