@magnusekdahl/parallix 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/integration-pipelines.json +9 -0
- package/config/workflow.config.schema.json +12 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
- package/docs/adr/0041-integration-pipeline-gates.md +1 -1
- package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
- package/docs/adr/index.md +1 -0
- package/docs/use-cases.md +44 -0
- package/lib/agents/agents.js +149 -100
- package/lib/agents/claude-telemetry.js +14 -10
- package/lib/agents/claude.js +32 -11
- package/lib/agents/codex-telemetry.js +32 -19
- package/lib/agents/codex.js +36 -11
- package/lib/agents/limit-hit.js +42 -25
- package/lib/agents/mistral-telemetry.js +1 -1
- package/lib/agents/mistral.js +13 -3
- package/lib/agents/opencode-export.js +11 -5
- package/lib/agents/opencode-telemetry.js +75 -90
- package/lib/agents/opencode.js +86 -29
- package/lib/agents/stage-telemetry.js +3 -7
- package/lib/commands/active.js +139 -94
- package/lib/commands/checkpoint.js +3 -1
- package/lib/commands/config.js +5 -3
- package/lib/commands/coverage-gate.js +17 -10
- package/lib/commands/diff.js +11 -5
- package/lib/commands/draft.js +95 -55
- package/lib/commands/handoff.js +253 -65
- package/lib/commands/integrate.js +288 -188
- package/lib/commands/mission-start.js +39 -34
- package/lib/commands/rebase.js +32 -21
- package/lib/commands/repair-handoff.js +21 -18
- package/lib/commands/resolve-conflict.js +5 -2
- package/lib/commands/review.js +1 -0
- package/lib/commands/setup-review.js +1 -0
- package/lib/commands/stats-backfill.js +50 -41
- package/lib/commands/stats.js +653 -204
- package/lib/commands/status.js +33 -28
- package/lib/core/fmt.js +58 -15
- package/lib/core/git.js +7 -5
- package/lib/core/gitignore.js +6 -4
- package/lib/core/mission-utils.js +161 -88
- package/lib/core/nels.js +199 -0
- package/lib/core/persistent-data-migration.js +55 -23
- package/lib/core/product-config.js +44 -23
- package/lib/core/runtime-matrix.js +15 -2
- package/lib/core/spawn-tee.js +40 -13
- package/lib/core/state-map.js +32 -13
- package/lib/core/storage.js +19 -5
- package/lib/core/subagent-limit.js +28 -0
- package/lib/core/verification.js +29 -12
- package/lib/review/rebase.js +11 -3
- package/lib/review/review-adapter.js +93 -6
- package/lib/review/review-artifacts.js +89 -23
- package/lib/review/review-commands.js +139 -45
- package/lib/review/review-events.js +103 -52
- package/lib/review/review-loop.js +117 -87
- package/lib/review/review-polling.js +25 -5
- package/lib/review/review-prompts.js +36 -5
- package/lib/review/review-state.js +39 -15
- package/lib/tools/backlog.js +162 -71
- package/lib/tools/forgejo.js +290 -129
- package/lib/tools/gatekeeper.js +15 -0
- package/lib/tools/redgreen.js +220 -0
- package/lib/tools/sessions.js +12 -5
- package/lib/tools/setup-review.js +170 -70
- package/package.json +11 -3
- package/prompts/draft.md +6 -0
- package/templates/mission-scaffold.md +1 -1
package/lib/commands/active.js
CHANGED
|
@@ -7,7 +7,7 @@ const agents = require('../agents/agents');
|
|
|
7
7
|
const { findMissionDir, findCheckpoints, getFirstLine, resolveWorktree, inferSlug, getMissionYear, missionDirForSlug, isWorkflowGeneratedArtifact } = require('../core/mission-utils');
|
|
8
8
|
const handoff = require('./handoff');
|
|
9
9
|
const { resolveTaskFile, transitionTask, getTaskStatus, getTaskImplementer } = require('../tools/backlog');
|
|
10
|
-
const {
|
|
10
|
+
const { resolveAgentModel } = require('../core/product-config');
|
|
11
11
|
const review = require('../review/review');
|
|
12
12
|
const repairHandoff = require('./repair-handoff');
|
|
13
13
|
const stats = require('./stats');
|
|
@@ -15,21 +15,26 @@ const { resolveStageTelemetry } = require('../agents/stage-telemetry');
|
|
|
15
15
|
|
|
16
16
|
const EXECUTE_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'execute.md');
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
/**
|
|
19
|
+
* @param {string[]} args
|
|
20
|
+
* @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
|
+
*/
|
|
22
|
+
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;
|
|
33
38
|
const explicitSlug = args[0];
|
|
34
39
|
const slug = inferSlugFn(explicitSlug);
|
|
35
40
|
if (!slug) {
|
|
@@ -41,9 +46,10 @@ async function active(args, {
|
|
|
41
46
|
const normalizedSlug = slug.toLowerCase();
|
|
42
47
|
|
|
43
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 */
|
|
44
50
|
function flagValue(arr, flag, name) {
|
|
45
51
|
const i = arr.indexOf(flag);
|
|
46
|
-
if (i === -1) return null;
|
|
52
|
+
if (i === -1) {return null;}
|
|
47
53
|
const v = arr[i + 1];
|
|
48
54
|
if (!v || v.startsWith('--')) {
|
|
49
55
|
errorFn(fmt.status('FAIL', `Missing value for --${name}. Usage: px active <slug> --${name} <family>`));
|
|
@@ -89,7 +95,7 @@ async function active(args, {
|
|
|
89
95
|
prompt
|
|
90
96
|
});
|
|
91
97
|
} catch (err) {
|
|
92
|
-
errorFn(`Could not launch execute agent: ${err.message}`);
|
|
98
|
+
errorFn(`Could not launch execute agent: ${/** @type{Error} */(err).message}`);
|
|
93
99
|
exitFn(1);
|
|
94
100
|
return;
|
|
95
101
|
}
|
|
@@ -111,7 +117,7 @@ async function active(args, {
|
|
|
111
117
|
try {
|
|
112
118
|
enforceExecuteCommitSafetyFn({ slug: normalizedSlug, worktree });
|
|
113
119
|
} catch (error) {
|
|
114
|
-
errorFn(fmt.status('FAIL', error.message));
|
|
120
|
+
errorFn(fmt.status('FAIL', /** @type{Error} */(error).message));
|
|
115
121
|
exitFn(1);
|
|
116
122
|
return;
|
|
117
123
|
}
|
|
@@ -121,7 +127,8 @@ async function active(args, {
|
|
|
121
127
|
try {
|
|
122
128
|
const result = launchResult.result;
|
|
123
129
|
const sinceMs = result && result.startedAt ? Date.parse(result.startedAt) : 0;
|
|
124
|
-
stats.recordActiveStats(
|
|
130
|
+
stats.recordActiveStats(
|
|
131
|
+
/** @type{Parameters<typeof stats.recordActiveStats>[0]} */({
|
|
125
132
|
slug: normalizedSlug,
|
|
126
133
|
rootDir: worktree,
|
|
127
134
|
implementer: agent,
|
|
@@ -133,9 +140,10 @@ async function active(args, {
|
|
|
133
140
|
durationMinutes: result && result.startedAt && result.endedAt
|
|
134
141
|
? (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000
|
|
135
142
|
: 0,
|
|
136
|
-
})
|
|
143
|
+
})
|
|
144
|
+
);
|
|
137
145
|
} catch (err) {
|
|
138
|
-
logFn(fmt.status('WARN', `Could not record execute stats for ${fmt.slug(normalizedSlug)}: ${err.message}`));
|
|
146
|
+
logFn(fmt.status('WARN', `Could not record execute stats for ${fmt.slug(normalizedSlug)}: ${/** @type{Error} */(err).message}`));
|
|
139
147
|
}
|
|
140
148
|
logFn(`\nExecute agent (${fmt.agent(agent)}) completed successfully. Starting automated handoff...`);
|
|
141
149
|
const ok = await runHandoffAndReviewFn(normalizedSlug, worktree, agent, {
|
|
@@ -156,23 +164,28 @@ async function active(args, {
|
|
|
156
164
|
// Backlog status is moved to 'active' from the onLaunch hook, which fires
|
|
157
165
|
// immediately after the launcher successfully spawns the process. If the final
|
|
158
166
|
// launch result later fails, we roll the task status back to the prior status.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
167
|
+
/**
|
|
168
|
+
* @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
|
+
*/
|
|
170
|
+
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;
|
|
173
185
|
const preselected = preselectedAgent || selectAgentFn('active', { config: agentConfig });
|
|
174
|
-
const
|
|
175
|
-
|
|
186
|
+
const taskResolutionTyped = /** @type{{ok: boolean, taskFile?: string} | undefined} */(taskResolution);
|
|
187
|
+
const taskFile = taskResolutionTyped && taskResolutionTyped.ok && taskResolutionTyped.taskFile
|
|
188
|
+
? taskResolutionTyped.taskFile
|
|
176
189
|
: null;
|
|
177
190
|
const priorStatus = taskFile ? getTaskStatusFn(taskFile) : null;
|
|
178
191
|
const priorImplementer = taskFile ? getTaskImplementerFn(taskFile) : null;
|
|
@@ -185,6 +198,7 @@ async function selectLaunchAndRecord({
|
|
|
185
198
|
}
|
|
186
199
|
|
|
187
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}} */
|
|
188
202
|
const rollbackOpts = { rootDir: worktree, log };
|
|
189
203
|
if (priorImplementer) {
|
|
190
204
|
rollbackOpts.implementer = priorImplementer;
|
|
@@ -193,7 +207,7 @@ async function selectLaunchAndRecord({
|
|
|
193
207
|
}
|
|
194
208
|
if (!transitionTaskFn(slug, priorStatus, rollbackOpts)) {
|
|
195
209
|
const msg = `Failed to roll back task ${fmt.slug(slug)} to ${priorStatus} after execute launch failure.`;
|
|
196
|
-
if (throwOnFailure) throw new Error(msg);
|
|
210
|
+
if (throwOnFailure) {throw new Error(msg);}
|
|
197
211
|
log(fmt.status('WARN', msg));
|
|
198
212
|
}
|
|
199
213
|
launchRecorded = false;
|
|
@@ -208,9 +222,9 @@ async function selectLaunchAndRecord({
|
|
|
208
222
|
agent: preselected,
|
|
209
223
|
slug: slug,
|
|
210
224
|
role: 'implementer',
|
|
211
|
-
onLaunch: ({ agent }) => {
|
|
225
|
+
onLaunch: (/** @type{{agent: string}} */ { agent }) => {
|
|
212
226
|
launchedAgent = agent;
|
|
213
|
-
if (!(
|
|
227
|
+
if (!(taskResolutionTyped && taskResolutionTyped.ok)) {
|
|
214
228
|
return;
|
|
215
229
|
}
|
|
216
230
|
|
|
@@ -253,19 +267,22 @@ async function selectLaunchAndRecord({
|
|
|
253
267
|
// implementer that actually ran is `actual`, not the originally `preselected`
|
|
254
268
|
// one. Re-record the resolved agent in the Backlog task so the post-active
|
|
255
269
|
// handoff and the autonomous review loop poll the correct Forgejo identity.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
270
|
+
/** @param {{slug: string, preselected: string, actual: string, taskResolution: object, worktree: string, log?: Function, transitionTaskFn?: Function}} opts */
|
|
271
|
+
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;
|
|
265
281
|
if (!actual || actual === preselected) {
|
|
266
282
|
return preselected;
|
|
267
283
|
}
|
|
268
|
-
|
|
284
|
+
const taskResolutionTyped2 = /** @type{{ok: boolean, taskFile?: string} | undefined} */(taskResolution);
|
|
285
|
+
if (taskResolutionTyped2 && taskResolutionTyped2.ok) {
|
|
269
286
|
log(fmt.status('INFO', `Execute agent fell back from ${fmt.agent(preselected)} to ${fmt.agent(actual)}; enforcing backlog assignee.`));
|
|
270
287
|
transitionTaskFn(slug, 'active', { implementer: actual, rootDir: worktree, log });
|
|
271
288
|
}
|
|
@@ -283,14 +300,22 @@ function applyExecuteFallback({
|
|
|
283
300
|
* @param {object} [options]
|
|
284
301
|
* @returns {Promise<{relaunched: boolean, error?: string}>} Result of relaunch attempt
|
|
285
302
|
*/
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} slug
|
|
305
|
+
* @param {string} worktree
|
|
306
|
+
* @param {string} errorMsg
|
|
307
|
+
* @param {string} agent
|
|
308
|
+
* @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function}} [options]
|
|
309
|
+
*/
|
|
310
|
+
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;
|
|
294
319
|
// Check if this is a relaunchable error
|
|
295
320
|
if (!isRelaunchableErrorFn(errorMsg)) {
|
|
296
321
|
log(`Error is not relaunchable: ${errorMsg}`);
|
|
@@ -318,7 +343,7 @@ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, {
|
|
|
318
343
|
slug,
|
|
319
344
|
role: 'implementer',
|
|
320
345
|
// startAgent will use RESUME_CAPABLE set and session markers to decide resume
|
|
321
|
-
onLaunch: ({ agent: launchedAgent }) => {
|
|
346
|
+
onLaunch: (/** @type{{agent: string}} */ { agent: launchedAgent }) => {
|
|
322
347
|
log(`Relaunched ${fmt.agent(launchedAgent)} for repair. Session persistence will be used if available.`);
|
|
323
348
|
}
|
|
324
349
|
});
|
|
@@ -336,18 +361,20 @@ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, {
|
|
|
336
361
|
log(`Relaunch successful. ${fmt.agent(agent)} is now running to fix the handoff error.`);
|
|
337
362
|
return { relaunched: true };
|
|
338
363
|
} catch (err) {
|
|
339
|
-
error(`Relaunch failed with exception: ${err.message}`);
|
|
340
|
-
return { relaunched: false, error: err.message };
|
|
364
|
+
error(`Relaunch failed with exception: ${/** @type{Error} */(err).message}`);
|
|
365
|
+
return { relaunched: false, error: /** @type{Error} */(err).message };
|
|
341
366
|
}
|
|
342
367
|
}
|
|
343
368
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
369
|
+
/** @param {string} slug @param {string} worktree @param {{findMissionDirFn?: Function, findCheckpointsFn?: Function, runFn?: Function, log?: Function, error?: Function}} [options] */
|
|
370
|
+
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;
|
|
351
378
|
const rootDir = worktree || process.cwd();
|
|
352
379
|
const missionDir = findMissionDirFn(slug, rootDir);
|
|
353
380
|
if (!missionDir) {
|
|
@@ -363,7 +390,7 @@ function validateCheckpointsBeforeHandoff(slug, worktree, {
|
|
|
363
390
|
return { ok: false, error: msg };
|
|
364
391
|
}
|
|
365
392
|
|
|
366
|
-
const checkpointArgs = checkpoints.map(checkpoint => path.relative(rootDir, checkpoint) || checkpoint);
|
|
393
|
+
const checkpointArgs = checkpoints.map((/** @type{string} */ checkpoint) => path.relative(rootDir, checkpoint) || checkpoint);
|
|
367
394
|
const statusResult = runFn(['status', '--porcelain', '--', ...checkpointArgs], { cwd: rootDir });
|
|
368
395
|
if (statusResult.status !== 0) {
|
|
369
396
|
const statusError = (statusResult.stderr || statusResult.stdout || '').trim() || 'git status failed';
|
|
@@ -374,14 +401,14 @@ function validateCheckpointsBeforeHandoff(slug, worktree, {
|
|
|
374
401
|
|
|
375
402
|
const dirtyCheckpointLines = (statusResult.stdout || '')
|
|
376
403
|
.split('\n')
|
|
377
|
-
.map(line => line.trimEnd())
|
|
404
|
+
.map((/** @type{string} */ line) => line.trimEnd())
|
|
378
405
|
.filter(Boolean);
|
|
379
406
|
|
|
380
407
|
if (dirtyCheckpointLines.length > 0) {
|
|
381
408
|
const dirtyPaths = dirtyCheckpointLines
|
|
382
|
-
.map(line => line.slice(3).trim())
|
|
409
|
+
.map((/** @type{string} */ line) => line.slice(3).trim())
|
|
383
410
|
.filter(Boolean);
|
|
384
|
-
const msg = `Checkpoint documents must be committed before handoff. Uncommitted checkpoint files: ${dirtyPaths.map(p => fmt.path(p)).join(', ')}. Commit the checkpoint update and re-run the handoff.`;
|
|
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.`;
|
|
385
412
|
error(msg);
|
|
386
413
|
return { ok: false, error: msg };
|
|
387
414
|
}
|
|
@@ -390,16 +417,23 @@ function validateCheckpointsBeforeHandoff(slug, worktree, {
|
|
|
390
417
|
return { ok: true };
|
|
391
418
|
}
|
|
392
419
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
startReviewLoop:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
420
|
+
/**
|
|
421
|
+
* @param {string} slug
|
|
422
|
+
* @param {string} worktree
|
|
423
|
+
* @param {string} agent
|
|
424
|
+
* @param {{taskFile?: string | null, validateCheckpointsBeforeHandoffFn?: Function, performHandoff?: Function, startReviewLoop?: Function, repairHandoffFn?: {isRelaunchableError: Function, buildRelaunchPrompt: Function}, attemptAgentRelaunchFn?: Function, log?: Function, error?: Function}} [options]
|
|
425
|
+
*/
|
|
426
|
+
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;
|
|
403
437
|
// Pre-handoff checkpoint enforcement: validate checkpoints exist before calling performHandoff()
|
|
404
438
|
// This catches missing checkpoints immediately after the execute agent exits,
|
|
405
439
|
// before the repair flow runs, and provides an explicit instruction to create them.
|
|
@@ -417,7 +451,7 @@ async function runHandoffAndReview(slug, worktree, agent, {
|
|
|
417
451
|
// Attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
|
|
418
452
|
log(`\nAutomated handoff failed: ${handoffResult.error}`);
|
|
419
453
|
log(`Attempting post-execute repair...`);
|
|
420
|
-
const { repaired, blocker } = await repairHandoffFn(slug, worktree, handoffResult.error, { taskFile, log, error });
|
|
454
|
+
const { repaired, blocker } = await /** @type{Function} */(repairHandoffFn)(slug, worktree, /** @type{string} */(handoffResult.error), { taskFile, log, error });
|
|
421
455
|
if (repaired) {
|
|
422
456
|
log(`Repair successful. Retrying automated handoff...`);
|
|
423
457
|
handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
|
|
@@ -429,7 +463,7 @@ async function runHandoffAndReview(slug, worktree, agent, {
|
|
|
429
463
|
// Attempt agent relaunch for repairable content errors (missing goal-check table)
|
|
430
464
|
log(`Content error detected. Attempting agent relaunch to fix...`);
|
|
431
465
|
const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(
|
|
432
|
-
slug, worktree, handoffResult.error, agent, { log, error }
|
|
466
|
+
slug, worktree, /** @type{string} */(handoffResult.error), agent, { log, error }
|
|
433
467
|
);
|
|
434
468
|
if (relaunched) {
|
|
435
469
|
// Agent was relaunched successfully; re-invoke performHandoff to verify
|
|
@@ -456,7 +490,9 @@ async function runHandoffAndReview(slug, worktree, agent, {
|
|
|
456
490
|
return false;
|
|
457
491
|
}
|
|
458
492
|
|
|
459
|
-
|
|
493
|
+
/** @type{{ok: boolean, error?: string, gatekeeperPushedBack?: boolean}} */
|
|
494
|
+
const hr = handoffResult;
|
|
495
|
+
if (hr.gatekeeperPushedBack) {
|
|
460
496
|
log(`\nGatekeeper posted pushback for ${fmt.slug(slug)}; skipping autonomous review loop until artifacts are fixed.`);
|
|
461
497
|
return true;
|
|
462
498
|
}
|
|
@@ -466,18 +502,20 @@ async function runHandoffAndReview(slug, worktree, agent, {
|
|
|
466
502
|
return true;
|
|
467
503
|
}
|
|
468
504
|
|
|
505
|
+
/** @param {string} slug */
|
|
469
506
|
function buildCheckpointContext(slug) {
|
|
470
507
|
const missionDir = findMissionDir(slug);
|
|
471
|
-
if (!missionDir) return 'No checkpoint documents found. Start from CP-1.';
|
|
508
|
+
if (!missionDir) {return 'No checkpoint documents found. Start from CP-1.';}
|
|
472
509
|
|
|
473
510
|
const checkpoints = findCheckpoints(missionDir);
|
|
474
|
-
if (checkpoints.length === 0) return 'No checkpoint documents found. Start from CP-1.';
|
|
511
|
+
if (checkpoints.length === 0) {return 'No checkpoint documents found. Start from CP-1.';}
|
|
475
512
|
|
|
476
513
|
const latest = checkpoints[checkpoints.length - 1];
|
|
477
514
|
const firstLine = getFirstLine(latest);
|
|
478
515
|
return `Most recent checkpoint: ${path.basename(latest)} — ${firstLine}\nResume from there, or start the next checkpoint if that one is complete.`;
|
|
479
516
|
}
|
|
480
517
|
|
|
518
|
+
/** @param {string} slug @param {string} rootDir */
|
|
481
519
|
function resolveExecuteTaskPath(slug, rootDir) {
|
|
482
520
|
const resolution = resolveTaskFile(slug, rootDir);
|
|
483
521
|
if (resolution && resolution.ok && resolution.taskFile) {
|
|
@@ -486,7 +524,9 @@ function resolveExecuteTaskPath(slug, rootDir) {
|
|
|
486
524
|
return path.join(rootDir, 'backlog', 'tasks', `<${slug}>.md`);
|
|
487
525
|
}
|
|
488
526
|
|
|
489
|
-
|
|
527
|
+
/** @param {string} slug @param {string} checkpointContext @param {{rootDir?: string}} [options] */
|
|
528
|
+
function buildExecutePrompt(slug, checkpointContext, options = {}) {
|
|
529
|
+
const { rootDir = process.cwd() } = options;
|
|
490
530
|
const template = fs.readFileSync(EXECUTE_PROMPT_PATH, 'utf8');
|
|
491
531
|
const year = getMissionYear(slug, rootDir) || String(new Date().getFullYear());
|
|
492
532
|
const missionPath = path.join(missionDirForSlug(rootDir, slug), 'MISSION.md');
|
|
@@ -507,6 +547,7 @@ function buildExecutePrompt(slug, checkpointContext, { rootDir = process.cwd() }
|
|
|
507
547
|
// and escapes are display syntax, not part of the on-disk path, so they must be
|
|
508
548
|
// decoded before the value is handed to `git add --` — otherwise git treats the
|
|
509
549
|
// quote-wrapped string as a pathspec that matches no file and aborts staging.
|
|
550
|
+
/** @param {string} rawPath */
|
|
510
551
|
function unquoteGitStatusPath(rawPath) {
|
|
511
552
|
if (rawPath.length < 2 || rawPath[0] !== '"' || rawPath[rawPath.length - 1] !== '"') {
|
|
512
553
|
return rawPath;
|
|
@@ -542,18 +583,19 @@ function unquoteGitStatusPath(rawPath) {
|
|
|
542
583
|
}
|
|
543
584
|
if (Object.prototype.hasOwnProperty.call(simple, next)) {
|
|
544
585
|
flush();
|
|
545
|
-
chunks.push(Buffer.from([simple[next]]));
|
|
586
|
+
chunks.push(Buffer.from([/** @type{number} */(simple[/** @type{keyof typeof simple} */(next)])]));
|
|
546
587
|
i++;
|
|
547
588
|
} else {
|
|
548
589
|
// Unknown or trailing escape: keep the following character literally.
|
|
549
590
|
literal += next === undefined ? '\\' : next;
|
|
550
|
-
if (next !== undefined) i++;
|
|
591
|
+
if (next !== undefined) {i++;}
|
|
551
592
|
}
|
|
552
593
|
}
|
|
553
594
|
flush();
|
|
554
595
|
return Buffer.concat(chunks).toString('utf8');
|
|
555
596
|
}
|
|
556
597
|
|
|
598
|
+
/** @param {string} entry */
|
|
557
599
|
function parseDirtyEntry(entry) {
|
|
558
600
|
const match = entry.match(/^(.{1,2})\s+(.*)$/);
|
|
559
601
|
const status = (match ? match[1] : entry.slice(0, 2)).padEnd(2, ' ');
|
|
@@ -561,17 +603,20 @@ function parseDirtyEntry(entry) {
|
|
|
561
603
|
// For renames git reports `<old> -> <new>`; each side is independently quoted
|
|
562
604
|
// and the ` -> ` separator is always literal, so split before unquoting and
|
|
563
605
|
// keep staging the destination path.
|
|
564
|
-
const renamed = rawPath.includes(' -> ') ? rawPath.split(' -> ').pop() : rawPath;
|
|
606
|
+
const renamed = rawPath.includes(' -> ') ? (rawPath.split(' -> ').pop() || rawPath) : rawPath;
|
|
565
607
|
const filePath = unquoteGitStatusPath(renamed.trim());
|
|
566
608
|
return { status, filePath };
|
|
567
609
|
}
|
|
568
610
|
|
|
611
|
+
/** @param {string} filePath */
|
|
569
612
|
function isExecuteIgnoredPath(filePath) {
|
|
570
613
|
return isWorkflowGeneratedArtifact(filePath);
|
|
571
614
|
}
|
|
572
615
|
|
|
573
|
-
|
|
574
|
-
|
|
616
|
+
/** @param {{slug: string, worktree: string, dirtyEntries?: Array<{status: string, filePath: string}>, gitImpl?: Function}} opts */
|
|
617
|
+
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));
|
|
575
620
|
const relevantEntries = parsedEntries.filter(entry => !isExecuteIgnoredPath(entry.filePath));
|
|
576
621
|
const conflictEntries = relevantEntries.filter(entry =>
|
|
577
622
|
['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(entry.status.trim())
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
const { git, run, getCurrentBranch } = require('../core/git');
|
|
2
2
|
const { findMissionDir, findMissionArea, inferSlug } = require('../core/mission-utils');
|
|
3
|
-
const path = require('path');
|
|
4
3
|
const fmt = require('../core/fmt');
|
|
5
4
|
const { formatVerificationCommand, runVerificationGate } = require('../core/verification');
|
|
6
5
|
|
|
6
|
+
/** @param {string[]} args */
|
|
7
7
|
function checkpoint(args) {
|
|
8
|
+
/** @param {string} a */
|
|
8
9
|
const flags = args.filter(a => a.startsWith('--'));
|
|
10
|
+
/** @param {string} a */
|
|
9
11
|
const params = args.filter(a => !a.startsWith('--'));
|
|
10
12
|
|
|
11
13
|
let [explicitSlug, cpName, nextAction] = params;
|
package/lib/commands/config.js
CHANGED
|
@@ -8,17 +8,19 @@ const { loadEffectiveConfig, loadWorkflowConfig, validateWorkflowConfig } = requ
|
|
|
8
8
|
// This replaces the deleted workflow.config.json.example as the way to discover
|
|
9
9
|
// the configurable surface, with no copy-paste footgun and no second source of
|
|
10
10
|
// truth to drift from (task-1233 Scope Amendment).
|
|
11
|
-
|
|
11
|
+
/** @param {string[]} _args @param {{logFn?: Function, errorFn?: Function, exitFn?: Function, rootDir?: string}} opts */
|
|
12
|
+
async function config(_args = [], opts = {}) {
|
|
13
|
+
void _args; // intentionally unused — part of public API signature
|
|
12
14
|
const logFn = opts.logFn || fmt.log.plain;
|
|
13
15
|
const errorFn = opts.errorFn || fmt.log.plainError;
|
|
14
|
-
const exitFn = opts.exitFn || (code => { process.exitCode = code; });
|
|
16
|
+
const exitFn = opts.exitFn || /** @type{(code: number) => void} */ ((code) => { process.exitCode = code; });
|
|
15
17
|
const rootDir = opts.rootDir || process.cwd();
|
|
16
18
|
|
|
17
19
|
const loaded = loadWorkflowConfig(rootDir);
|
|
18
20
|
if (!loaded.found) {
|
|
19
21
|
logFn(fmt.status('INFO', 'No workflow.config.json found — showing built-in defaults.'));
|
|
20
22
|
} else if (loaded.parseError) {
|
|
21
|
-
errorFn(fmt.status('FAIL', `workflow.config.json is invalid JSON (${loaded.parseError.message}); showing fallback built-in defaults.`));
|
|
23
|
+
errorFn(fmt.status('FAIL', `workflow.config.json is invalid JSON (${/** @type{Error} */(loaded.parseError).message}); showing fallback built-in defaults.`));
|
|
22
24
|
logFn(JSON.stringify(loadEffectiveConfig(rootDir), null, 2));
|
|
23
25
|
exitFn(1);
|
|
24
26
|
return;
|
|
@@ -89,6 +89,7 @@ const COVERAGE_EXCLUDES = [
|
|
|
89
89
|
// under cold caches. Keep the gate generous so it can finish without a manual
|
|
90
90
|
// override while still failing on real hangs.
|
|
91
91
|
const DEFAULT_TEST_TIMEOUT_MS = 3_600_000;
|
|
92
|
+
/** @type {string[]} */
|
|
92
93
|
const PER_RUN_SCRATCH = [];
|
|
93
94
|
let threshold = 90;
|
|
94
95
|
let dryRun = false;
|
|
@@ -108,7 +109,7 @@ for (let i = 2; i < process.argv.length; i++) {
|
|
|
108
109
|
|
|
109
110
|
function discoverTestFiles() {
|
|
110
111
|
const testDir = path.join(REPO_ROOT, 'test');
|
|
111
|
-
if (!fs.existsSync(testDir)) return [];
|
|
112
|
+
if (!fs.existsSync(testDir)) {return [];}
|
|
112
113
|
return fs.readdirSync(testDir)
|
|
113
114
|
.filter(file => file.endsWith('.test.js'))
|
|
114
115
|
.filter(file => file !== SELF_TEST_FILE)
|
|
@@ -117,18 +118,20 @@ function discoverTestFiles() {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
function listTempEntries(tmpRoot = os.tmpdir()) {
|
|
120
|
-
if (!fs.existsSync(tmpRoot)) return new Set();
|
|
121
|
+
if (!fs.existsSync(tmpRoot)) {return new Set();}
|
|
121
122
|
return new Set(fs.readdirSync(tmpRoot));
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/** @param {string} name */
|
|
124
126
|
function shouldCleanTempDir(name) {
|
|
125
127
|
return TEMP_DIR_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/** @param {Set<string>} beforeEntries @param {string} tmpRoot */
|
|
128
131
|
function cleanupNewTempDirs(beforeEntries, tmpRoot = os.tmpdir()) {
|
|
129
|
-
if (!fs.existsSync(tmpRoot)) return;
|
|
132
|
+
if (!fs.existsSync(tmpRoot)) {return;}
|
|
130
133
|
for (const name of fs.readdirSync(tmpRoot)) {
|
|
131
|
-
if (beforeEntries.has(name) || !shouldCleanTempDir(name)) continue;
|
|
134
|
+
if (beforeEntries.has(name) || !shouldCleanTempDir(name)) {continue;}
|
|
132
135
|
const fullPath = path.join(tmpRoot, name);
|
|
133
136
|
let stat = null;
|
|
134
137
|
try {
|
|
@@ -136,7 +139,7 @@ function cleanupNewTempDirs(beforeEntries, tmpRoot = os.tmpdir()) {
|
|
|
136
139
|
} catch (_) {
|
|
137
140
|
continue;
|
|
138
141
|
}
|
|
139
|
-
if (!stat.isDirectory()) continue;
|
|
142
|
+
if (!stat.isDirectory()) {continue;}
|
|
140
143
|
try {
|
|
141
144
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
142
145
|
} catch (_) {}
|
|
@@ -179,7 +182,7 @@ exit 1
|
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
function cleanupPerRunScratch() {
|
|
182
|
-
if (cleanupDone) return;
|
|
185
|
+
if (cleanupDone) {return;}
|
|
183
186
|
cleanupDone = true;
|
|
184
187
|
for (const dir of PER_RUN_SCRATCH) {
|
|
185
188
|
try {
|
|
@@ -208,6 +211,7 @@ function resetPerRunScratchState() {
|
|
|
208
211
|
cleanupDone = false;
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
/** @param {string[]} testFiles @param {number} coverageThreshold @param {boolean} useLcov */
|
|
211
215
|
function buildCoverageArgs(testFiles, coverageThreshold = threshold, useLcov = lcov) {
|
|
212
216
|
const args = [
|
|
213
217
|
'--test',
|
|
@@ -234,11 +238,12 @@ function buildCoverageArgs(testFiles, coverageThreshold = threshold, useLcov = l
|
|
|
234
238
|
|
|
235
239
|
function resolveTestTimeoutMs(env = process.env) {
|
|
236
240
|
const raw = env.WORKFLOW_COVERAGE_GATE_TIMEOUT_MS;
|
|
237
|
-
if (raw === undefined || raw === '') return DEFAULT_TEST_TIMEOUT_MS;
|
|
241
|
+
if (raw === undefined || raw === '') {return DEFAULT_TEST_TIMEOUT_MS;}
|
|
238
242
|
const parsed = Number(raw);
|
|
239
243
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TEST_TIMEOUT_MS;
|
|
240
244
|
}
|
|
241
245
|
|
|
246
|
+
/** @param {string[]} testFiles @param {number} coverageThreshold @param {Function} _spawnSync */
|
|
242
247
|
function runTests(testFiles, coverageThreshold = threshold, _spawnSync = spawnSync) {
|
|
243
248
|
const tmpRoot = createPerRunTmpRoot();
|
|
244
249
|
const tmpEntriesBefore = listTempEntries(tmpRoot);
|
|
@@ -300,7 +305,9 @@ if (require.main === module) {
|
|
|
300
305
|
main();
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
/** @param {string[]} args @param {{exitFn?: Function}} options */
|
|
303
309
|
function run(args, options = {}) {
|
|
310
|
+
/** @type{Function} */
|
|
304
311
|
const exitFn = options.exitFn || process.exit;
|
|
305
312
|
let threshold_ = 90;
|
|
306
313
|
let dryRun_ = false;
|
|
@@ -320,7 +327,7 @@ function run(args, options = {}) {
|
|
|
320
327
|
if (dryRun_) {
|
|
321
328
|
const testFiles = discoverTestFiles();
|
|
322
329
|
if (testFiles.length === 0) {
|
|
323
|
-
if (typeof exitFn === 'function') exitFn(1);
|
|
330
|
+
if (typeof exitFn === 'function') {exitFn(1);}
|
|
324
331
|
} else {
|
|
325
332
|
fmt.log.info(`Found ${testFiles.length} test file(s)`);
|
|
326
333
|
fmt.log.info(`DRY-RUN mode — threshold=${threshold}%`);
|
|
@@ -328,8 +335,8 @@ function run(args, options = {}) {
|
|
|
328
335
|
fmt.log.info(`Include globs: ${COVERAGE_INCLUDES.join(', ')}`);
|
|
329
336
|
fmt.log.info(`Exclude globs: ${COVERAGE_EXCLUDES.join(', ')}`);
|
|
330
337
|
fmt.log.info(`Would run: ${fmt.command(`${process.execPath} ${buildCoverageArgs(testFiles, threshold).join(' ')}`)}`);
|
|
331
|
-
if (typeof exitFn === 'function') exitFn(0);
|
|
332
|
-
else process.exit(0);
|
|
338
|
+
if (typeof exitFn === 'function') {exitFn(0);}
|
|
339
|
+
else {process.exit(0);}
|
|
333
340
|
}
|
|
334
341
|
} else {
|
|
335
342
|
registerExitHandlers();
|