amicus 4.4.0 → 4.5.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 (109) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +162 -0
  3. package/README.md +17 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/DISTRIBUTION.md +234 -0
  6. package/docs/ROADMAP.md +226 -0
  7. package/docs/SHIMS.md +62 -0
  8. package/docs/architecture.md +104 -0
  9. package/docs/configuration.md +395 -0
  10. package/docs/council.md +970 -0
  11. package/docs/doc-system.md +92 -0
  12. package/docs/electron-testing.md +471 -0
  13. package/docs/jsdoc-setup.md +75 -0
  14. package/docs/opencode-integration.md +114 -0
  15. package/docs/publishing.md +60 -0
  16. package/docs/schemas.md +56 -0
  17. package/docs/testing.md +589 -0
  18. package/docs/troubleshooting.md +298 -0
  19. package/docs/usage.md +849 -0
  20. package/electron/fold.js +1 -1
  21. package/electron/main.js +4 -1
  22. package/electron/setup-ui-aliases.js +6 -6
  23. package/electron/workspace-ui/live-model.js +12 -1
  24. package/electron/workspace-ui/md-lite.js +52 -8
  25. package/electron/workspace-ui/workspace-app.js +39 -17
  26. package/electron/workspace-ui/workspace-matrix.js +46 -9
  27. package/electron/workspace-ui/workspace-panels.js +88 -19
  28. package/electron/workspace-ui/workspace-render.js +17 -1
  29. package/electron/workspace-ui/workspace-verbs.js +48 -2
  30. package/package.json +8 -3
  31. package/schemas/council-run-live.schema.json +1 -1
  32. package/schemas/council-run.schema.json +34 -0
  33. package/schemas/error.schema.json +1 -1
  34. package/schemas/event.schema.json +1 -1
  35. package/schemas/pack.schema.json +30 -0
  36. package/schemas/progress.schema.json +13 -1
  37. package/schemas/run-live.schema.json +1 -1
  38. package/schemas/run.schema.json +2 -1
  39. package/schemas/spend.schema.json +52 -4
  40. package/schemas/wave-live.schema.json +1 -1
  41. package/schemas/wave.schema.json +2 -1
  42. package/skills/second-opinion/SKILL.md +5 -0
  43. package/src/cli-handlers-council-run.js +51 -8
  44. package/src/cli-handlers-pack.js +238 -0
  45. package/src/cli-handlers-run.js +36 -8
  46. package/src/cli-handlers-spend.js +20 -2
  47. package/src/cli-handlers-template.js +53 -0
  48. package/src/cli-handlers-watch.js +11 -0
  49. package/src/cli.js +68 -5
  50. package/src/council/briefings-debate.js +27 -7
  51. package/src/council/briefings-stage2.js +155 -25
  52. package/src/council/briefings.js +24 -1
  53. package/src/council/findings.js +199 -9
  54. package/src/council/parse-stage2.js +10 -2
  55. package/src/council/presets-cli.js +23 -11
  56. package/src/council/report.js +19 -8
  57. package/src/council/run-assemble.js +42 -1
  58. package/src/council/run-budget.js +64 -11
  59. package/src/council/run-chair.js +4 -1
  60. package/src/council/run-debate.js +4 -2
  61. package/src/council/run-finalize.js +102 -0
  62. package/src/council/run-launch.js +29 -1
  63. package/src/council/run-server.js +248 -0
  64. package/src/council/run-stage2.js +118 -0
  65. package/src/council/run-stages.js +134 -110
  66. package/src/council/run-state.js +40 -1
  67. package/src/council/run.js +45 -47
  68. package/src/council/tally.js +10 -0
  69. package/src/headless.js +180 -7
  70. package/src/mcp-council-run.js +108 -4
  71. package/src/mcp-server.js +203 -7
  72. package/src/mcp-tools.js +15 -5
  73. package/src/observe/council-legs.js +60 -3
  74. package/src/observe/live-doc.js +18 -1
  75. package/src/observe/watch-render.js +4 -1
  76. package/src/pack/pack-cli.js +38 -0
  77. package/src/pack/pack-forward.js +96 -0
  78. package/src/pack/pack-resolve.js +297 -0
  79. package/src/pack/pack-store.js +130 -0
  80. package/src/pack/pack-validate.js +113 -0
  81. package/src/sidecar/child-sessions.js +1 -2
  82. package/src/sidecar/fanout-leg-fallback.js +69 -21
  83. package/src/sidecar/fanout-leg.js +6 -0
  84. package/src/sidecar/fanout-signals.js +61 -0
  85. package/src/sidecar/fanout-wave-io.js +75 -0
  86. package/src/sidecar/fanout.js +82 -74
  87. package/src/sidecar/progress-fields.js +26 -4
  88. package/src/sidecar/progress.js +42 -1
  89. package/src/sidecar/session-utils.js +23 -14
  90. package/src/sidecar/start.js +5 -4
  91. package/src/sidecar/workspace-auto-open.js +69 -0
  92. package/src/sidecar/workspace-window.js +46 -1
  93. package/src/spend-query.js +17 -5
  94. package/src/template/apply.js +88 -0
  95. package/src/template/render.js +86 -0
  96. package/src/template/store.js +106 -0
  97. package/src/utils/config.js +65 -25
  98. package/src/utils/error-doc.js +5 -0
  99. package/src/utils/lifecycle.js +37 -1
  100. package/src/utils/path-fence.js +39 -1
  101. package/src/utils/pricing.js +26 -10
  102. package/src/utils/result-schema-rebuild.js +1 -0
  103. package/src/utils/result-schema.js +8 -2
  104. package/src/utils/server-setup.js +79 -1
  105. package/src/utils/spend-ledger.js +24 -3
  106. package/src/workspace/artifact-guard.js +66 -7
  107. package/src/workspace/fold-format.js +33 -4
  108. package/src/workspace/live-normalize.js +28 -15
  109. package/src/workspace/run-detail.js +13 -1
@@ -401,14 +401,57 @@ function getCouncilWithSource(name, catalog = []) {
401
401
  return { members: null, builtin: false };
402
402
  }
403
403
 
404
+ /**
405
+ * Per-member alias/catalog classification — the SAME check `resolveCouncilMembers`
406
+ * (below) uses to decide the real run path's bench, extracted so `amicus council
407
+ * show` (council/presets-cli.js) can reuse it verbatim instead of re-deriving a
408
+ * parallel (and, pre-v4.5-Wave-2, drifted) check. Each member is resolved to its
409
+ * full model id (alias → id via effective aliases; a member containing '/' is
410
+ * taken as-is) and that id checked against the cached catalog. Tri-state catalog
411
+ * rule: an EMPTY catalog (offline / never fetched) never drops anything —
412
+ * "unknown" is not "delisted" — and a local-vendor member is never dropped on
413
+ * catalog absence either way (v4.2 §4.4: a local server may simply have been off
414
+ * at the last refresh; the leg itself fails pre-flight with the actionable
415
+ * local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
416
+ * catalog that omits the resolved id is a definitive drop.
417
+ * @param {string[]} members raw council members (aliases or provider/model ids)
418
+ * @param {Array<{id:string}>} [catalog]
419
+ * @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
420
+ * `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
421
+ * callers keep working); `droppedMembers` additively pairs each with WHY.
422
+ */
423
+ function classifyCouncilMembers(members, catalog = []) {
424
+ const aliases = getEffectiveAliases();
425
+ const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
426
+ const { isLocalProvider } = require('./local-providers');
427
+ const models = [];
428
+ const dropped = [];
429
+ const droppedMembers = [];
430
+ for (const member of members) {
431
+ const id = member.includes('/') ? member : aliases[member];
432
+ if (!id) { // alias no longer resolves
433
+ dropped.push(member);
434
+ droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
435
+ continue;
436
+ }
437
+ const vendor = typeof id === 'string' ? id.split('/')[0] : '';
438
+ if (isLocalProvider(vendor)) { models.push(member); continue; }
439
+ if (known.size > 0 && !known.has(id)) { // delisted model
440
+ dropped.push(member);
441
+ droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
442
+ continue;
443
+ }
444
+ models.push(member);
445
+ }
446
+ return { models, dropped, droppedMembers };
447
+ }
448
+
404
449
  /**
405
450
  * Expand a saved council into a runnable members list, degrading gracefully.
406
- * Each member is resolved to its full model id (alias id via effective
407
- * aliases; a member containing '/' is taken as-is) and that id checked against
408
- * the cached catalog. Unresolvable aliases and delisted ids are dropped with a
409
- * warning rather than fail-fast-aborting the whole wave. The catalog check is
410
- * skipped when the catalog is empty (offline). Returns members RAW (alias or
411
- * id) — leg-time validation resolves them again.
451
+ * Unresolvable aliases and delisted ids are dropped with a warning rather than
452
+ * fail-fast-aborting the whole wave (classification: classifyCouncilMembers
453
+ * above). Returns members RAW (alias or id) leg-time validation resolves
454
+ * them again.
412
455
  *
413
456
  * Resolution order: user config (`config.councils`) is checked first; when
414
457
  * `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
@@ -416,7 +459,7 @@ function getCouncilWithSource(name, catalog = []) {
416
459
  * shadows a built-in of the same name.
417
460
  * @param {string} name
418
461
  * @param {Array<{id:string}>} [catalog]
419
- * @returns {{models:string[], dropped:string[]} | {error:string}}
462
+ * @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>} | {error:string}}
420
463
  */
421
464
  function resolveCouncilMembers(name, catalog = []) {
422
465
  const { members } = getCouncilWithSource(name, catalog);
@@ -426,23 +469,7 @@ function resolveCouncilMembers(name, catalog = []) {
426
469
  if (!Array.isArray(members) || members.length === 0) {
427
470
  return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
428
471
  }
429
- const aliases = getEffectiveAliases();
430
- const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
431
- const { isLocalProvider } = require('./local-providers');
432
- const models = [];
433
- const dropped = [];
434
- for (const member of members) {
435
- const id = member.includes('/') ? member : aliases[member];
436
- if (!id) { dropped.push(member); continue; } // alias no longer resolves
437
- const vendor = typeof id === 'string' ? id.split('/')[0] : '';
438
- // v4.2 §4.4: a local server may simply have been off at the last catalog
439
- // refresh — that is "unknown", not "delisted". Never drop a local-vendor
440
- // member on catalog absence; the leg itself fails pre-flight with the
441
- // actionable local_endpoint_unreachable error if the server is truly down.
442
- if (isLocalProvider(vendor)) { models.push(member); continue; }
443
- if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
444
- models.push(member);
445
- }
472
+ const { models, dropped, droppedMembers } = classifyCouncilMembers(members, catalog);
446
473
  if (models.length < 2) {
447
474
  return {
448
475
  error: `Council '${name}' has fewer than 2 usable members` +
@@ -450,7 +477,7 @@ function resolveCouncilMembers(name, catalog = []) {
450
477
  '. Run \'amicus setup\' to refresh it.',
451
478
  };
452
479
  }
453
- return { models, dropped };
480
+ return { models, dropped, droppedMembers };
454
481
  }
455
482
 
456
483
  /** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
@@ -503,6 +530,17 @@ function hasTierOnboarded() {
503
530
  return !!(config.routing && config.routing.tier_onboarded === true);
504
531
  }
505
532
 
533
+ /**
534
+ * v4.5 auto-open (spec §6 guard 4): the Workspace auto-opens on MCP council
535
+ * runs from Claude Code (local) unless config.workspace.autoOpen === false. Only an
536
+ * explicit false disables — absent/junk values stay ON (opt-out semantics).
537
+ * @returns {boolean}
538
+ */
539
+ function getWorkspaceAutoOpen() {
540
+ const config = loadConfig() || {};
541
+ return !(config.workspace && config.workspace.autoOpen === false);
542
+ }
543
+
506
544
  /**
507
545
  * Persist the one-time onboarding-notice flag, preserving any other routing
508
546
  * keys (prefer, tier, migration_notified). Best-effort: swallows any
@@ -562,6 +600,7 @@ module.exports = {
562
600
  getCouncils,
563
601
  getCouncil,
564
602
  getCouncilWithSource,
603
+ classifyCouncilMembers,
565
604
  resolveCouncilMembers,
566
605
  getRoutingConfig,
567
606
  resolveGatewayMode,
@@ -571,4 +610,5 @@ module.exports = {
571
610
  setCostTier,
572
611
  hasTierOnboarded,
573
612
  markTierOnboarded,
613
+ getWorkspaceAutoOpen,
574
614
  };
@@ -24,6 +24,11 @@ const ERROR_CODES = Object.freeze({
24
24
  COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
25
25
  // council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
26
26
  COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
27
+ TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', // --template name/path unresolvable at run time (v4.5 F9)
28
+ TEMPLATE_RENDER: 'TEMPLATE_RENDER', // strict render rule violated: unknown var, slot/data mismatch (v4.5 F9)
29
+ PACK_NOT_FOUND: 'PACK_NOT_FOUND', // --pack name not in packs dir / path unreadable (v4.5 B7/F5)
30
+ PACK_INVALID: 'PACK_INVALID', // pack schema/structural/seat validation failure (v4.5 B7/F5)
31
+ PACK_KIND_MISMATCH: 'PACK_KIND_MISMATCH', // e.g. a council pack passed to fanout (v4.5 B7/F5)
27
32
  });
28
33
 
29
34
  /**
@@ -41,4 +41,40 @@ function armExitWatchdog(code = 0, ms = 1500, deps = {}) {
41
41
  return t;
42
42
  }
43
43
 
44
- module.exports = { isOneShotCommand, armExitWatchdog, ONE_SHOT_COMMANDS };
44
+ /**
45
+ * An `exit` hook for armExitWatchdog that REAPS an OpenCode server this process
46
+ * is using but does not own, on its way out (v4.4.1 fix wave, finding F3).
47
+ *
48
+ * The force-exit path is the one place where "not ours to close" stops being the
49
+ * safe answer. A wave running on an injected server deliberately never closes it
50
+ * — the owner does, once, in its own finalize. But if the watchdog fires first
51
+ * the parent dies anyway, and the Go server survives it: an orphan still holding
52
+ * the OpenCode SQLite lock that the per-run shared server exists to stop
53
+ * contending on. Before the external-server seam, fanout's own close() covered
54
+ * this by accident; this restores it deliberately.
55
+ *
56
+ * SIGTERM only, and synchronous: process.exit cannot await server.close()'s
57
+ * escalation, so this sends the one signal that fits in the window and gets out
58
+ * of the way. Never signals this process, and never lets a dead/absent pid throw
59
+ * — the exit must happen regardless.
60
+ *
61
+ * @param {{goPid?: number|null}|null} server the injected server handle
62
+ * @param {{kill?: Function, exit?: Function}} [deps] test seams
63
+ * @returns {(code: number) => void}
64
+ */
65
+ function exitReaping(server, deps = {}) {
66
+ return (code) => {
67
+ // Resolved at CALL time, not creation time: this hook is built the instant a
68
+ // signal lands and invoked up to 10s later, so binding process.kill/exit
69
+ // early would freeze whatever was installed at signal time.
70
+ const kill = deps.kill || ((p, sig) => process.kill(p, sig));
71
+ const exit = deps.exit || ((c) => process.exit(c));
72
+ const pid = server && server.goPid;
73
+ if (pid && pid !== process.pid) {
74
+ try { kill(pid, 'SIGTERM'); } catch { /* already gone: nothing to reap */ }
75
+ }
76
+ exit(code);
77
+ };
78
+ }
79
+
80
+ module.exports = { isOneShotCommand, armExitWatchdog, exitReaping, ONE_SHOT_COMMANDS };
@@ -7,7 +7,10 @@
7
7
  * for truthiness by src/council/run-state.js's readPointer, so nothing
8
8
  * upstream of this check guarantees it stays inside the project).
9
9
  *
10
- * It is a LEAF: `fs` + `path` and nothing else, no require cycle possible.
10
+ * It is a LEAF: `fs` + `path` at load time and nothing else, no require cycle
11
+ * possible. (SEC-3's debug-mode diagnostic requires src/utils/logger.js lazily,
12
+ * inside the flag branch — logger.js has zero requires of its own, so even that
13
+ * cannot introduce a cycle, and nothing is loaded at all with the flag unset.)
11
14
  * That was first needed inside the v4.4 workspace layer —
12
15
  * src/workspace/artifact-guard.js requires src/workspace/run-scan.js for
13
16
  * readPointer, so if run-scan.js also required artifact-guard.js for this
@@ -30,6 +33,39 @@
30
33
  const fs = require('fs');
31
34
  const path = require('path');
32
35
 
36
+ /**
37
+ * ⚠️ SEC-3: isRealpathContained is a pure string-prefix comparison, and it is only SOUND
38
+ * if both arguments were already resolved through realpathSync. Nothing enforced that, so
39
+ * a caller who forgot got a silently WEAKER check rather than an error — a raw
40
+ * (unresolved) path containing a symlink can prefix-match a directory it does not
41
+ * physically live under.
42
+ *
43
+ * This is a DIAGNOSTIC, opt-in via `AMICUS_DEBUG_FENCE=1`, and it MUST NEVER THROW: the
44
+ * fence must never become the failure it exists to prevent. Every step is wrapped, and a
45
+ * non-existent path is a legitimate argument here (callers fence paths before probing
46
+ * them), so it is not reported. The logger is required lazily so this module keeps its
47
+ * `fs` + `path` load-time surface (see the header) for the 99.99% of calls that run with
48
+ * the flag unset.
49
+ * @param {string} dir already-stringified dirRealPath
50
+ * @param {string} target already-stringified targetRealPath
51
+ */
52
+ function assertResolvedArgs(dir, target) {
53
+ try {
54
+ const { logger } = require('./logger');
55
+ for (const [name, p] of [['dirRealPath', dir], ['targetRealPath', target]]) {
56
+ if (!path.isAbsolute(p)) {
57
+ logger.warn('path-fence: argument is not absolute', { arg: name, value: p });
58
+ continue;
59
+ }
60
+ try {
61
+ if (fs.existsSync(p) && fs.realpathSync(p) !== p) {
62
+ logger.warn('path-fence: argument is not realpath-resolved', { arg: name, value: p });
63
+ }
64
+ } catch { /* unreadable / dangling: not this diagnostic's business */ }
65
+ }
66
+ } catch { /* a broken logger must never break the fence */ }
67
+ }
68
+
33
69
  /**
34
70
  * True when `targetRealPath` is exactly `dirRealPath` or a proper descendant
35
71
  * of it. Both arguments MUST already be resolved through realpathSync — this
@@ -41,6 +77,8 @@ const path = require('path');
41
77
  function isRealpathContained(dirRealPath, targetRealPath) {
42
78
  const dir = String(dirRealPath);
43
79
  const target = String(targetRealPath);
80
+ // SEC-3: debug-mode contract check only — never a gate, never a throw (see above).
81
+ if (process.env.AMICUS_DEBUG_FENCE === '1') { assertResolvedArgs(dir, target); }
44
82
  if (target === dir) { return true; }
45
83
  // ⚠️ COUNCIL REVIEW R2 (A6): when dirRealPath IS a filesystem root, it already
46
84
  // ends in a separator ('/' on POSIX, 'C:\\' on Windows) — blindly appending
@@ -63,24 +63,37 @@ function lookupPricing(modelId) {
63
63
  }
64
64
 
65
65
  /**
66
- * Did we actually OBSERVE any token usage for this leg? (v4.4 B2)
66
+ * Did we actually OBSERVE tokens THIS MODULE'S ESTIMATE CAN PRICE? (v4.4 B2,
67
+ * narrowed by v4.4.1 CA-7)
67
68
  *
68
69
  * This is the predicate that separates "the provider billed us for a $0 tier"
69
70
  * from "we never saw a usage payload at all" — a distinction the old
70
71
  * `pricing && tokens` guard could not make, because it only inspected the
71
- * PRICE. Accepts both the normalized totals shape (cacheRead/cacheWrite, as
72
- * produced by sumPerMessageUsage) and OpenCode's raw per-message shape
73
- * (`cache: {read, write}`), so callers holding either can ask one question.
72
+ * PRICE. It reads the normalized totals shape (input/output, as produced by
73
+ * sumPerMessageUsage) and OpenCode's raw per-message shape alike, because both
74
+ * carry `input`/`output` at the top level.
75
+ *
76
+ * ⚠️ CA-7: deliberately narrower than "did we see any token count at all". The
77
+ * estimate in resolveLegCost prices input/output ONLY, so accepting
78
+ * cacheRead/cacheWrite — and `reasoning`, which this predicate also used to
79
+ * accept and which the estimate likewise never prices — let a leg observed with
80
+ * none of input/output pass the observation gate and resolve to `estimated $0`:
81
+ * the same false-zero class the v4.4 observed-tokens fix exists to kill, in the
82
+ * one corner its predicate did not cover. Such a leg is now `unknown`, which is
83
+ * true, rather than free, which is not. Pricing cache or reasoning tokens
84
+ * properly needs catalog fields that may not exist; when they do, widen this and
85
+ * the estimate together, never one alone.
86
+ *
87
+ * Live-validated 2026-07-26: a real local leg reports `input: 25953, output: 3`,
88
+ * so the shipped v4.2 free-local `$0` promise (a local seat costs nothing but
89
+ * still reports real input/output) is untouched — tests/pricing-local.test.js
90
+ * is the standing guard on that.
74
91
  * @param {object|null|undefined} tokens
75
92
  * @returns {boolean}
76
93
  */
77
94
  function hasObservedTokens(tokens) {
78
95
  if (!tokens || typeof tokens !== 'object') { return false; }
79
- const cache = tokens.cache && typeof tokens.cache === 'object' ? tokens.cache : {};
80
- const cacheRead = tokens.cacheRead || cache.read || 0;
81
- const cacheWrite = tokens.cacheWrite || cache.write || 0;
82
- return (tokens.input || 0) > 0 || (tokens.output || 0) > 0
83
- || (tokens.reasoning || 0) > 0 || cacheRead > 0 || cacheWrite > 0;
96
+ return (tokens.input || 0) > 0 || (tokens.output || 0) > 0;
84
97
  }
85
98
 
86
99
  /** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
@@ -96,7 +109,10 @@ function resolveLegCost({ reportedCost, tokens, pricing }) {
96
109
  // The v4.2 §4.5 free-local-tier carve-out is DELIBERATELY preserved: a local
97
110
  // Ollama/LM Studio seat legitimately costs $0 but still reports tokens, so
98
111
  // hasObservedTokens() is true for it and it still resolves to `estimated $0`.
99
- // Only a leg where we observed no tokens at all falls through to `unknown`.
112
+ // v4.4.1 CA-7: a leg with no input/output falls through to `unknown` even when
113
+ // it reported cache or reasoning tokens — those are real observations, but not
114
+ // ones THIS estimate can turn into a price, so calling the result $0 would be
115
+ // the same fabrication one corner over. See hasObservedTokens above.
100
116
  if (pricing && hasObservedTokens(tokens)) {
101
117
  const est = (tokens.input || 0) * pricing.prompt + (tokens.output || 0) * pricing.completion;
102
118
  if (est >= 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
@@ -90,6 +90,7 @@ function buildWaveResultFromSession(project, waveId) {
90
90
  waveId,
91
91
  legs,
92
92
  promptMeta: meta.promptMeta || null,
93
+ ...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
93
94
  createdAt: meta.createdAt || null,
94
95
  completedAt: meta.completedAt || null,
95
96
  });
@@ -44,7 +44,9 @@ function durationBetween(createdAt, completedAt) {
44
44
  * @param {string|null} [opts.modelInput] - What the caller typed (alias), if known
45
45
  * @param {string|null} [opts.sessionDir]
46
46
  * @param {string|null} [opts.waveId] - Explicit wave id (falls back to metadata.parentWave)
47
- * @returns {object} run document
47
+ * @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
48
+ * metadata.pack was recorded (solo session launched via --pack), sourced straight off
49
+ * `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
48
50
  */
49
51
  function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
50
52
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
@@ -68,6 +70,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
68
70
  sessionDir,
69
71
  opencodeSessionId: metadata.opencodeSessionId || null,
70
72
  usage: usage !== null ? usage : (metadata.usage || null),
73
+ ...(metadata.pack ? { pack: metadata.pack } : {}),
71
74
  };
72
75
  }
73
76
 
@@ -122,9 +125,11 @@ function waveExitCode(waveStatus) {
122
125
  * @param {string|null} [opts.completedAt]
123
126
  * @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
124
127
  * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
128
+ * @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
129
+ * additive — present only when the wave was launched via --pack (absent, never null, otherwise).
125
130
  * @returns {object} wave document
126
131
  */
127
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
132
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
128
133
  const { sumWaveUsage } = require('./pricing');
129
134
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
130
135
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -151,6 +156,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
151
156
  durationMs,
152
157
  usage: sumWaveUsage(legs),
153
158
  notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
159
+ ...(pack ? { pack } : {}),
154
160
  };
155
161
  }
156
162
 
@@ -75,10 +75,88 @@ function ensurePortAvailable(port = DEFAULT_PORT) {
75
75
  return false;
76
76
  }
77
77
 
78
+ /**
79
+ * A start failure that is a LOCK RACE, not a deterministic error.
80
+ *
81
+ * OpenCode opens one shared SQLite database (~/.local/share/opencode/opencode.db)
82
+ * at startup, so two servers starting in the same instant can collide on it and
83
+ * the loser exits 1 with `database is locked`. Measured: council run v441plan01
84
+ * lost four of five seats in 736ms to exactly this.
85
+ *
86
+ * Deliberately NARROW. A missing binary, a bad key, a busy port and a config
87
+ * error are all deterministic — retrying them only triples the latency before
88
+ * the same failure, so they must fall straight through untouched.
89
+ */
90
+ const LOCK_CLASS_START_FAILURE = /database is locked|database table is locked|SQLITE_BUSY/i;
91
+
92
+ /**
93
+ * Backoff between start attempts; 5 attempts total, ≤3.75s of added latency.
94
+ *
95
+ * ⚠️ WIDENED from 3 attempts at 250/500ms (≤750ms) by v4.4.1 Step 10.5. 750ms is
96
+ * thin against a multi-megabyte WAL and any concurrent process touching the same
97
+ * OpenCode database: run v441plan02 exhausted it, the council degraded to one
98
+ * server per wave, and then lost four of five seats to the very race the retry
99
+ * exists to survive. The wait is bounded and paid at most once per acquisition;
100
+ * the cost of not waiting is a dead bench.
101
+ *
102
+ * Still lock-class ONLY — a deterministic failure (missing binary, bad key, busy
103
+ * port, config error) never sleeps a single millisecond here.
104
+ */
105
+ const LOCK_RETRY_DELAYS_MS = [250, 500, 1000, 2000];
106
+
107
+ /**
108
+ * @param {Error|null} error
109
+ * @returns {boolean} true only for a lock-class (retryable) start failure
110
+ */
111
+ function isLockClassStartFailure(error) {
112
+ if (!error) { return false; }
113
+ // The real failure arrives as a message with the server's own stdout inlined
114
+ // ("Server exited with code 1 / Server output: … database is locked"), but
115
+ // check the usual carriers too so a wrapped/spawn-shaped error still matches.
116
+ const carriers = [error.message, error.stderr, error.stdout, error.cause && error.cause.message];
117
+ return carriers.some(c => typeof c === 'string' && LOCK_CLASS_START_FAILURE.test(c));
118
+ }
119
+
120
+ /**
121
+ * Run `attempt` with a BOUNDED retry on a lock-class failure only.
122
+ *
123
+ * Never fails closed: the final failure is rethrown unchanged, so every caller
124
+ * that already degrades on a start failure (runFanout writes an error wave; a
125
+ * council run falls back to per-wave servers) degrades exactly as before —
126
+ * just later, and far less often. This is the half of the fix that covers what
127
+ * a per-run shared server cannot: two separate `amicus` processes, or a CLI run
128
+ * beside a live MCP server, still contend for the same database.
129
+ *
130
+ * @param {(attempt: number) => Promise<T>} attempt
131
+ * @param {{retryDelayMs?: number}} [opts] retryDelayMs: test seam — collapses
132
+ * every backoff to this value so a retry test does not sleep for real.
133
+ * @returns {Promise<T>}
134
+ * @template T
135
+ */
136
+ async function retryOnLockRace(attempt, opts = {}) {
137
+ const delays = opts.retryDelayMs === undefined
138
+ ? LOCK_RETRY_DELAYS_MS
139
+ : LOCK_RETRY_DELAYS_MS.map(() => opts.retryDelayMs);
140
+ for (let i = 0; ; i += 1) {
141
+ try {
142
+ return await attempt(i);
143
+ } catch (error) {
144
+ if (i >= delays.length || !isLockClassStartFailure(error)) { throw error; }
145
+ logger.warn('OpenCode server start lost a lock race — retrying', {
146
+ attempt: i + 1, of: delays.length + 1, delayMs: delays[i], error: error.message,
147
+ });
148
+ await new Promise(resolve => setTimeout(resolve, delays[i]));
149
+ }
150
+ }
151
+ }
152
+
78
153
  module.exports = {
79
154
  DEFAULT_PORT,
155
+ LOCK_RETRY_DELAYS_MS,
80
156
  isPortInUse,
81
157
  getPortPid,
82
158
  killPortProcess,
83
- ensurePortAvailable
159
+ ensurePortAvailable,
160
+ isLockClassStartFailure,
161
+ retryOnLockRace
84
162
  };
@@ -45,7 +45,10 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
45
45
  * @param {string} [opts.waveId] present for a fanout leg
46
46
  * @param {string} opts.model resolved model id (or alias, if that's all the caller has)
47
47
  * @param {'headless'|'interactive'|'leg'} opts.mode
48
- * @param {{tokens:object, cost:{amount:number|null,currency:string,source:string}}|null} opts.usage
48
+ * @param {{tokens:object, cost:{amount:number|null,currency:string,source:string},
49
+ * subtreeUnknown?:boolean}|null} opts.usage `subtreeUnknown` (v4.4.1 CA-2) is
50
+ * copied onto the row when truthy — this leg's own cost resolved, but a child
51
+ * session it spawned could not be priced, so the row's `cost` is a FLOOR
49
52
  * @param {string} [opts.op] 'leg' | 'start' | 'continue' | 'resume'
50
53
  * @param {string} [opts.status] terminal status
51
54
  * @param {string} [opts.councilRunId] council run id (additive attribution)
@@ -86,18 +89,36 @@ function appendSpend({ taskId, waveId, model, mode, usage,
86
89
  if (attempt !== undefined) { row.attempt = attempt; }
87
90
  if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
88
91
  if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
92
+ // v4.4.1 CA-2: a leg whose OWN cost is known but which spawned a child
93
+ // session the walk could not price writes a PRICED row — so `unpricedRows`
94
+ // never catches it and `amicus spend` reads as a complete measurement while
95
+ // `council run` says `costExact:false` about the same dollars. Omitted (not
96
+ // `|| false`) so a pre-4.4.1 row and an ordinary row stay identical, matching
97
+ // the linkage-field convention above.
98
+ if (usage.subtreeUnknown) { row.subtreeUnknown = true; }
89
99
  fs.appendFileSync(path.join(dir, SPEND_LEDGER_FILE), JSON.stringify(row) + '\n');
90
100
  } catch (e) {
91
101
  logger.debug('spend-ledger append failed (best-effort, run unaffected)', { taskId, error: e.message });
92
102
  }
93
103
  }
94
104
 
95
- /** @param {string} [dir] @returns {Array<object>} parsed rows; corrupt lines skipped */
105
+ /**
106
+ * Read the ledger. Corrupt lines are skipped — and v4.4.1 A2 widens "corrupt"
107
+ * from "does not parse" to "does not parse AS A ROW". A line that is valid JSON
108
+ * but not a plain object (`"foo"`, `42`, `[1,2]`) used to survive `filter(Boolean)`
109
+ * and be treated as a row by every consumer: `aggregateSpend` counted it in
110
+ * `runs`, scored it into `unpricedRows`/`sourceMix.unknown` off its absent cost
111
+ * block, and `--rows` echoed it into a published document that says rows are
112
+ * objects. A scalar in a JSONL ledger of row objects is a corrupt line, so it is
113
+ * now dropped like any other — one fewer way the totals can be inflated by damage.
114
+ * @param {string} [dir] @returns {Array<object>} parsed rows; corrupt lines skipped
115
+ */
96
116
  function readSpendRows(dir) {
97
117
  const file = path.join(dir || getConfigDir(), SPEND_LEDGER_FILE);
98
118
  if (!fs.existsSync(file)) { return []; }
99
119
  return fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean)
100
- .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
120
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
121
+ .filter(r => r !== null && typeof r === 'object' && !Array.isArray(r));
101
122
  }
102
123
 
103
124
  module.exports = { appendSpend, readSpendRows, SPEND_LEDGER_FILE, SPEND_LEDGER_SCHEMA_VERSION };
@@ -24,6 +24,9 @@ const FIXED_ARTIFACTS = Object.freeze(['briefing-stage1.md', 'bundle-stage2.md',
24
24
  // `artifact not allowed: <name>`. Writers: tally-provisional.json = src/council/run.js:199;
25
25
  // revote-bundle.md = run-debate.js:119; debate.json = run-debate.js:261; the per-seat
26
26
  // rebuttal-/revote- pair = materializeDebate (run-launch.js:127-136).
27
+ // ⚠️ FIVE KINDS, THREE ENTRIES — that is not a miscount (v4.4.1 DOC-7, re-verified). This const
28
+ // holds only the three RUN-LEVEL names; the last two of the five, the rebuttal-/revote- pair, are
29
+ // per BENCH MODEL and are appended inside artifactAllowlist below, next to review-/judge-.
27
30
  const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
28
31
  const MAX_ARTIFACT_BYTES = 200 * 1024;
29
32
 
@@ -93,14 +96,37 @@ function artifactAllowlist(run) {
93
96
  }
94
97
  }
95
98
 
99
+ // ⚠️ Task 18 (RN-1): the collision above is a real run-integrity defect — the run directory
100
+ // physically holds ONE file where two models' artifacts should be, and no renderer trick can
101
+ // recover both. What the renderer CAN stop doing is showing model A's prose under model B's
102
+ // name. Deterministic disambiguation: per colliding sanitized name, sort the RAW models
103
+ // (sorting, not insertion order, is what keeps this reproducible across processes/runs); the
104
+ // first (sorted) keeps the bare sanitized name, the rest get `~2`, `~3`, ... The suffixed
105
+ // names deliberately do not exist on disk — the presence manifest (run-detail.js, via
106
+ // fs.statSync over this same allowlist) marks them absent, so the renderer shows the honest
107
+ // "not written yet" empty state for every model but the first, instead of cross-matching.
108
+ const nameFor = new Map(); // raw model -> its (possibly suffixed) sanitized name
96
109
  for (const m of uniqueModels) {
97
- names.push(`review-${sanitizeName(m)}.md`);
98
- names.push(`judge-${sanitizeName(m)}.md`);
99
- // rebuttal-/revote- are keyed on the same BENCH ALIAS through the same sanitizeName
100
- // (materializeDebate is called with `d.raiser` / the revote leg's model — both aliases).
110
+ let s = sanitizeName(m);
111
+ const collision = collisionModels.get(s);
112
+ if (collision) {
113
+ const sortedRaw = [...collision].sort();
114
+ const index = sortedRaw.indexOf(m);
115
+ if (index > 0) { s = `${s}~${index + 1}`; }
116
+ }
117
+ nameFor.set(m, s);
118
+ }
119
+
120
+ for (const m of uniqueModels) {
121
+ const s = nameFor.get(m);
122
+ names.push(`review-${s}.md`);
123
+ names.push(`judge-${s}.md`);
124
+ // rebuttal-/revote- are keyed on the same BENCH ALIAS through the same (now possibly
125
+ // suffixed) name — materializeDebate is called with `d.raiser` / the revote leg's model
126
+ // (both aliases), so a colliding pair's debate artifacts are disambiguated the same way.
101
127
  if (debated) {
102
- names.push(`rebuttal-${sanitizeName(m)}.md`);
103
- names.push(`revote-${sanitizeName(m)}.md`);
128
+ names.push(`rebuttal-${s}.md`);
129
+ names.push(`revote-${s}.md`);
104
130
  }
105
131
  }
106
132
  // `uniqueModels` already collapsed genuinely-repeated bench entries, so this final Set is
@@ -112,6 +138,21 @@ function artifactAllowlist(run) {
112
138
  sanitized, models: [...models],
113
139
  }));
114
140
  }
141
+ // Consumed by workspace-panels.js (wireLazyPanels' file lists + drillIntoJudge's artifact
142
+ // lookup), which prefers this map over re-deriving names via sanitizeName(model) directly —
143
+ // that re-derivation is exactly what would ignore the suffixing above and misattribute prose.
144
+ // ⚠️ Fix-wave (review finding 1) residual limit this map cannot close: the BARE (unsuffixed)
145
+ // name is still exactly ONE physical file on disk, and its actual bytes belong to whichever
146
+ // colliding model's writer ran LAST — no map can recover which one that was. The guarantee
147
+ // delivered here is narrower than "attribution is fully sound": at most the sorted-first
148
+ // model can still be misattributed under the bare name; artifactCollisions (the run-integrity
149
+ // banner rendered by workspace-app.js's renderBanners) is what covers that residual case.
150
+ list.artifactsByModel = Object.fromEntries(
151
+ [...nameFor].map(([m, s]) => [m, {
152
+ review: `review-${s}.md`, judge: `judge-${s}.md`,
153
+ rebuttal: `rebuttal-${s}.md`, revote: `revote-${s}.md`,
154
+ }]),
155
+ );
115
156
  return list;
116
157
  }
117
158
 
@@ -167,7 +208,25 @@ function readRunArtifact(project, runId, name, deps = {}) {
167
208
 
168
209
  let realTarget;
169
210
  try { realTarget = realpathSync(path.join(ptr.runDir, name)); }
170
- catch { return { error: `not written yet: ${name}` }; }
211
+ catch (err) {
212
+ // ⚠️ v4.4.1 RN-10: this catch used to answer `not written yet: <name>` for ANY realpath
213
+ // failure — ENOENT, EACCES, EPERM, EIO, ELOOP, a dangling symlink — so a permission problem
214
+ // was indistinguishable from a file the council simply has not produced yet. That is not a
215
+ // cosmetic conflation: electron/ipc-workspace.js's workspace:fold reads chair-output.md
216
+ // through this function, and on a permission error it produced a silent CHAIRLESS fold that
217
+ // still reported {ok: true}. The logger.warn it now emits was the mitigation — but it logged
218
+ // this string, so the log said "not written yet" about a file that was right there.
219
+ //
220
+ // ⚠️ Keep the sanitization. Do NOT re-interpolate `err.message`: a realpath failure's message
221
+ // embeds the full resolved path it tried to open, which round 4 deliberately stopped handing
222
+ // back over IPC (see the run.json catch above). `err.code` is a bare symbolic errno with no
223
+ // path in it, and it is the one piece an operator reading the fold warning actually needs —
224
+ // whitelisted to the errno character class so nothing else can ever ride out through here.
225
+ const code = err && typeof err.code === 'string' && /^[A-Z][A-Z0-9_]{1,15}$/.test(err.code)
226
+ ? err.code : 'unknown';
227
+ if (code === 'ENOENT') { return { error: `not written yet: ${name}` }; }
228
+ return { error: `artifact unreadable (${code}): ${name}` };
229
+ }
171
230
  if (!isRealpathContained(realDir, realTarget)) {
172
231
  return { error: 'artifact escapes run directory' };
173
232
  }