@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
@@ -24,8 +24,10 @@ const RESUME_CAPABLE = new Set(['claude', 'codex', 'custom']);
24
24
  const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'agents.json');
25
25
 
26
26
  // Test hook: when set, used instead of spawning `command -v` to check PATH.
27
+ /** @type {((name: string) => string | null) | null} */
27
28
  let _commandPathProbe = null;
28
29
 
30
+ /** @type {{[key: string]: Function}} */
29
31
  const LAUNCHERS = {
30
32
  codex: startCodexDraftAgent,
31
33
  claude: startClaudeAgent,
@@ -33,6 +35,7 @@ const LAUNCHERS = {
33
35
  custom: startOpencodeAgent
34
36
  };
35
37
 
38
+ /** @type {{[key: string]: () => string}} */
36
39
  const RESOLVERS = {
37
40
  codex: resolveCodexCommand,
38
41
  claude: resolveClaudeCommand,
@@ -40,6 +43,7 @@ const RESOLVERS = {
40
43
  custom: resolveOpencodeCommand
41
44
  };
42
45
 
46
+ /** @type {{[key: string]: string[]}} */
43
47
  const HEALTH_PROBE_ARGS = Object.freeze({
44
48
  codex: ['--help'],
45
49
  claude: ['--help'],
@@ -58,6 +62,7 @@ const KNOWN_AGENT_NAMES = Object.freeze([
58
62
  'human'
59
63
  ]);
60
64
 
65
+ /** @param {string} agent */
61
66
  function workflowLauncherStatus(agent) {
62
67
  const resolver = RESOLVERS[agent];
63
68
  if (!resolver) {
@@ -77,8 +82,10 @@ function workflowLauncherStatus(agent) {
77
82
  });
78
83
 
79
84
  if (probe.error || probe.status !== 0) {
85
+ /** @type {Error & {code?: string}} */
86
+ const pErr = probe.error || new Error('');
80
87
  const reason = probe.error
81
- ? probe.error.code || probe.error.message
88
+ ? (pErr.code || pErr.message)
82
89
  : `exit ${probe.status}`;
83
90
  return {
84
91
  agent,
@@ -92,6 +99,7 @@ function workflowLauncherStatus(agent) {
92
99
  return { agent, supported: true, detail: `${command} ${probeArgs.join(' ')}`.trim(), health: 'ok' };
93
100
  }
94
101
 
102
+ /** @param {string} name */
95
103
  function commandInPath(name) {
96
104
  if (_commandPathProbe) {
97
105
  return _commandPathProbe(name) || false;
@@ -103,9 +111,11 @@ function commandInPath(name) {
103
111
  return result.status === 0 && result.stdout.trim().length > 0;
104
112
  }
105
113
 
114
+ /** @param {string} configPath @param {string} scope @param {{message?: string} | null} originalError */
106
115
  function buildInvalidAgentConfigError(configPath, scope, originalError) {
107
116
  const location = path.resolve(configPath);
108
117
  const detail = originalError && originalError.message ? originalError.message : 'invalid JSON';
118
+ /** @type {any} */
109
119
  const error = new Error(
110
120
  `Invalid ${scope} agent config at ${location}: ${detail}. ` +
111
121
  'Fix or remove the malformed file before running workflow commands so agent blocking is applied deterministically.'
@@ -116,6 +126,7 @@ function buildInvalidAgentConfigError(configPath, scope, originalError) {
116
126
  return error;
117
127
  }
118
128
 
129
+ /** @param {{code?: string}} error */
119
130
  function isInvalidAgentConfigError(error) {
120
131
  return Boolean(error && error.code === 'WORKFLOW_AGENT_CONFIG_INVALID');
121
132
  }
@@ -123,30 +134,33 @@ function isInvalidAgentConfigError(error) {
123
134
  function readAgentConfigOrExit(configPath = CONFIG_PATH, options = {}) {
124
135
  try {
125
136
  return readAgentConfig(configPath, options);
126
- } catch (error) {
127
- if (isInvalidAgentConfigError(error)) {
128
- fmt.log.fail(error.message);
137
+ } catch (/** @type {unknown} */ error) {
138
+ if (isInvalidAgentConfigError(/** @type {any} */ (error))) {
139
+ fmt.log.fail(/** @type {any} */ (error).message);
129
140
  process.exit(1);
130
141
  }
131
142
  throw error;
132
143
  }
133
144
  }
134
145
 
146
+ /** @param {string} configPath @param {string} scope */
135
147
  function parseAgentConfigFile(configPath, scope) {
136
148
  try {
137
149
  return JSON.parse(fs.readFileSync(configPath, 'utf8'));
138
- } catch (err) {
139
- throw buildInvalidAgentConfigError(configPath, scope, err);
150
+ } catch (/** @type {unknown} */ err) {
151
+ throw buildInvalidAgentConfigError(configPath, scope, /** @type {{message?: string}} */ (err));
140
152
  }
141
153
  }
142
154
 
155
+ /** @param {{mergeLocal?: boolean, mainWorktreePath?: string | null, warn?: Function, targetPath?: string}} options */
143
156
  function readAgentConfig(configPath = CONFIG_PATH, options = {}) {
144
157
  const {
145
158
  mergeLocal = path.resolve(configPath) === path.resolve(CONFIG_PATH),
146
159
  mainWorktreePath,
147
160
  warn = fmt.log.warn
148
161
  } = options;
149
- let config = null;
162
+ /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}}} */
163
+ let config = {};
150
164
  if (fs.existsSync(configPath)) {
151
165
  config = parseAgentConfigFile(configPath, 'workflow');
152
166
  }
@@ -157,12 +171,12 @@ function readAgentConfig(configPath = CONFIG_PATH, options = {}) {
157
171
  const mainWorktree = mainWorktreePath !== undefined
158
172
  ? mainWorktreePath
159
173
  : getMainWorktreePath({ cwd: projectRoot, warn });
160
- const legacyPaths = [
174
+ const /** @type {string[]} */ legacyPaths = [
161
175
  path.join(path.dirname(configPath), 'agents.local.json'),
162
176
  path.join(projectRoot, 'agents.local.json'),
163
- mainWorktree ? path.join(mainWorktree, 'agents.local.json') : null
164
- ].filter(Boolean);
165
- const targetPath = options.targetPath || storage.resolveAgentsLocalPath({ ensureDir: true });
177
+ mainWorktree ? path.join(mainWorktree, 'agents.local.json') : ''
178
+ ].filter(/** @param {string} p */ (p) => Boolean(p));
179
+ const /** @type {string} */ targetPath = options.targetPath || storage.resolveAgentsLocalPath({ ensureDir: true });
166
180
  if (!fs.existsSync(targetPath)) {
167
181
  try {
168
182
  migrateAgentBlocklists({
@@ -170,14 +184,14 @@ function readAgentConfig(configPath = CONFIG_PATH, options = {}) {
170
184
  destinationPath: targetPath,
171
185
  warn
172
186
  });
173
- } catch (error) {
174
- throw buildInvalidAgentConfigError(targetPath, 'local', error);
187
+ } catch (/** @type {unknown} */ error) {
188
+ throw buildInvalidAgentConfigError(targetPath, 'local', /** @type {any} */ (error));
175
189
  }
176
190
  }
177
191
  if (fs.existsSync(targetPath)) {
178
192
  const localConfig = parseAgentConfigFile(targetPath, 'local');
179
193
  if (localConfig && localConfig.blocklist) {
180
- config.blocklist = Object.assign(config.blocklist || {}, localConfig.blocklist);
194
+ /** @type {{blocklist?: {[key: string]: any}}} */ (config).blocklist = Object.assign(/** @type {{blocklist?: {[key: string]: any}}} */ (config).blocklist || {}, localConfig.blocklist);
181
195
  }
182
196
  }
183
197
  }
@@ -185,6 +199,7 @@ function readAgentConfig(configPath = CONFIG_PATH, options = {}) {
185
199
  return config;
186
200
  }
187
201
 
202
+ /** @param {{cwd?: string, warn?: Function}} options */
188
203
  function getMainWorktreePath(options = {}) {
189
204
  const { cwd = process.cwd(), warn = fmt.log.warn } = options;
190
205
  try {
@@ -209,7 +224,7 @@ function getMainWorktreePath(options = {}) {
209
224
  const lines = result.stdout.split('\n');
210
225
  const mainWorktreePath = detectMainWorktreePath(lines, cwd, commonDir);
211
226
  if (mainWorktreePath) {
212
- if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, mainWorktreePath);
227
+ if (commonDir) {MainWorktreeDetector.byCommonDir.set(commonDir, mainWorktreePath);}
213
228
  return mainWorktreePath;
214
229
  }
215
230
 
@@ -220,7 +235,7 @@ function getMainWorktreePath(options = {}) {
220
235
  const wt = lines[i].slice('worktree '.length).trim();
221
236
  const branchLineIdx = i + 1;
222
237
  if (branchLineIdx < lines.length && lines[branchLineIdx].startsWith('branch refs/heads/main')) {
223
- if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, wt);
238
+ if (commonDir) {MainWorktreeDetector.byCommonDir.set(commonDir, wt);}
224
239
  return wt;
225
240
  }
226
241
  }
@@ -232,13 +247,15 @@ function getMainWorktreePath(options = {}) {
232
247
  if (lines[i].startsWith('worktree ')) {
233
248
  const wt = lines[i].slice('worktree '.length).trim();
234
249
  if (wt !== cwd) {
235
- if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, wt);
250
+ if (commonDir) {MainWorktreeDetector.byCommonDir.set(commonDir, wt);}
236
251
  return wt;
237
252
  }
238
253
  }
239
254
  }
240
- } catch (err) {
241
- const detail = err && (err.code || err.message) ? (err.code || err.message) : 'unknown error';
255
+ } catch (/** @type {unknown} */ err) {
256
+ /** @type {Error & {code?: string}} */
257
+ const e = /** @type {any} */ (err);
258
+ const detail = e && (e.code || e.message) ? (e.code || e.message) : 'unknown error';
242
259
  warn(
243
260
  `Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
244
261
  `skipping that lookup (${detail}).`
@@ -261,6 +278,7 @@ const MainWorktreeDetector = {
261
278
  byCommonDir: new Map()
262
279
  };
263
280
 
281
+ /** @param {string} cwd @param {string[]} args */
264
282
  function getGitPath(cwd, args) {
265
283
  const result = spawnSync('git', ['-C', cwd, ...args], {
266
284
  encoding: 'utf8',
@@ -273,13 +291,15 @@ function getGitPath(cwd, args) {
273
291
  return result.stdout.trim() || null;
274
292
  }
275
293
 
294
+ /** @param {string[]} lines */
276
295
  function parseWorktreePaths(lines) {
277
296
  return lines
278
- .filter(line => line.startsWith('worktree '))
279
- .map(line => line.slice('worktree '.length).trim())
297
+ .filter(/** @param {string} line */ (line) => line.startsWith('worktree '))
298
+ .map(/** @param {string} line */ (line) => line.slice('worktree '.length).trim())
280
299
  .filter(Boolean);
281
300
  }
282
301
 
302
+ /** @param {string[]} lines @param {string} cwd @param {string | null} commonDir */
283
303
  function detectMainWorktreePath(lines, cwd, commonDir) {
284
304
  const worktrees = parseWorktreePaths(lines);
285
305
  if (worktrees.length === 0) {
@@ -307,6 +327,7 @@ function detectMainWorktreePath(lines, cwd, commonDir) {
307
327
  return null;
308
328
  }
309
329
 
330
+ /** @param {string | number} value */
310
331
  function parseBlockUntil(value) {
311
332
  if (typeof value !== 'string') {
312
333
  return NaN;
@@ -336,16 +357,20 @@ function parseBlockUntil(value) {
336
357
  return parsed.getTime();
337
358
  }
338
359
 
360
+ /**
361
+ * @param {string} agent
362
+ * @param {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} config
363
+ */
339
364
  function isAgentBlocked(agent, config) {
340
365
  if (!config || !config.blocklist || config.blocklist[agent] === undefined) {
341
366
  return false;
342
367
  }
343
368
  const entry = config.blocklist[agent];
344
- if (entry === true) return true;
345
- if (entry === false) return false;
369
+ if (entry === true) {return true;}
370
+ if (entry === false) {return false;}
346
371
  if (entry && typeof entry === 'object') {
347
- if (entry.blocked === true) return true;
348
- if (entry.blocked === false) return false;
372
+ if (entry.blocked === true) {return true;}
373
+ if (entry.blocked === false) {return false;}
349
374
  if (entry.until) {
350
375
  const until = parseBlockUntil(entry.until);
351
376
  if (!isNaN(until) && until > Date.now()) {
@@ -356,29 +381,38 @@ function isAgentBlocked(agent, config) {
356
381
  return false;
357
382
  }
358
383
 
384
+ /**
385
+ * @param {string} step
386
+ * @param {{config?: {blocklist?: {[key: string]: any}, steps?: {[key: string]: any}}, configPath?: string}} options
387
+ */
359
388
  function eligibleAgentsForStep(step, options = {}) {
360
- const config = options.config !== undefined
389
+ const /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} */ config = options.config !== undefined
361
390
  ? options.config
362
- : readAgentConfig(options.configPath || CONFIG_PATH, options);
391
+ : readAgentConfig(options.configPath || CONFIG_PATH, /** @type {{mergeLocal?: boolean, mainWorktreePath?: string | null, warn?: Function, targetPath?: string}} */ (options));
363
392
  let eligible;
364
393
  if (!config || !config.steps || !config.steps[step]) {
365
394
  eligible = Object.keys(LAUNCHERS);
366
395
  } else {
367
396
  eligible = config.steps[step].eligible || Object.keys(LAUNCHERS);
368
397
  }
369
- return eligible.filter(agent => !isAgentBlocked(agent, config));
398
+ return eligible.filter(/** @param {string} agent */ (agent) => !isAgentBlocked(agent, config));
370
399
  }
371
400
 
401
+ /** @param {string[]} agents @param {{[key: string]: number}} weights */
372
402
  function weightedRandom(agents, weights) {
373
- const total = agents.reduce((sum, a) => sum + (weights[a] || 1), 0);
403
+ const total = agents.reduce(/** @param {number} sum @param {string} a */ (sum, a) => sum + (weights[a] || 1), 0);
374
404
  let r = Math.random() * total;
375
405
  for (const agent of agents) {
376
406
  r -= weights[agent] || 1;
377
- if (r <= 0) return agent;
407
+ if (r <= 0) {return agent;}
378
408
  }
379
409
  return agents[agents.length - 1];
380
410
  }
381
411
 
412
+ /**
413
+ * @param {string} step
414
+ * @param {{exclude?: Set<string>, config?: {blocklist?: {[key: string]: any}, steps?: {[key: string]: any}}, configPath?: string}} options
415
+ */
382
416
  function selectAgent(step, options = {}) {
383
417
  const envOverride = process.env.WORKFLOW_AGENT;
384
418
  const excluded = options.exclude instanceof Set ? options.exclude : new Set();
@@ -393,7 +427,7 @@ function selectAgent(step, options = {}) {
393
427
  return envOverride;
394
428
  }
395
429
 
396
- const pool = eligible.filter(agent => !excluded.has(agent));
430
+ const pool = eligible.filter(/** @param {string} agent */ (agent) => !excluded.has(agent));
397
431
  if (eligible.length === 0) {
398
432
  throw new Error(`No agents are eligible for workflow step: ${step}`);
399
433
  }
@@ -407,15 +441,15 @@ function selectAgent(step, options = {}) {
407
441
  // Filter to agents that are both eligible (per config) and supported (launcher present).
408
442
  const statuses = new Map(
409
443
  pool
410
- .filter(agent => LAUNCHERS[agent])
411
- .map(agent => [agent, workflowLauncherStatus(agent)])
444
+ .filter(/** @param {string} agent */ (agent) => LAUNCHERS[agent])
445
+ .map(/** @param {string} agent */ (agent) => [agent, workflowLauncherStatus(agent)])
412
446
  );
413
- const available = pool.filter(agent => {
447
+ const available = pool.filter(/** @param {string} agent */ (agent) => {
414
448
  const status = statuses.get(agent);
415
449
  return Boolean(status && status.supported);
416
450
  });
417
451
  if (available.length === 0) {
418
- const blockers = pool.map(agent => {
452
+ const blockers = pool.map(/** @param {string} agent */ (agent) => {
419
453
  const status = statuses.get(agent) || { detail: agent, reason: 'unsupported-agent' };
420
454
  const suffix = status.reason ? `; ${status.reason}` : '';
421
455
  return `${agent} (looked for: ${status.detail}${suffix})`;
@@ -427,9 +461,9 @@ function selectAgent(step, options = {}) {
427
461
  );
428
462
  }
429
463
 
430
- const config = options.config !== undefined
464
+ const /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} */ config = options.config !== undefined
431
465
  ? options.config
432
- : readAgentConfig(options.configPath || CONFIG_PATH, options);
466
+ : readAgentConfig(options.configPath || CONFIG_PATH, /** @type {{mergeLocal?: boolean, mainWorktreePath?: string | null, warn?: Function, targetPath?: string}} */ (options));
433
467
  const stepConfig = config && config.steps && config.steps[step] ? config.steps[step] : {};
434
468
  const selection = stepConfig.selection || 'random';
435
469
 
@@ -445,8 +479,10 @@ function selectAgent(step, options = {}) {
445
479
  return available[0];
446
480
  }
447
481
 
482
+ /** @param {string} agent */
448
483
  function assertAgentSupported(agent) {
449
484
  if (!LAUNCHERS[agent]) {
485
+ /** @type {any} */
450
486
  const error = new Error(
451
487
  `Unknown agent: "${fmt.agent(agent)}". Supported agents: ${Object.keys(LAUNCHERS).join(', ')}.`
452
488
  );
@@ -458,6 +494,7 @@ function assertAgentSupported(agent) {
458
494
  if (!status.supported) {
459
495
  const health = status.health ? ` (${status.health})` : '';
460
496
  const reason = status.reason ? `; reason: ${status.reason}` : '';
497
+ /** @type {any} */
461
498
  const error = new Error(
462
499
  `Agent "${fmt.agent(agent)}" launcher is not available on this workstation${health}. ` +
463
500
  `Looked for: ${fmt.path(status.detail)}${reason}. ` +
@@ -468,11 +505,13 @@ function assertAgentSupported(agent) {
468
505
  }
469
506
  }
470
507
 
508
+ /** @param {{targetPath?: string}} options */
471
509
  function resolveBlocklistTargetPath(options = {}) {
472
- if (options.targetPath) return options.targetPath;
510
+ if (options.targetPath) {return options.targetPath;}
473
511
  return storage.resolveAgentsLocalPath({ ensureDir: true });
474
512
  }
475
513
 
514
+ /** @param {string} agent @param {string} until @param {{targetPath?: string}} options */
476
515
  function updateAgentBlock(agent, until, options = {}) {
477
516
  if (!agent || typeof agent !== 'string') {
478
517
  throw new Error('updateAgentBlock requires an agent name');
@@ -483,6 +522,7 @@ function updateAgentBlock(agent, until, options = {}) {
483
522
 
484
523
  const targetPath = resolveBlocklistTargetPath(options);
485
524
 
525
+ /** @type {{blocklist?: {[key: string]: any}}} */
486
526
  let payload = {};
487
527
  if (fs.existsSync(targetPath)) {
488
528
  // Match the read-path contract (parseAgentConfigFile): malformed local agent
@@ -490,8 +530,8 @@ function updateAgentBlock(agent, until, options = {}) {
490
530
  // corrupted agents.local.json would destroy whatever was on disk.
491
531
  try {
492
532
  payload = JSON.parse(fs.readFileSync(targetPath, 'utf8')) || {};
493
- } catch (err) {
494
- throw buildInvalidAgentConfigError(targetPath, 'local', err);
533
+ } catch (/** @type {unknown} */ err) {
534
+ throw buildInvalidAgentConfigError(targetPath, 'local', /** @type {{message?: string}} */ (err));
495
535
  }
496
536
  if (typeof payload !== 'object' || Array.isArray(payload)) {
497
537
  throw buildInvalidAgentConfigError(
@@ -510,11 +550,12 @@ function updateAgentBlock(agent, until, options = {}) {
510
550
  return { path: targetPath, blocklist: payload.blocklist };
511
551
  }
512
552
 
553
+ /** @param {string} agent */
513
554
  function defaultIsAgentBlockedNow(agent) {
514
555
  try {
515
556
  const config = readAgentConfig(CONFIG_PATH, {});
516
557
  return isAgentBlocked(agent, config);
517
- } catch (err) {
558
+ } catch (/** @type {unknown} */ err) {
518
559
  // If the config is malformed, surface that through the launcher path
519
560
  // (assertAgentSupported / launch) instead of silently rerouting. Treat as
520
561
  // not-blocked here so the existing error path runs.
@@ -522,18 +563,20 @@ function defaultIsAgentBlockedNow(agent) {
522
563
  }
523
564
  }
524
565
 
566
+ /** @param {string} name */
525
567
  function readPositiveMsEnv(name) {
526
568
  const raw = process.env[name];
527
- if (raw === undefined || raw === '') return null;
569
+ if (raw === undefined || raw === '') {return null;}
528
570
  const value = Number(raw);
529
571
  return Number.isFinite(value) && value >= 0 ? value : null;
530
572
  }
531
573
 
532
- function resolveNoOutputWatchdogConfig(config = {}, step = null) {
574
+ /** @param {{initialDelayMs?: number, intervalMs?: number}|boolean} config @param {string | null} step */
575
+ function resolveNoOutputWatchdogConfig(config, step = null) {
533
576
  if (config === false || process.env.WORKFLOW_AGENT_NO_OUTPUT_WATCHDOG === '0') {
534
577
  return null;
535
578
  }
536
- const explicit = config && typeof config === 'object' ? config : {};
579
+ const /** @type {{initialDelayMs?: number, intervalMs?: number}} */ explicit = config && typeof config === 'object' ? config : {};
537
580
  // Draft gets a shorter default watchdog to surface agent-launch visibility
538
581
  // quickly; the generic default (60s) is too slow for the draft entrypoint
539
582
  // where an operator cannot tell launch from hang.
@@ -557,15 +600,20 @@ function resolveNoOutputWatchdogConfig(config = {}, step = null) {
557
600
  return { initialDelayMs, intervalMs };
558
601
  }
559
602
 
603
+ /** @param {number} elapsedMs */
560
604
  function formatElapsed(elapsedMs) {
561
605
  const seconds = Math.max(0, Math.round(elapsedMs / 1000));
562
- if (seconds < 60) return `${seconds}s`;
606
+ if (seconds < 60) {return `${seconds}s`;}
563
607
  const minutes = Math.floor(seconds / 60);
564
608
  const remainder = seconds % 60;
565
609
  return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
566
610
  }
567
611
 
568
- async function startAgent(step, opts = {}) {
612
+ /**
613
+ * @param {string} step
614
+ * @param {{prompt: string | Function, worktree?: string, agent?: string, env?: {[key: string]: string}, exclude?: (string|null|Set<string>)[], onLimitHit?: Function, onLaunch?: Function, slug?: string | null, role?: string | null, detectLimitHitFn?: Function, updateAgentBlockFn?: Function, selectAgentFn?: Function, resolveAgentModelFn?: Function, isAgentBlockedFn?: Function, sessionsModule?: object, log?: Function, noOutputWatchdog?: {initialDelayMs?: number, intervalMs?: number}}} opts
615
+ */
616
+ async function startAgent(step, opts = { prompt: '' }) {
569
617
  const {
570
618
  prompt,
571
619
  worktree,
@@ -602,17 +650,16 @@ async function startAgent(step, opts = {}) {
602
650
  if (!chosen) {
603
651
  try {
604
652
  chosen = selectAgentFn(step, { exclude: tried });
605
- } catch (err) {
653
+ } catch (/** @type {unknown} */ err) {
606
654
  // Only catch pool exhaustion errors from selectAgent.
607
655
  // Configuration errors (no eligible agents, no working launcher) must
608
656
  // propagate unchanged to preserve diagnostics (SC 3).
609
657
  // Exhaustion is indicated by:
610
658
  // - "exhausted" from real selectAgent pool exhaustion ("are exhausted")
611
659
  // - "No agents available" from test mocks simulating exhaustion
612
- if (!err.message || !(
613
- err.message.includes('exhausted') ||
614
- err.message.includes('No agents available')
615
- )) {
660
+ if (!(/** @type {any} */ (err).message || '').includes('exhausted') &&
661
+ !(/** @type {any} */ (err).message || '').includes('No agents available')
662
+ ) {
616
663
  throw err;
617
664
  }
618
665
  // Pool exhausted; build clear exhaustion diagnostics with per-agent errors (SC 3)
@@ -639,33 +686,35 @@ async function startAgent(step, opts = {}) {
639
686
  // the same limit. Reroute through normal selection on the next iteration.
640
687
  log(fmt.status('WARN', `Pinned agent "${fmt.agent(chosen)}" is currently blocked in agents.local.json; rerouting via selectAgent for step "${step}".`));
641
688
  tried.add(chosen);
642
- chosen = null;
689
+ chosen = undefined;
643
690
  continue;
644
691
  }
645
692
 
646
693
  try {
647
- assertAgentSupported(chosen);
648
- } catch (err) {
649
- if (err.code !== 'LAUNCHER_UNAVAILABLE') {
694
+ assertAgentSupported(chosen || '');
695
+ } catch (/** @type {unknown} */ err) {
696
+ /** @type {Error & {code?: string}} */
697
+ const e = /** @type {any} */ (err);
698
+ if (e.code !== 'LAUNCHER_UNAVAILABLE') {
650
699
  throw err;
651
700
  }
652
- log(fmt.status('WARN', err.message));
701
+ log(fmt.status('WARN', /** @type {any} */ (err).message));
653
702
  // Only reroute for launcher-availability failures (missing or probe-failed).
654
- tried.add(chosen);
703
+ tried.add(chosen || '');
655
704
  // If the caller pinned a specific agent, allow one retry that ignores
656
705
  // the override and falls back to normal selection (matches limit-hit logic).
657
706
  if (agentOverride && agentOverride === chosen && iteration === 1) {
658
- chosen = null;
707
+ chosen = undefined;
659
708
  continue;
660
709
  }
661
- chosen = null;
710
+ chosen = undefined;
662
711
  continue;
663
712
  }
664
- tried.add(chosen);
665
- launched.add(chosen);
713
+ tried.add(chosen || '');
714
+ launched.add(chosen || '');
666
715
 
667
- const launcher = LAUNCHERS[chosen];
668
- log(fmt.status('INFO', `Selected agent for step "${step}": ${fmt.agent(chosen)}${iteration > 1 ? ` (attempt ${iteration})` : ''}`));
716
+ const launcher = LAUNCHERS[chosen || ''];
717
+ log(fmt.status('INFO', `Selected agent for step "${step}": ${fmt.agent(chosen || '')}${iteration > 1 ? ` (attempt ${iteration})` : ''}`));
669
718
 
670
719
  // Enforce the agent family as the Forgejo identity (ADR 0029 / task-095).
671
720
  // FORGEJO_USER is set last so the harness-selected identity always wins;
@@ -678,15 +727,15 @@ async function startAgent(step, opts = {}) {
678
727
  // invalidates the prior session).
679
728
  const resume = Boolean(
680
729
  worktree && slug && role &&
681
- RESUME_CAPABLE.has(chosen) &&
682
- sessionsModule.shouldResume(worktree, slug, role, chosen)
730
+ RESUME_CAPABLE.has(chosen || '') &&
731
+ (/** @type {any} */ (sessionsModule)).shouldResume(worktree, slug, role, chosen || '')
683
732
  );
684
- const sessionId = sessionsModule.getSessionId(worktree, slug, role);
733
+ const sessionId = (/** @type {any} */ (sessionsModule)).getSessionId(worktree, slug, role);
685
734
  if (slug && role) {
686
735
  if (resume) {
687
- log(fmt.status('INFO', `Resuming ${fmt.agent(chosen)} session for ${fmt.slug(slug)} (${role}).${sessionId ? ` Session: ${sessionId}` : ''}`));
688
- } else if (RESUME_CAPABLE.has(chosen)) {
689
- log(fmt.status('INFO', `No prior ${fmt.agent(chosen)} session for ${fmt.slug(slug)} (${role}); launching fresh.`));
736
+ log(fmt.status('INFO', `Resuming ${fmt.agent(chosen || '')} session for ${fmt.slug(slug)} (${role}).${sessionId ? ` Session: ${sessionId}` : ''}`));
737
+ } else if (RESUME_CAPABLE.has(chosen || '')) {
738
+ log(fmt.status('INFO', `No prior ${fmt.agent(chosen || '')} session for ${fmt.slug(slug)} (${role}); launching fresh.`));
690
739
  }
691
740
  }
692
741
 
@@ -699,9 +748,9 @@ async function startAgent(step, opts = {}) {
699
748
  // Resolve the per-family model override (adapters.agents.models[chosen]).
700
749
  // null when the family is not configured, in which case the launcher omits
701
750
  // the model flag entirely and the agent uses its own default.
702
- const model = resolveAgentModelFn(chosen, worktree || process.cwd());
751
+ const model = resolveAgentModelFn(chosen || '', worktree || process.cwd());
703
752
  if (model) {
704
- log(fmt.status('INFO', `Using configured model for ${fmt.agent(chosen)}: ${model}`));
753
+ log(fmt.status('INFO', `Using configured model for ${fmt.agent(chosen || '')}: ${model}`));
705
754
  }
706
755
 
707
756
  const watchdogConfig = resolveNoOutputWatchdogConfig(noOutputWatchdog, step);
@@ -717,14 +766,14 @@ async function startAgent(step, opts = {}) {
717
766
  teeOptions: watchdogConfig ? {
718
767
  noOutputWatchdog: {
719
768
  ...watchdogConfig,
720
- onNoOutput: ({ pid, elapsedMs }) => {
721
- const stage = elapsedMs < (step === 'draft' ? DRAFT_NO_OUTPUT_INITIAL_DELAY_MS : DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS)
769
+ onNoOutput: (/** @type {{pid: number, elapsedMs: number}} */ evt) => {
770
+ const stage = evt.elapsedMs < (step === 'draft' ? DRAFT_NO_OUTPUT_INITIAL_DELAY_MS : DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS)
722
771
  ? 'starting up'
723
772
  : 'running';
724
773
  log(fmt.status(
725
774
  'INFO',
726
- `No output yet from ${fmt.agent(chosen)} for step "${step}" after ${formatElapsed(elapsedMs)} ` +
727
- `(pid ${pid || 'unknown'}, agent ${stage}). ` +
775
+ `No output yet from ${fmt.agent(chosen || '')} for step "${step}" after ${formatElapsed(evt.elapsedMs)} ` +
776
+ `(pid ${evt.pid || 'unknown'}, agent ${stage}). ` +
728
777
  `Launcher is still running; stdout/stderr have not produced visible output.`
729
778
  ));
730
779
  }
@@ -760,12 +809,12 @@ async function startAgent(step, opts = {}) {
760
809
  });
761
810
 
762
811
  if (limitHit) {
763
- log(fmt.status('WARN', `Limit hit detected for ${fmt.agent(chosen)}; reset estimate "${limitHit.until}" (${limitHit.source}). Blocking and retrying.`));
812
+ log(fmt.status('WARN', `Limit hit detected for ${fmt.agent(chosen || '')}; reset estimate "${limitHit.until}" (${limitHit.source}). Blocking and retrying.`));
764
813
  try {
765
- const blockResult = updateAgentBlockFn(chosen, limitHit.until);
766
- log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen)} -> ${fmt.path(blockResult.path)}`));
767
- } catch (err) {
768
- log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen)}: ${err.message}`));
814
+ const blockResult = updateAgentBlockFn(chosen || '', limitHit.until);
815
+ log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)}`));
816
+ } catch (/** @type {unknown} */ err) {
817
+ log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${/** @type {any} */ (err).message}`));
769
818
  }
770
819
  if (typeof onLimitHit === 'function') {
771
820
  onLimitHit({ agent: chosen, until: limitHit.until, source: limitHit.source });
@@ -774,22 +823,22 @@ async function startAgent(step, opts = {}) {
774
823
  // If the caller pinned a specific agent, fail loudly — there is no fallback.
775
824
  if (agentOverride && agentOverride === chosen && iteration === 1) {
776
825
  // Allow one retry that ignores the override.
777
- chosen = null;
826
+ chosen = undefined;
778
827
  continue;
779
828
  }
780
- chosen = null;
829
+ chosen = undefined;
781
830
  continue;
782
831
  }
783
832
 
784
833
  // Reroute if the launcher binary could not be started (ENOENT = not found, EACCES = not executable).
785
834
  if (result && result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {
786
- log(fmt.status('WARN', `Launcher for "${chosen}" could not be started (${result.error.code}); rerouting.`));
787
- tried.add(chosen);
835
+ log(fmt.status('WARN', `Launcher for "${chosen || ''}" could not be started (${result.error.code}); rerouting.`));
836
+ tried.add(chosen || '');
788
837
  if (agentOverride && agentOverride === chosen && iteration === 1) {
789
- chosen = null;
838
+ chosen = undefined;
790
839
  continue;
791
840
  }
792
- chosen = null;
841
+ chosen = undefined;
793
842
  continue;
794
843
  }
795
844
 
@@ -814,29 +863,29 @@ async function startAgent(step, opts = {}) {
814
863
  const stderrSnippet = result && result.stderr
815
864
  ? ` (${result.stderr.trim().split('\n')[0]})`
816
865
  : '';
817
- log(fmt.status('WARN', `Agent ${fmt.agent(chosen)} failed to complete (${exitInfo}${stderrSnippet}); retrying with next eligible agent.`));
818
- agentErrors.set(chosen, {
866
+ log(fmt.status('WARN', `Agent ${fmt.agent(chosen || '')} failed to complete (${exitInfo}${stderrSnippet}); retrying with next eligible agent.`));
867
+ agentErrors.set(chosen || '', {
819
868
  exitInfo,
820
869
  stderr: result.stderr,
821
870
  stdout: result.stdout,
822
871
  signal: result.signal,
823
872
  status: result.status,
824
873
  });
825
- tried.add(chosen);
826
- launched.add(chosen);
874
+ tried.add(chosen || '');
875
+ launched.add(chosen || '');
827
876
  // Block non-custom agents on non-limit failures so selectAgent excludes them
828
877
  // on the next retry iteration, and the review-loop fallback path can activate.
829
878
  // custom (opencode/local AI) is excluded — exit 1 is a temporary local error.
830
879
  if (chosen !== 'custom') {
831
880
  const blockUntil = formatBlockUntil(new Date(Date.now() + DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000));
832
881
  try {
833
- const blockResult = updateAgentBlockFn(chosen, blockUntil);
834
- log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen)} -> ${fmt.path(blockResult.path)} (${DEFAULT_FALLBACK_HOURS}h block)`));
835
- } catch (err) {
836
- log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen)}: ${err.message}`));
882
+ const blockResult = updateAgentBlockFn(chosen || '', blockUntil);
883
+ log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${DEFAULT_FALLBACK_HOURS}h block)`));
884
+ } catch (/** @type {unknown} */ err) {
885
+ log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${/** @type {any} */ (err).message}`));
837
886
  }
838
887
  }
839
- chosen = null;
888
+ chosen = undefined;
840
889
  continue;
841
890
  }
842
891
  // Record the marker so a subsequent same-(slug, role) launch knows which
@@ -846,9 +895,9 @@ async function startAgent(step, opts = {}) {
846
895
  if (worktree && slug && role && result && result.status === 0 && !result.error) {
847
896
  try {
848
897
  const sessionId = result && result.sessionId ? result.sessionId : null;
849
- sessionsModule.writeSession(worktree, slug, role, { agent: chosen, sessionId });
850
- } catch (err) {
851
- log(fmt.status('WARN', `Could not persist session marker for ${fmt.slug(slug)} (${role}): ${err.message}`));
898
+ (/** @type {any} */ (sessionsModule)).writeSession(worktree, slug, role, { agent: chosen || '', sessionId });
899
+ } catch (/** @type {unknown} */ err) {
900
+ log(fmt.status('WARN', `Could not persist session marker for ${fmt.slug(slug)} (${role}): ${/** @type {any} */ (err).message}`));
852
901
  }
853
902
  }
854
903
 
@@ -858,7 +907,7 @@ async function startAgent(step, opts = {}) {
858
907
 
859
908
  // Legacy alias kept for backwards compatibility — draft.js calls this directly.
860
909
  // Returns { agent, invocation, result } so callers can log which agent ran.
861
- async function startDraftAgent(opts = {}) {
910
+ async function startDraftAgent(opts = { prompt: '' }) {
862
911
  return startAgent('draft', opts);
863
912
  }
864
913
 
@@ -873,7 +922,7 @@ module.exports = {
873
922
  readAgentConfigOrExit,
874
923
  assertAgentSupported,
875
924
  workflowLauncherStatus,
876
- setCommandPathProbe: (fn) => { _commandPathProbe = fn; },
925
+ setCommandPathProbe: (/** @type {(name: string) => string | null} */ fn) => { _commandPathProbe = fn; },
877
926
  isAgentBlocked,
878
927
  parseBlockUntil,
879
928
  isInvalidAgentConfigError,