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