@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
@@ -3,20 +3,28 @@ const { extractOpencodeTelemetryFromExport } = require('./opencode-telemetry');
3
3
  const { captureOpencodeExport } = require('./opencode-export');
4
4
  const sessions = require('../tools/sessions');
5
5
  const { detectLimitHit } = require('./limit-hit');
6
+ const { buildSubagentLimitPrefix } = require('../core/subagent-limit');
6
7
 
7
8
  // Injectable I/O for tests. Production uses the real spawn-tee / export capture.
9
+ /** @type {Function} */
8
10
  let _spawnAndTee = spawnAndTee;
11
+ /** @type {Function} */
9
12
  let _captureExport = captureOpencodeExport;
13
+ /** @type {object} */
10
14
  let _sessions = sessions;
11
15
 
12
16
  // Test hooks: override the launcher's I/O without touching the public signature.
17
+ /** @param {Function} fn */
13
18
  function __setSpawnAndTeeForTest(fn) { _spawnAndTee = fn || spawnAndTee; }
19
+ /** @param {Function} fn */
14
20
  function __setExportCaptureForTest(fn) { _captureExport = fn || captureOpencodeExport; }
21
+ /** @param {object} mod */
15
22
  function __setSessionsForTest(mod) { _sessions = mod || sessions; }
16
23
 
17
24
  // Test hook: override the cached feature-detect for `--format json` support.
18
25
  // Set to true/false to force inclusion/exclusion of the flag regardless of
19
26
  // the real binary. Pass null to reset to the live detect.
27
+ /** @param {boolean|null} val */
20
28
  function __setJsonFormatSupportForTest(val) {
21
29
  _jsonFormatSupported = val;
22
30
  _jsonFormatDetectFn = null;
@@ -24,6 +32,7 @@ function __setJsonFormatSupportForTest(val) {
24
32
 
25
33
  // Test hook: inject a feature-detect function for tests. This replaces the
26
34
  // live shell-out with a stubbed function, making tests hermetic.
35
+ /** @param {Function} fn */
27
36
  function __setJsonFormatDetectForTest(fn) {
28
37
  _jsonFormatDetectFn = fn;
29
38
  _jsonFormatSupported = null; // invalidate cache so the detect fn runs
@@ -45,10 +54,11 @@ function __setJsonFormatDetectForTest(fn) {
45
54
  const OPENCODE_SESSION_ID_RE = /opencode\s+-s\s+(ses_\S+)/i;
46
55
  const OPENCODE_JSON_SESSION_ID_RE = /"sessionID"\s*:\s*"(ses_[^"]+)"/;
47
56
 
57
+ /** @param {string} stdout */
48
58
  function extractOpencodeSessionId(stdout) {
49
- if (!stdout) return null;
59
+ if (!stdout) {return null;}
50
60
  const footer = OPENCODE_SESSION_ID_RE.exec(stdout);
51
- if (footer) return footer[1];
61
+ if (footer) {return footer[1];}
52
62
  const json = OPENCODE_JSON_SESSION_ID_RE.exec(stdout);
53
63
  return json ? json[1] : null;
54
64
  }
@@ -56,10 +66,12 @@ function extractOpencodeSessionId(stdout) {
56
66
  // Cached feature-detect for `--format json` support. Set to `false` only when
57
67
  // a real invocation proves the flag is rejected (older opencode versions).
58
68
  // Tests can inject a canned result via __setJsonFormatSupportForTest.
69
+ /** @type {boolean | null} */
59
70
  let _jsonFormatSupported = null;
60
71
 
61
72
  // Injectable feature-detect function for tests. When set, checkJsonFormatSupport()
62
73
  // calls this function instead of shelling out, making tests hermetic.
74
+ /** @type {Function | null} */
63
75
  let _jsonFormatDetectFn = null;
64
76
 
65
77
  function resolveOpencodeCommand() {
@@ -72,7 +84,7 @@ function resolveOpencodeCommand() {
72
84
  // Results are cached so the check runs at most once in production.
73
85
  // Tests can inject a canned result via __setJsonFormatSupportForTest.
74
86
  function checkJsonFormatSupport() {
75
- if (_jsonFormatSupported !== null) return _jsonFormatSupported;
87
+ if (_jsonFormatSupported !== null) {return _jsonFormatSupported;}
76
88
  if (_jsonFormatDetectFn) {
77
89
  _jsonFormatSupported = _jsonFormatDetectFn();
78
90
  return _jsonFormatSupported;
@@ -99,6 +111,9 @@ function checkJsonFormatSupport() {
99
111
  }
100
112
  }
101
113
 
114
+ /**
115
+ * @param {{ prompt: string, worktree: string, env?: object, resume?: boolean, sessionId?: string | null, model?: string | null, preferJson?: boolean }} opts
116
+ */
102
117
  function buildOpencodeInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null, preferJson = true }) {
103
118
  // `--format json` makes opencode stream NDJSON events that each carry a
104
119
  // "sessionID":"ses_..." field. opencode v2.0.0's default `run` output no
@@ -111,8 +126,8 @@ function buildOpencodeInvocation({ prompt, worktree, env, resume = false, sessio
111
126
  // invocation, preserving launch behaviour at the cost of telemetry.
112
127
  const useJson = preferJson && checkJsonFormatSupport();
113
128
  const args = ['run', '--pure', '--dangerously-skip-permissions'];
114
- if (useJson) args.push('--format', 'json');
115
- if (model) args.push('-m', model);
129
+ if (useJson) {args.push('--format', 'json');}
130
+ if (model) {args.push('-m', model);}
116
131
  // Only resume when the marker explicitly says so (resume=true).
117
132
  // sessionId from a stale cross-family marker must be ignored when resume=false.
118
133
  if (resume && sessionId) {
@@ -170,13 +185,19 @@ const HARD_OPENCODE_PATTERNS = [
170
185
  /\bauthentication (?:failed|error)\b/i,
171
186
  ];
172
187
 
188
+ /**
189
+ * @param {{stdout?: string, stderr?: string}} result
190
+ */
173
191
  function failureText(result) {
174
192
  return `${(result && result.stdout) || ''}\n${(result && result.stderr) || ''}`;
175
193
  }
176
194
 
195
+ /**
196
+ * @param {{stdout?: string, stderr?: string, error?: {code?: string}}} result
197
+ */
177
198
  function isHardOpencodeFailure(result) {
178
- if (!result) return false;
179
- if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) return true;
199
+ if (!result) {return false;}
200
+ if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {return true;}
180
201
  const text = failureText(result);
181
202
  return HARD_OPENCODE_PATTERNS.some((re) => re.test(text));
182
203
  }
@@ -186,16 +207,22 @@ function isHardOpencodeFailure(result) {
186
207
  // When the transcript contains a valid "reason":"stop" completion event and
187
208
  // no "type":"error" event, the exit is spurious – the agent actually
188
209
  // completed successfully. See opencode issues #31446 and #33653.
210
+ /**
211
+ * @param {{status?: number, stdout?: string, stderr?: string}} result
212
+ */
189
213
  function isSpuriousOpencodeExit(result) {
190
- if (!result || result.status !== 1) return false;
214
+ if (!result || result.status !== 1) {return false;}
191
215
  const text = failureText(result);
192
216
  const hasStop = /"reason"\s*:\s*"stop"/.test(text);
193
217
  const hasError = /"type"\s*:\s*"error"/.test(text);
194
218
  return hasStop && !hasError;
195
219
  }
196
220
 
221
+ /**
222
+ * @param {{stdout?: string, stderr?: string, error?: {code?: string}, signal?: string, status?: number}} result
223
+ */
197
224
  function isTransientOpencodeFailure(result) {
198
- if (!result) return false;
225
+ if (!result) {return false;}
199
226
  const text = failureText(result);
200
227
  return TRANSIENT_OPENCODE_PATTERNS.some((re) => re.test(text));
201
228
  }
@@ -204,11 +231,12 @@ function isTransientOpencodeFailure(result) {
204
231
  // Only genuine non-zero exits carrying a transient backend signature qualify;
205
232
  // clean exits, spawn errors (ENOENT/EACCES), killing signals, limit-hits, and
206
233
  // hard errors never retry — so real failures still surface to the reroute loop.
234
+ /** @param {{stdout?: string, stderr?: string, status?: number | null, signal?: string | null, error?: {code?: string}, sessionId?: string | null}} result */
207
235
  function shouldRetryOpencodeFailure(result) {
208
- if (!result) return false;
209
- if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) return false;
210
- if (result.signal) return false;
211
- if (!(typeof result.status === 'number' && result.status !== 0)) return false;
236
+ if (!result) {return false;}
237
+ if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {return false;}
238
+ if (result.signal) {return false;}
239
+ if (!(typeof result.status === 'number' && result.status !== 0)) {return false;}
212
240
  if (detectLimitHit({
213
241
  agent: 'custom',
214
242
  stdout: result.stdout,
@@ -216,11 +244,14 @@ function shouldRetryOpencodeFailure(result) {
216
244
  status: result.status,
217
245
  signal: result.signal,
218
246
  error: result.error,
219
- })) return false;
220
- if (isHardOpencodeFailure(result)) return false;
221
- return isTransientOpencodeFailure(result);
247
+ })) {return false;}
248
+ if (isHardOpencodeFailure(result)) {return false;}
249
+ return isTransientOpencodeFailure(/** @type {any} */(result));
222
250
  }
223
251
 
252
+ /**
253
+ * @param {{ prompt: string, worktree: string, env?: object, resume?: boolean, sessionId?: string | null, model?: string | null, teeOptions?: object, slug?: string | null, role?: string | null, maxTransientRetries?: number }} opts
254
+ */
224
255
  function startOpencodeAgent({
225
256
  prompt,
226
257
  worktree,
@@ -233,18 +264,32 @@ function startOpencodeAgent({
233
264
  role = null,
234
265
  maxTransientRetries = 1
235
266
  }) {
267
+ // Prepend the subagent-limit advisory prefix to the prompt.
268
+ // When maxParallel is unset/null/zero the prefix is empty (no-op).
269
+ const subagentPrefix = buildSubagentLimitPrefix();
270
+ const injectedPrompt = subagentPrefix + prompt;
271
+
272
+ /**
273
+ * @param {{stdout?: string, stderr?: string}} result
274
+ */
236
275
  function isStaleSessionResult(result) {
237
- if (!result) return false;
276
+ if (!result) {return false;}
238
277
  const stderr = result.stderr || '';
239
278
  const stdout = result.stdout || '';
240
279
  return stderr.includes('Session not found') || stdout.includes('Session not found');
241
280
  }
242
281
 
282
+ /**
283
+ * @param {{command: string, args: string[], options?: object}} invocation
284
+ */
243
285
  function runInvocation(invocation) {
244
286
  const spawnOptions = { ...invocation.options, ...teeOptions };
245
287
  return _spawnAndTee(invocation.command, invocation.args, spawnOptions);
246
288
  }
247
289
 
290
+ /**
291
+ * @param {{command: string, args: string[], options?: object}} invocation
292
+ */
248
293
  async function runInvocationWithRetry(invocation) {
249
294
  let attempts = 0;
250
295
  let result = await runInvocation(invocation);
@@ -252,7 +297,7 @@ function startOpencodeAgent({
252
297
  attempts += 1;
253
298
  result = await runInvocation(invocation);
254
299
  }
255
- if (result) result.transientRetries = attempts;
300
+ if (result) {result.transientRetries = attempts;}
256
301
  return result;
257
302
  }
258
303
 
@@ -260,25 +305,31 @@ function startOpencodeAgent({
260
305
  // that wasn't caught by the feature-detect). If so, retry the invocation
261
306
  // without the flag so the prompt still runs (telemetry will be lost but the
262
307
  // agent won't fail outright).
308
+ /**
309
+ * @param {{stdout?: string, stderr?: string}} result
310
+ */
263
311
  function isJsonFlagError(result) {
264
- if (!result) return false;
312
+ if (!result) {return false;}
265
313
  const text = failureText(result);
266
314
  return (/\bunrecognized option\b|\bunknown option\b|\bno such option\b|\binvalid option\b|\bunrecognized flag\b|\bunknown flag\b/i).test(text);
267
315
  }
268
316
 
317
+ /**
318
+ * @param {{stdout?: string, sessionId?: string, telemetry?: object, model?: string, provider?: string, transientRetries?: number, _jsonFallback?: boolean}} result
319
+ */
269
320
  async function processResult(result) {
270
321
  if (result && result.stdout) {
271
- result.sessionId = extractOpencodeSessionId(result.stdout);
322
+ result.sessionId = extractOpencodeSessionId(result.stdout) || undefined;
272
323
  }
273
324
  if (result && result.sessionId) {
274
325
  try {
275
326
  const exportJson = await _captureExport(result.sessionId, { worktree, env });
276
327
  if (exportJson) {
277
- const telemetry = extractOpencodeTelemetryFromExport(exportJson, model);
328
+ const telemetry = extractOpencodeTelemetryFromExport(exportJson, model || undefined);
278
329
  if (telemetry) {
279
- result.telemetry = telemetry;
280
- if (telemetry.model) result.model = telemetry.model;
281
- if (telemetry.provider) result.provider = telemetry.provider;
330
+ /** @type {any} */(result).telemetry = telemetry;
331
+ if (/** @type {any} */(telemetry).model) {/** @type {any} */(result).model = /** @type {any} */(telemetry).model;}
332
+ if (/** @type {any} */(telemetry).provider) {/** @type {any} */(result).provider = /** @type {any} */(telemetry).provider;}
282
333
  }
283
334
  }
284
335
  } catch (_) {
@@ -288,34 +339,40 @@ function startOpencodeAgent({
288
339
  return result;
289
340
  }
290
341
 
342
+ /**
343
+ * @param {{command: string, args: string[], options?: object}} invocation
344
+ */
291
345
  async function runWithJsonFallback(invocation) {
292
346
  let result = await runInvocationWithRetry(invocation);
293
347
  // Runtime fallback: if the first invocation used --format json but the
294
348
  // binary rejected it, retry without the flag so the prompt still executes.
295
349
  if (isJsonFlagError(result) && invocation.args.includes('--format')) {
296
350
  const legacyInv = buildOpencodeInvocation({
297
- prompt, worktree, env, resume, sessionId, model, preferJson: false,
351
+ prompt: injectedPrompt, worktree, env, resume, sessionId, model, preferJson: false,
298
352
  });
299
353
  result = await runInvocationWithRetry(legacyInv);
300
354
  // Mark that we fell back so callers know telemetry may be absent.
301
- if (result) result._jsonFallback = true;
355
+ if (result) {result._jsonFallback = true;}
302
356
  }
303
357
  return result;
304
358
  }
305
359
 
360
+ /**
361
+ * @param {{command: string, args: string[], options?: object}} invocation
362
+ */
306
363
  async function staleSessionHandler(invocation) {
307
364
  let result = await runWithJsonFallback(invocation);
308
365
  if (isStaleSessionResult(result) && worktree && resume) {
309
366
  try {
310
- _sessions.clearSession(worktree, slug, role);
367
+ /** @type {any} */(_sessions).clearSession(worktree, slug || '', role || '');
311
368
  } catch (_) { /* best-effort */ }
312
- const freshInv = buildOpencodeInvocation({ prompt, worktree, env, resume: false, sessionId: null, model });
369
+ const freshInv = buildOpencodeInvocation({ prompt: injectedPrompt, worktree, env, resume: false, sessionId: null, model });
313
370
  result = await runWithJsonFallback(freshInv);
314
371
  }
315
372
  return processResult(result);
316
373
  }
317
374
 
318
- const invocation = buildOpencodeInvocation({ prompt, worktree, env, resume, sessionId, model });
375
+ const invocation = buildOpencodeInvocation({ prompt: injectedPrompt, worktree, env, resume, sessionId, model });
319
376
  const resultPromise = staleSessionHandler(invocation);
320
377
 
321
378
  return { invocation, resultPromise };
@@ -17,15 +17,11 @@
17
17
  const { codexHomeRoot, extractCodexTelemetry } = require('./codex');
18
18
 
19
19
  /**
20
- * @param {object} opts
21
- * @param {string} opts.worktree - worktree root used to locate the codex home.
22
- * @param {object} opts.result - launcher result; `result.telemetry` is the
23
- * launcher-attached fallback and `result.startedAt` bounds the codex window.
24
- * @param {number} [opts.sinceMs] - epoch ms marking the start of this stage.
20
+ * @param {{ worktree: string, result: { telemetry?: { provider?: string } & object }, sinceMs?: number }} opts
25
21
  * @returns {object|null} normalized telemetry, or null when none is available.
26
22
  */
27
- function resolveStageTelemetry({ worktree, result, sinceMs = 0 } = {}) {
28
- if (!result || !result.telemetry) return null;
23
+ function resolveStageTelemetry({ worktree, result, sinceMs = 0 } = /** @type {{ worktree: string, result: { telemetry?: { provider?: string } & object }, sinceMs?: number }} */({})) {
24
+ if (!result || !result.telemetry) {return null;}
29
25
  try {
30
26
  const codexT = extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: sinceMs || 0 });
31
27
  // Guard against stale Codex rollouts bleeding into non-Codex stages: