@magnusekdahl/parallix 1.0.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 (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. package/tools/setup-forgejo-docker.sh +84 -0
@@ -0,0 +1,867 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+ const fmt = require('../core/fmt');
5
+ const { startCodexDraftAgent, resolveCodexCommand } = require('./codex');
6
+ const { startClaudeAgent, resolveClaudeCommand } = require('./claude');
7
+ const { startMistralAgent, resolveMistralCommand } = require('./mistral');
8
+ const { startOpencodeAgent, resolveOpencodeCommand } = require('./opencode');
9
+ const { detectLimitHit } = require('./limit-hit');
10
+ const sessions = require('../tools/sessions');
11
+ const storage = require('../core/storage');
12
+ const { resolveAgentModel } = require('../core/product-config');
13
+ const { migrateAgentBlocklists } = require('../core/persistent-data-migration');
14
+
15
+ // Launchers whose CLI accepts a per-call resume flag threaded by startAgent.
16
+ // Each launcher outputs a session resume hint at the end of its run (e.g.
17
+ // "codex resume <id>", "opencode -s ses_<id>",
18
+ // "claude --resume <id>"). The resume flag is only used when the caller
19
+ // passes slug+role+worktree and the session marker matches the chosen agent.
20
+ // qwen (opencode) always uses --continue; claude uses --continue; codex uses
21
+ // `exec resume --last`.
22
+ const RESUME_CAPABLE = new Set(['claude', 'codex', 'qwen']);
23
+
24
+ const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'agents.json');
25
+
26
+ // Test hook: when set, used instead of spawning `command -v` to check PATH.
27
+ let _commandPathProbe = null;
28
+
29
+ const LAUNCHERS = {
30
+ codex: startCodexDraftAgent,
31
+ claude: startClaudeAgent,
32
+ mistral: startMistralAgent,
33
+ qwen: startOpencodeAgent
34
+ };
35
+
36
+ const RESOLVERS = {
37
+ codex: resolveCodexCommand,
38
+ claude: resolveClaudeCommand,
39
+ mistral: resolveMistralCommand,
40
+ qwen: resolveOpencodeCommand
41
+ };
42
+
43
+ const HEALTH_PROBE_ARGS = Object.freeze({
44
+ codex: ['--help'],
45
+ claude: ['--help'],
46
+ mistral: ['--help'],
47
+ qwen: ['--help']
48
+ });
49
+ const LAUNCHER_HEALTH_TIMEOUT_MS = 3000;
50
+ const DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS = 60_000;
51
+ const DEFAULT_NO_OUTPUT_INTERVAL_MS = 60_000;
52
+ const DRAFT_NO_OUTPUT_INITIAL_DELAY_MS = 15_000;
53
+ const DRAFT_NO_OUTPUT_INTERVAL_MS = 30_000;
54
+
55
+ const WORKFLOW_AGENT_NAMES = Object.freeze(Object.keys(LAUNCHERS));
56
+ const KNOWN_AGENT_NAMES = Object.freeze([
57
+ ...WORKFLOW_AGENT_NAMES,
58
+ 'human'
59
+ ]);
60
+
61
+ function workflowLauncherStatus(agent) {
62
+ const resolver = RESOLVERS[agent];
63
+ if (!resolver) {
64
+ return { agent, supported: false, detail: `unknown agent: ${agent}` };
65
+ }
66
+ const command = resolver();
67
+ const exists = command.includes('/') ? fs.existsSync(command) : commandInPath(command);
68
+ if (!exists) {
69
+ return { agent, supported: false, detail: command, health: 'missing' };
70
+ }
71
+
72
+ const probeArgs = HEALTH_PROBE_ARGS[agent] || ['--help'];
73
+ const probe = spawnSync(command, probeArgs, {
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ timeout: LAUNCHER_HEALTH_TIMEOUT_MS
77
+ });
78
+
79
+ if (probe.error || probe.status !== 0) {
80
+ const reason = probe.error
81
+ ? probe.error.code || probe.error.message
82
+ : `exit ${probe.status}`;
83
+ return {
84
+ agent,
85
+ supported: false,
86
+ detail: `${command} ${probeArgs.join(' ')}`.trim(),
87
+ health: 'probe-failed',
88
+ reason
89
+ };
90
+ }
91
+
92
+ return { agent, supported: true, detail: `${command} ${probeArgs.join(' ')}`.trim(), health: 'ok' };
93
+ }
94
+
95
+ function commandInPath(name) {
96
+ if (_commandPathProbe) {
97
+ return _commandPathProbe(name) || false;
98
+ }
99
+ const result = spawnSync('bash', ['-c', `command -v ${name}`], {
100
+ encoding: 'utf8',
101
+ stdio: ['ignore', 'pipe', 'ignore']
102
+ });
103
+ return result.status === 0 && result.stdout.trim().length > 0;
104
+ }
105
+
106
+ function buildInvalidAgentConfigError(configPath, scope, originalError) {
107
+ const location = path.resolve(configPath);
108
+ const detail = originalError && originalError.message ? originalError.message : 'invalid JSON';
109
+ const error = new Error(
110
+ `Invalid ${scope} agent config at ${location}: ${detail}. ` +
111
+ 'Fix or remove the malformed file before running workflow commands so agent blocking is applied deterministically.'
112
+ );
113
+ error.code = 'WORKFLOW_AGENT_CONFIG_INVALID';
114
+ error.configPath = location;
115
+ error.configScope = scope;
116
+ return error;
117
+ }
118
+
119
+ function isInvalidAgentConfigError(error) {
120
+ return Boolean(error && error.code === 'WORKFLOW_AGENT_CONFIG_INVALID');
121
+ }
122
+
123
+ function readAgentConfigOrExit(configPath = CONFIG_PATH, options = {}) {
124
+ try {
125
+ return readAgentConfig(configPath, options);
126
+ } catch (error) {
127
+ if (isInvalidAgentConfigError(error)) {
128
+ fmt.log.fail(error.message);
129
+ process.exit(1);
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ function parseAgentConfigFile(configPath, scope) {
136
+ try {
137
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
138
+ } catch (err) {
139
+ throw buildInvalidAgentConfigError(configPath, scope, err);
140
+ }
141
+ }
142
+
143
+ function readAgentConfig(configPath = CONFIG_PATH, options = {}) {
144
+ const {
145
+ mergeLocal = path.resolve(configPath) === path.resolve(CONFIG_PATH),
146
+ mainWorktreePath,
147
+ warn = fmt.log.warn
148
+ } = options;
149
+ let config = null;
150
+ if (fs.existsSync(configPath)) {
151
+ config = parseAgentConfigFile(configPath, 'workflow');
152
+ }
153
+
154
+ if (mergeLocal) {
155
+ config = config || {};
156
+ const projectRoot = path.resolve(path.dirname(configPath), '..', '..');
157
+ const mainWorktree = mainWorktreePath !== undefined
158
+ ? mainWorktreePath
159
+ : getMainWorktreePath({ cwd: projectRoot, warn });
160
+ const legacyPaths = [
161
+ path.join(path.dirname(configPath), 'agents.local.json'),
162
+ 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 });
166
+ if (!fs.existsSync(targetPath)) {
167
+ try {
168
+ migrateAgentBlocklists({
169
+ sourcePaths: legacyPaths,
170
+ destinationPath: targetPath,
171
+ warn
172
+ });
173
+ } catch (error) {
174
+ throw buildInvalidAgentConfigError(targetPath, 'local', error);
175
+ }
176
+ }
177
+ if (fs.existsSync(targetPath)) {
178
+ const localConfig = parseAgentConfigFile(targetPath, 'local');
179
+ if (localConfig && localConfig.blocklist) {
180
+ config.blocklist = Object.assign(config.blocklist || {}, localConfig.blocklist);
181
+ }
182
+ }
183
+ }
184
+
185
+ return config;
186
+ }
187
+
188
+ function getMainWorktreePath(options = {}) {
189
+ const { cwd = process.cwd(), warn = fmt.log.warn } = options;
190
+ try {
191
+ const commonDir = getGitPath(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
192
+ if (commonDir && MainWorktreeDetector.byCommonDir.has(commonDir)) {
193
+ return MainWorktreeDetector.byCommonDir.get(commonDir);
194
+ }
195
+
196
+ const result = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
197
+ encoding: 'utf8',
198
+ stdio: ['ignore', 'pipe', 'ignore'],
199
+ timeout: 1000
200
+ });
201
+ if (result.status !== 0) {
202
+ warn(
203
+ `Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
204
+ `skipping that lookup (git exited with status ${result.status}).`
205
+ );
206
+ return null;
207
+ }
208
+
209
+ const lines = result.stdout.split('\n');
210
+ const mainWorktreePath = detectMainWorktreePath(lines, cwd, commonDir);
211
+ if (mainWorktreePath) {
212
+ if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, mainWorktreePath);
213
+ return mainWorktreePath;
214
+ }
215
+
216
+ // Fallback: pick the first worktree whose HEAD points to main
217
+ let i = 0;
218
+ while (i < lines.length) {
219
+ if (lines[i].startsWith('worktree ')) {
220
+ const wt = lines[i].slice('worktree '.length).trim();
221
+ const branchLineIdx = i + 1;
222
+ if (branchLineIdx < lines.length && lines[branchLineIdx].startsWith('branch refs/heads/main')) {
223
+ if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, wt);
224
+ return wt;
225
+ }
226
+ }
227
+ i++;
228
+ }
229
+
230
+ // Last resort: the first worktree in the list that isn't the current cwd
231
+ for (i = 0; i < lines.length; i++) {
232
+ if (lines[i].startsWith('worktree ')) {
233
+ const wt = lines[i].slice('worktree '.length).trim();
234
+ if (wt !== cwd) {
235
+ if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, wt);
236
+ return wt;
237
+ }
238
+ }
239
+ }
240
+ } catch (err) {
241
+ const detail = err && (err.code || err.message) ? (err.code || err.message) : 'unknown error';
242
+ warn(
243
+ `Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
244
+ `skipping that lookup (${detail}).`
245
+ );
246
+ return null;
247
+ }
248
+
249
+ warn(
250
+ 'Could not determine the main worktree from `git worktree list --porcelain`; ' +
251
+ 'skipping main-worktree agents.local.json lookup.'
252
+ );
253
+ return null;
254
+ }
255
+
256
+ // Extract the known main worktree path from the repo metadata so worktree
257
+ // iteration doesn't accidentally pick the current (non-main) worktree.
258
+ // Cached per git common directory to avoid repeated subprocess calls without
259
+ // leaking a temp-repo answer into later tests or nested workflow invocations.
260
+ const MainWorktreeDetector = {
261
+ byCommonDir: new Map()
262
+ };
263
+
264
+ function getGitPath(cwd, args) {
265
+ const result = spawnSync('git', ['-C', cwd, ...args], {
266
+ encoding: 'utf8',
267
+ stdio: ['ignore', 'pipe', 'ignore'],
268
+ timeout: 1000
269
+ });
270
+ if (result.status !== 0) {
271
+ return null;
272
+ }
273
+ return result.stdout.trim() || null;
274
+ }
275
+
276
+ function parseWorktreePaths(lines) {
277
+ return lines
278
+ .filter(line => line.startsWith('worktree '))
279
+ .map(line => line.slice('worktree '.length).trim())
280
+ .filter(Boolean);
281
+ }
282
+
283
+ function detectMainWorktreePath(lines, cwd, commonDir) {
284
+ const worktrees = parseWorktreePaths(lines);
285
+ if (worktrees.length === 0) {
286
+ return null;
287
+ }
288
+
289
+ const resolvedCommonDir = commonDir ? path.resolve(commonDir) : null;
290
+ for (const wt of worktrees) {
291
+ const gitDir = getGitPath(wt, ['rev-parse', '--absolute-git-dir']);
292
+ const wtCommonDir = getGitPath(wt, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
293
+ if (
294
+ gitDir &&
295
+ wtCommonDir &&
296
+ path.resolve(gitDir) === path.resolve(wtCommonDir) &&
297
+ (!resolvedCommonDir || path.resolve(wtCommonDir) === resolvedCommonDir)
298
+ ) {
299
+ return wt;
300
+ }
301
+ }
302
+
303
+ const currentTopLevel = getGitPath(cwd, ['rev-parse', '--show-toplevel']);
304
+ if (currentTopLevel && worktrees.length === 1 && path.resolve(worktrees[0]) === path.resolve(currentTopLevel)) {
305
+ return worktrees[0];
306
+ }
307
+ return null;
308
+ }
309
+
310
+ function parseBlockUntil(value) {
311
+ if (typeof value !== 'string') {
312
+ return NaN;
313
+ }
314
+
315
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2})$/);
316
+ if (!match) {
317
+ return NaN;
318
+ }
319
+
320
+ const [, yearStr, monthStr, dayStr, hourStr] = match;
321
+ const year = Number(yearStr);
322
+ const month = Number(monthStr);
323
+ const day = Number(dayStr);
324
+ const hour = Number(hourStr);
325
+ const parsed = new Date(year, month - 1, day, hour, 0, 0, 0);
326
+
327
+ if (
328
+ parsed.getFullYear() !== year ||
329
+ parsed.getMonth() !== month - 1 ||
330
+ parsed.getDate() !== day ||
331
+ parsed.getHours() !== hour
332
+ ) {
333
+ return NaN;
334
+ }
335
+
336
+ return parsed.getTime();
337
+ }
338
+
339
+ function isAgentBlocked(agent, config) {
340
+ if (!config || !config.blocklist || config.blocklist[agent] === undefined) {
341
+ return false;
342
+ }
343
+ const entry = config.blocklist[agent];
344
+ if (entry === true) return true;
345
+ if (entry === false) return false;
346
+ if (entry && typeof entry === 'object') {
347
+ if (entry.blocked === true) return true;
348
+ if (entry.blocked === false) return false;
349
+ if (entry.until) {
350
+ const until = parseBlockUntil(entry.until);
351
+ if (!isNaN(until) && until > Date.now()) {
352
+ return true;
353
+ }
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+
359
+ function eligibleAgentsForStep(step, options = {}) {
360
+ const config = options.config !== undefined
361
+ ? options.config
362
+ : readAgentConfig(options.configPath || CONFIG_PATH, options);
363
+ let eligible;
364
+ if (!config || !config.steps || !config.steps[step]) {
365
+ eligible = Object.keys(LAUNCHERS);
366
+ } else {
367
+ eligible = config.steps[step].eligible || Object.keys(LAUNCHERS);
368
+ }
369
+ return eligible.filter(agent => !isAgentBlocked(agent, config));
370
+ }
371
+
372
+ function weightedRandom(agents, weights) {
373
+ const total = agents.reduce((sum, a) => sum + (weights[a] || 1), 0);
374
+ let r = Math.random() * total;
375
+ for (const agent of agents) {
376
+ r -= weights[agent] || 1;
377
+ if (r <= 0) return agent;
378
+ }
379
+ return agents[agents.length - 1];
380
+ }
381
+
382
+ function selectAgent(step, options = {}) {
383
+ const envOverride = process.env.WORKFLOW_AGENT;
384
+ const excluded = options.exclude instanceof Set ? options.exclude : new Set();
385
+ const eligible = eligibleAgentsForStep(step, options);
386
+ // Honor the env override only when it is in the current eligible-and-unblocked
387
+ // pool and not already excluded (a previous limit-hit attempt in the same
388
+ // startAgent retry loop). A pinned agent that is hard-blocked in
389
+ // agents.local.json or excluded by step eligibility falls through to normal
390
+ // selection — matches parallix/docs/agents.md, which documents that
391
+ // WORKFLOW_AGENT is honored alongside the eligibility config and blocklist.
392
+ if (envOverride && !excluded.has(envOverride) && eligible.includes(envOverride)) {
393
+ return envOverride;
394
+ }
395
+
396
+ const pool = eligible.filter(agent => !excluded.has(agent));
397
+ if (eligible.length === 0) {
398
+ throw new Error(`No agents are eligible for workflow step: ${step}`);
399
+ }
400
+ if (pool.length === 0) {
401
+ throw new Error(
402
+ `All eligible agents for step "${step}" are exhausted (limit-hit or excluded). ` +
403
+ `Tried: ${[...excluded].join(', ')}.`
404
+ );
405
+ }
406
+
407
+ // Filter to agents that are both eligible (per config) and supported (launcher present).
408
+ const statuses = new Map(
409
+ pool
410
+ .filter(agent => LAUNCHERS[agent])
411
+ .map(agent => [agent, workflowLauncherStatus(agent)])
412
+ );
413
+ const available = pool.filter(agent => {
414
+ const status = statuses.get(agent);
415
+ return Boolean(status && status.supported);
416
+ });
417
+ if (available.length === 0) {
418
+ const blockers = pool.map(agent => {
419
+ const status = statuses.get(agent) || { detail: agent, reason: 'unsupported-agent' };
420
+ const suffix = status.reason ? `; ${status.reason}` : '';
421
+ return `${agent} (looked for: ${status.detail}${suffix})`;
422
+ });
423
+ throw new Error(
424
+ `No eligible agents have a working launcher for step "${step}". ` +
425
+ `Eligible but blocked: ${blockers.join(', ')}. ` +
426
+ `Set WORKFLOW_AGENT=<name> to override or install a supported agent.`
427
+ );
428
+ }
429
+
430
+ const config = options.config !== undefined
431
+ ? options.config
432
+ : readAgentConfig(options.configPath || CONFIG_PATH, options);
433
+ const stepConfig = config && config.steps && config.steps[step] ? config.steps[step] : {};
434
+ const selection = stepConfig.selection || 'random';
435
+
436
+ if (selection === 'weighted') {
437
+ const weights = stepConfig.weights || {};
438
+ return weightedRandom(available, weights);
439
+ }
440
+
441
+ if (selection === 'random') {
442
+ return available[Math.floor(Math.random() * available.length)];
443
+ }
444
+
445
+ return available[0];
446
+ }
447
+
448
+ function assertAgentSupported(agent) {
449
+ if (!LAUNCHERS[agent]) {
450
+ const error = new Error(
451
+ `Unknown agent: "${fmt.agent(agent)}". Supported agents: ${Object.keys(LAUNCHERS).join(', ')}.`
452
+ );
453
+ error.code = 'UNKNOWN_AGENT';
454
+ throw error;
455
+ }
456
+
457
+ const status = workflowLauncherStatus(agent);
458
+ if (!status.supported) {
459
+ const health = status.health ? ` (${status.health})` : '';
460
+ const reason = status.reason ? `; reason: ${status.reason}` : '';
461
+ const error = new Error(
462
+ `Agent "${fmt.agent(agent)}" launcher is not available on this workstation${health}. ` +
463
+ `Looked for: ${fmt.path(status.detail)}${reason}. ` +
464
+ `Ensure ${fmt.agent(agent)} is on your PATH and retry.`
465
+ );
466
+ error.code = 'LAUNCHER_UNAVAILABLE';
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ function resolveBlocklistTargetPath(options = {}) {
472
+ if (options.targetPath) return options.targetPath;
473
+ return storage.resolveAgentsLocalPath({ ensureDir: true });
474
+ }
475
+
476
+ function updateAgentBlock(agent, until, options = {}) {
477
+ if (!agent || typeof agent !== 'string') {
478
+ throw new Error('updateAgentBlock requires an agent name');
479
+ }
480
+ if (!until || typeof until !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}$/.test(until)) {
481
+ throw new Error(`updateAgentBlock requires an "YYYY-MM-DD HH" timestamp; got: ${until}`);
482
+ }
483
+
484
+ const targetPath = resolveBlocklistTargetPath(options);
485
+
486
+ let payload = {};
487
+ if (fs.existsSync(targetPath)) {
488
+ // Match the read-path contract (parseAgentConfigFile): malformed local agent
489
+ // JSON is a hard failure, not a silent overwrite. Otherwise a limit hit on a
490
+ // corrupted agents.local.json would destroy whatever was on disk.
491
+ try {
492
+ payload = JSON.parse(fs.readFileSync(targetPath, 'utf8')) || {};
493
+ } catch (err) {
494
+ throw buildInvalidAgentConfigError(targetPath, 'local', err);
495
+ }
496
+ if (typeof payload !== 'object' || Array.isArray(payload)) {
497
+ throw buildInvalidAgentConfigError(
498
+ targetPath,
499
+ 'local',
500
+ new Error('expected a JSON object at the file root')
501
+ );
502
+ }
503
+ }
504
+ if (!payload.blocklist || typeof payload.blocklist !== 'object' || Array.isArray(payload.blocklist)) {
505
+ payload.blocklist = {};
506
+ }
507
+ payload.blocklist[agent] = { until };
508
+
509
+ storage.writeJson(targetPath, payload);
510
+ return { path: targetPath, blocklist: payload.blocklist };
511
+ }
512
+
513
+ function defaultIsAgentBlockedNow(agent) {
514
+ try {
515
+ const config = readAgentConfig(CONFIG_PATH, {});
516
+ return isAgentBlocked(agent, config);
517
+ } catch (err) {
518
+ // If the config is malformed, surface that through the launcher path
519
+ // (assertAgentSupported / launch) instead of silently rerouting. Treat as
520
+ // not-blocked here so the existing error path runs.
521
+ return false;
522
+ }
523
+ }
524
+
525
+ function readPositiveMsEnv(name) {
526
+ const raw = process.env[name];
527
+ if (raw === undefined || raw === '') return null;
528
+ const value = Number(raw);
529
+ return Number.isFinite(value) && value >= 0 ? value : null;
530
+ }
531
+
532
+ function resolveNoOutputWatchdogConfig(config = {}, step = null) {
533
+ if (config === false || process.env.WORKFLOW_AGENT_NO_OUTPUT_WATCHDOG === '0') {
534
+ return null;
535
+ }
536
+ const explicit = config && typeof config === 'object' ? config : {};
537
+ // Draft gets a shorter default watchdog to surface agent-launch visibility
538
+ // quickly; the generic default (60s) is too slow for the draft entrypoint
539
+ // where an operator cannot tell launch from hang.
540
+ let initialDelayMs;
541
+ let intervalMs;
542
+ if (step === 'draft') {
543
+ initialDelayMs = explicit.initialDelayMs ??
544
+ readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INITIAL_MS') ??
545
+ DRAFT_NO_OUTPUT_INITIAL_DELAY_MS;
546
+ intervalMs = explicit.intervalMs ??
547
+ readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INTERVAL_MS') ??
548
+ DRAFT_NO_OUTPUT_INTERVAL_MS;
549
+ } else {
550
+ initialDelayMs = explicit.initialDelayMs ??
551
+ readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INITIAL_MS') ??
552
+ DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS;
553
+ intervalMs = explicit.intervalMs ??
554
+ readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INTERVAL_MS') ??
555
+ DEFAULT_NO_OUTPUT_INTERVAL_MS;
556
+ }
557
+ return { initialDelayMs, intervalMs };
558
+ }
559
+
560
+ function formatElapsed(elapsedMs) {
561
+ const seconds = Math.max(0, Math.round(elapsedMs / 1000));
562
+ if (seconds < 60) return `${seconds}s`;
563
+ const minutes = Math.floor(seconds / 60);
564
+ const remainder = seconds % 60;
565
+ return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
566
+ }
567
+
568
+ async function startAgent(step, opts = {}) {
569
+ const {
570
+ prompt,
571
+ worktree,
572
+ agent: agentOverride,
573
+ env = {},
574
+ exclude = [],
575
+ onLimitHit,
576
+ onLaunch,
577
+ slug = null,
578
+ role = null,
579
+ detectLimitHitFn = detectLimitHit,
580
+ updateAgentBlockFn = updateAgentBlock,
581
+ selectAgentFn = selectAgent,
582
+ resolveAgentModelFn = resolveAgentModel,
583
+ isAgentBlockedFn = defaultIsAgentBlockedNow,
584
+ sessionsModule = sessions,
585
+ log = fmt.log.plain,
586
+ noOutputWatchdog = {}
587
+ } = opts;
588
+
589
+ // `exclude` seeds the tried-set so callers can reserve agents (e.g. exclude
590
+ // the current implementer from reviewer fallback to preserve family separation).
591
+ const excludeIterable = exclude instanceof Set ? exclude : exclude;
592
+ const tried = new Set(excludeIterable);
593
+ // Track per-agent failure details for accurate exhaustion diagnostics (SC 3)
594
+ const agentErrors = new Map();
595
+ // Track agents actually launched (not just pre-excluded) for accurate reporting
596
+ const launched = new Set();
597
+ let iteration = 0;
598
+ let chosen = agentOverride;
599
+
600
+ while (true) {
601
+ iteration += 1;
602
+ if (!chosen) {
603
+ try {
604
+ chosen = selectAgentFn(step, { exclude: tried });
605
+ } catch (err) {
606
+ // Only catch pool exhaustion errors from selectAgent.
607
+ // Configuration errors (no eligible agents, no working launcher) must
608
+ // propagate unchanged to preserve diagnostics (SC 3).
609
+ // Exhaustion is indicated by:
610
+ // - "exhausted" from real selectAgent pool exhaustion ("are exhausted")
611
+ // - "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
+ )) {
616
+ throw err;
617
+ }
618
+ // Pool exhausted; build clear exhaustion diagnostics with per-agent errors (SC 3)
619
+ const errorDetails = [...agentErrors.entries()].map(([agent, details]) => {
620
+ const status = details.exitInfo === 'stalled'
621
+ ? 'stalled (no output)'
622
+ : (details.status !== undefined && details.status !== null
623
+ ? `exit ${details.status}`
624
+ : (details.signal ? `signal ${details.signal}` : 'unknown'));
625
+ const stderrSnippet = details.stderr ? ` (${details.stderr.trim().split('\n')[0]})` : '';
626
+ return `${agent}: ${status}${stderrSnippet}`;
627
+ }).join('; ');
628
+ const launchedList = [...launched].join(', ');
629
+ throw new Error(
630
+ `All eligible agents exhausted for step "${step}". ` +
631
+ `Tried: ${launchedList}. Errors: ${errorDetails}.`
632
+ );
633
+ }
634
+ } else if (isAgentBlockedFn(chosen)) {
635
+ // Pre-launch blocklist gate. An explicit `agent:` override (e.g. a pinned
636
+ // reviewer/implementer carried over from review-state.json) bypasses
637
+ // selectAgent's blocklist filter. Without this check, a known-blocked
638
+ // family is relaunched immediately and the harness wastes a retry hitting
639
+ // the same limit. Reroute through normal selection on the next iteration.
640
+ log(fmt.status('WARN', `Pinned agent "${fmt.agent(chosen)}" is currently blocked in agents.local.json; rerouting via selectAgent for step "${step}".`));
641
+ tried.add(chosen);
642
+ chosen = null;
643
+ continue;
644
+ }
645
+
646
+ try {
647
+ assertAgentSupported(chosen);
648
+ } catch (err) {
649
+ if (err.code !== 'LAUNCHER_UNAVAILABLE') {
650
+ throw err;
651
+ }
652
+ log(fmt.status('WARN', err.message));
653
+ // Only reroute for launcher-availability failures (missing or probe-failed).
654
+ tried.add(chosen);
655
+ // If the caller pinned a specific agent, allow one retry that ignores
656
+ // the override and falls back to normal selection (matches limit-hit logic).
657
+ if (agentOverride && agentOverride === chosen && iteration === 1) {
658
+ chosen = null;
659
+ continue;
660
+ }
661
+ chosen = null;
662
+ continue;
663
+ }
664
+ tried.add(chosen);
665
+ launched.add(chosen);
666
+
667
+ const launcher = LAUNCHERS[chosen];
668
+ log(fmt.status('INFO', `Selected agent for step "${step}": ${fmt.agent(chosen)}${iteration > 1 ? ` (attempt ${iteration})` : ''}`));
669
+
670
+ // Enforce the agent family as the Forgejo identity (ADR 0029 / task-095).
671
+ // FORGEJO_USER is set last so the harness-selected identity always wins;
672
+ // a caller-supplied env.FORGEJO_USER cannot override it.
673
+ const agentEnv = { ...env, FORGEJO_USER: chosen };
674
+
675
+ // Decide whether to resume the agent's prior session for this (slug, role).
676
+ // Only honored when the caller passed slug+role+worktree AND the previous
677
+ // marker matches the chosen agent family (a fallback to a different family
678
+ // invalidates the prior session).
679
+ const resume = Boolean(
680
+ worktree && slug && role &&
681
+ RESUME_CAPABLE.has(chosen) &&
682
+ sessionsModule.shouldResume(worktree, slug, role, chosen)
683
+ );
684
+ const sessionId = sessionsModule.getSessionId(worktree, slug, role);
685
+ if (slug && role) {
686
+ 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.`));
690
+ }
691
+ }
692
+
693
+ // Resolve the prompt string. If a function was provided, call it with the
694
+ // currently chosen agent name (TASK-1051). This ensures that if startAgent
695
+ // falls back to a different family after a limit hit, the fallback agent
696
+ // receives a prompt tailored to its own identity.
697
+ const actualPrompt = typeof prompt === 'function' ? prompt(chosen) : prompt;
698
+
699
+ // Resolve the per-family model override (adapters.agents.models[chosen]).
700
+ // null when the family is not configured, in which case the launcher omits
701
+ // the model flag entirely and the agent uses its own default.
702
+ const model = resolveAgentModelFn(chosen, worktree || process.cwd());
703
+ if (model) {
704
+ log(fmt.status('INFO', `Using configured model for ${fmt.agent(chosen)}: ${model}`));
705
+ }
706
+
707
+ const watchdogConfig = resolveNoOutputWatchdogConfig(noOutputWatchdog, step);
708
+ const launchResult = launcher({
709
+ prompt: actualPrompt,
710
+ worktree,
711
+ env: agentEnv,
712
+ resume,
713
+ sessionId,
714
+ model,
715
+ slug,
716
+ role,
717
+ teeOptions: watchdogConfig ? {
718
+ noOutputWatchdog: {
719
+ ...watchdogConfig,
720
+ onNoOutput: ({ pid, elapsedMs }) => {
721
+ const stage = elapsedMs < (step === 'draft' ? DRAFT_NO_OUTPUT_INITIAL_DELAY_MS : DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS)
722
+ ? 'starting up'
723
+ : 'running';
724
+ log(fmt.status(
725
+ 'INFO',
726
+ `No output yet from ${fmt.agent(chosen)} for step "${step}" after ${formatElapsed(elapsedMs)} ` +
727
+ `(pid ${pid || 'unknown'}, agent ${stage}). ` +
728
+ `Launcher is still running; stdout/stderr have not produced visible output.`
729
+ ));
730
+ }
731
+ }
732
+ } : {}
733
+ });
734
+ const { invocation, resultPromise } = launchResult;
735
+ if (invocation) {
736
+ log(fmt.status('INFO', `Launching: ${fmt.command(`${invocation.command} ${invocation.args.join(' ')}`)}`));
737
+ if (invocation.options && invocation.options.cwd) {
738
+ log(fmt.status('INFO', `Working directory: ${fmt.path(invocation.options.cwd)}`));
739
+ }
740
+ }
741
+
742
+ if (onLaunch) {
743
+ await onLaunch({ agent: chosen, invocation });
744
+ }
745
+
746
+ const result = resultPromise ? await resultPromise : launchResult.result;
747
+
748
+ // Pass exit metadata so detectLimitHit only treats matching transcript text
749
+ // as a real limit hit when the launcher actually failed. A successful run
750
+ // (status === 0) that happens to contain limit-hit phrases — for example,
751
+ // an agent reviewing code or logs that quote those phrases — must not block
752
+ // the healthy agent.
753
+ const limitHit = detectLimitHitFn({
754
+ agent: chosen,
755
+ stdout: result && result.stdout,
756
+ stderr: result && result.stderr,
757
+ status: result && result.status,
758
+ signal: result && result.signal,
759
+ error: result && result.error
760
+ });
761
+
762
+ if (limitHit) {
763
+ log(fmt.status('WARN', `Limit hit detected for ${fmt.agent(chosen)}; reset estimate "${limitHit.until}" (${limitHit.source}). Blocking and retrying.`));
764
+ 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}`));
769
+ }
770
+ if (typeof onLimitHit === 'function') {
771
+ onLimitHit({ agent: chosen, until: limitHit.until, source: limitHit.source });
772
+ }
773
+ // Reset chosen so next iteration reselects, but only when no explicit override.
774
+ // If the caller pinned a specific agent, fail loudly — there is no fallback.
775
+ if (agentOverride && agentOverride === chosen && iteration === 1) {
776
+ // Allow one retry that ignores the override.
777
+ chosen = null;
778
+ continue;
779
+ }
780
+ chosen = null;
781
+ continue;
782
+ }
783
+
784
+ // Reroute if the launcher binary could not be started (ENOENT = not found, EACCES = not executable).
785
+ 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);
788
+ if (agentOverride && agentOverride === chosen && iteration === 1) {
789
+ chosen = null;
790
+ continue;
791
+ }
792
+ chosen = null;
793
+ continue;
794
+ }
795
+
796
+ // Detect launch failure: agent started but exited with non-zero status and
797
+ // no limit-hit was detected. This catches errors like "Model not found" in
798
+ // opencode that cause the launcher to exit immediately with an error code.
799
+ // Retry with the next eligible agent instead of returning the failure.
800
+ // Only treat `status !== null && status !== 0` or `signal` (with no spawn
801
+ // error) as a launch failure; `status: null` without signal is ambiguous
802
+ // (spawn-tee close event can emit null code) and should not trigger a retry.
803
+ const launchFailed = result &&
804
+ ((result.status !== null && result.status !== 0) || (result.signal && !result.error)) &&
805
+ !limitHit;
806
+ if (launchFailed) {
807
+ const exitInfo = result.signal
808
+ ? `signal ${result.signal}`
809
+ : `exit ${result.status}`;
810
+ const stderrSnippet = result && result.stderr
811
+ ? ` (${result.stderr.trim().split('\n')[0]})`
812
+ : '';
813
+ log(fmt.status('WARN', `Agent ${fmt.agent(chosen)} failed to complete (${exitInfo}${stderrSnippet}); retrying with next eligible agent.`));
814
+ agentErrors.set(chosen, {
815
+ exitInfo,
816
+ stderr: result.stderr,
817
+ stdout: result.stdout,
818
+ signal: result.signal,
819
+ status: result.status,
820
+ });
821
+ tried.add(chosen);
822
+ launched.add(chosen);
823
+ chosen = null;
824
+ continue;
825
+ }
826
+ // Record the marker so a subsequent same-(slug, role) launch knows which
827
+ // family last ran here. We only persist when the run exited cleanly
828
+ // (status 0 and no spawn error); a failed launch should not overwrite
829
+ // the canonical session marker with a stale transcript.
830
+ if (worktree && slug && role && result && result.status === 0 && !result.error) {
831
+ try {
832
+ const sessionId = result && result.sessionId ? result.sessionId : null;
833
+ sessionsModule.writeSession(worktree, slug, role, { agent: chosen, sessionId });
834
+ } catch (err) {
835
+ log(fmt.status('WARN', `Could not persist session marker for ${fmt.slug(slug)} (${role}): ${err.message}`));
836
+ }
837
+ }
838
+
839
+ return { agent: chosen, invocation, result };
840
+ }
841
+ }
842
+
843
+ // Legacy alias kept for backwards compatibility — draft.js calls this directly.
844
+ // Returns { agent, invocation, result } so callers can log which agent ran.
845
+ async function startDraftAgent(opts = {}) {
846
+ return startAgent('draft', opts);
847
+ }
848
+
849
+ module.exports = {
850
+ KNOWN_AGENT_NAMES,
851
+ WORKFLOW_AGENT_NAMES,
852
+ startAgent,
853
+ startDraftAgent,
854
+ selectAgent,
855
+ eligibleAgentsForStep,
856
+ readAgentConfig,
857
+ readAgentConfigOrExit,
858
+ assertAgentSupported,
859
+ workflowLauncherStatus,
860
+ setCommandPathProbe: (fn) => { _commandPathProbe = fn; },
861
+ isAgentBlocked,
862
+ parseBlockUntil,
863
+ isInvalidAgentConfigError,
864
+ updateAgentBlock,
865
+ resolveBlocklistTargetPath,
866
+ resolveNoOutputWatchdogConfig
867
+ };