@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.
Files changed (68) hide show
  1. package/config/integration-pipelines.json +9 -0
  2. package/config/workflow.config.schema.json +12 -0
  3. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
  4. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
  5. package/docs/adr/0041-integration-pipeline-gates.md +1 -1
  6. package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
  7. package/docs/adr/index.md +1 -0
  8. package/docs/use-cases.md +44 -0
  9. package/lib/agents/agents.js +149 -100
  10. package/lib/agents/claude-telemetry.js +14 -10
  11. package/lib/agents/claude.js +32 -11
  12. package/lib/agents/codex-telemetry.js +32 -19
  13. package/lib/agents/codex.js +36 -11
  14. package/lib/agents/limit-hit.js +42 -25
  15. package/lib/agents/mistral-telemetry.js +1 -1
  16. package/lib/agents/mistral.js +13 -3
  17. package/lib/agents/opencode-export.js +11 -5
  18. package/lib/agents/opencode-telemetry.js +75 -90
  19. package/lib/agents/opencode.js +86 -29
  20. package/lib/agents/stage-telemetry.js +3 -7
  21. package/lib/commands/active.js +139 -94
  22. package/lib/commands/checkpoint.js +3 -1
  23. package/lib/commands/config.js +5 -3
  24. package/lib/commands/coverage-gate.js +17 -10
  25. package/lib/commands/diff.js +11 -5
  26. package/lib/commands/draft.js +95 -55
  27. package/lib/commands/handoff.js +253 -65
  28. package/lib/commands/integrate.js +288 -188
  29. package/lib/commands/mission-start.js +39 -34
  30. package/lib/commands/rebase.js +32 -21
  31. package/lib/commands/repair-handoff.js +21 -18
  32. package/lib/commands/resolve-conflict.js +5 -2
  33. package/lib/commands/review.js +1 -0
  34. package/lib/commands/setup-review.js +1 -0
  35. package/lib/commands/stats-backfill.js +50 -41
  36. package/lib/commands/stats.js +653 -204
  37. package/lib/commands/status.js +33 -28
  38. package/lib/core/fmt.js +58 -15
  39. package/lib/core/git.js +7 -5
  40. package/lib/core/gitignore.js +6 -4
  41. package/lib/core/mission-utils.js +161 -88
  42. package/lib/core/nels.js +199 -0
  43. package/lib/core/persistent-data-migration.js +55 -23
  44. package/lib/core/product-config.js +44 -23
  45. package/lib/core/runtime-matrix.js +15 -2
  46. package/lib/core/spawn-tee.js +40 -13
  47. package/lib/core/state-map.js +32 -13
  48. package/lib/core/storage.js +19 -5
  49. package/lib/core/subagent-limit.js +28 -0
  50. package/lib/core/verification.js +29 -12
  51. package/lib/review/rebase.js +11 -3
  52. package/lib/review/review-adapter.js +93 -6
  53. package/lib/review/review-artifacts.js +89 -23
  54. package/lib/review/review-commands.js +139 -45
  55. package/lib/review/review-events.js +103 -52
  56. package/lib/review/review-loop.js +117 -87
  57. package/lib/review/review-polling.js +25 -5
  58. package/lib/review/review-prompts.js +36 -5
  59. package/lib/review/review-state.js +39 -15
  60. package/lib/tools/backlog.js +162 -71
  61. package/lib/tools/forgejo.js +290 -129
  62. package/lib/tools/gatekeeper.js +15 -0
  63. package/lib/tools/redgreen.js +220 -0
  64. package/lib/tools/sessions.js +12 -5
  65. package/lib/tools/setup-review.js +170 -70
  66. package/package.json +11 -3
  67. package/prompts/draft.md +6 -0
  68. package/templates/mission-scaffold.md +1 -1
@@ -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 { loadAdapterConfig, resolveAgentModel } = require('../core/product-config');
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
- async function active(args, {
19
- inferSlugFn = inferSlug,
20
- missionStartFn = missionStart,
21
- resolveWorktreeFn = resolveWorktree,
22
- readAgentConfigOrExitFn = agents.readAgentConfigOrExit,
23
- resolveTaskFileFn = resolveTaskFile,
24
- buildCheckpointContextFn = buildCheckpointContext,
25
- buildExecutePromptFn = buildExecutePrompt,
26
- selectLaunchAndRecordFn = selectLaunchAndRecord,
27
- enforceExecuteCommitSafetyFn = enforceExecuteCommitSafety,
28
- runHandoffAndReviewFn = runHandoffAndReview,
29
- exitFn = process.exit,
30
- logFn = fmt.log.info,
31
- errorFn = fmt.log.fail
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
- async function selectLaunchAndRecord({
160
- slug,
161
- worktree,
162
- preselectedAgent = null,
163
- agentConfig,
164
- taskResolution,
165
- prompt,
166
- startAgentFn = (step, opts) => agents.startAgent(step, opts),
167
- transitionTaskFn = transitionTask,
168
- getTaskStatusFn = getTaskStatus,
169
- getTaskImplementerFn = getTaskImplementer,
170
- selectAgentFn = agents.selectAgent,
171
- log = fmt.log.plain
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 taskFile = taskResolution && taskResolution.ok && taskResolution.taskFile
175
- ? taskResolution.taskFile
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 (!(taskResolution && taskResolution.ok)) {
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
- function applyExecuteFallback({
257
- slug,
258
- preselected,
259
- actual,
260
- taskResolution,
261
- worktree,
262
- log = fmt.log.plain,
263
- transitionTaskFn = transitionTask
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
- if (taskResolution && taskResolution.ok) {
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
- async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, {
287
- isRelaunchableErrorFn = repairHandoff.isRelaunchableError,
288
- buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt,
289
- workflowLauncherStatusFn = agents.workflowLauncherStatus,
290
- startAgentFn = agents.startAgent,
291
- log = fmt.log.plain,
292
- error = fmt.log.plainError
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
- function validateCheckpointsBeforeHandoff(slug, worktree, {
345
- findMissionDirFn = findMissionDir,
346
- findCheckpointsFn = findCheckpoints,
347
- runFn = git,
348
- log = fmt.log.plain,
349
- error = fmt.log.plainError
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
- async function runHandoffAndReview(slug, worktree, agent, {
394
- taskFile = null,
395
- validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff,
396
- performHandoff: _performHandoff = (s, o) => handoff.performHandoff(s, o),
397
- startReviewLoop: _startReviewLoop = (s, o) => review.startReviewLoop(s, o),
398
- repairHandoffFn = repairHandoff,
399
- attemptAgentRelaunchFn = attemptAgentRelaunch,
400
- log = fmt.log.plain,
401
- error = fmt.log.plainError
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
- if (handoffResult.gatekeeperPushedBack) {
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
- function buildExecutePrompt(slug, checkpointContext, { rootDir = process.cwd() } = {}) {
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
- function enforceExecuteCommitSafety({ slug, worktree, dirtyEntries = getWorktreeStatus(worktree), gitImpl = git }) {
574
- const parsedEntries = dirtyEntries.map(parseDirtyEntry);
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;
@@ -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
- async function config(args = [], opts = {}) {
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();