amicus 4.3.0 → 4.4.1

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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -62,33 +62,136 @@ function lookupPricing(modelId) {
62
62
  return null;
63
63
  }
64
64
 
65
+ /**
66
+ * Did we actually OBSERVE tokens THIS MODULE'S ESTIMATE CAN PRICE? (v4.4 B2,
67
+ * narrowed by v4.4.1 CA-7)
68
+ *
69
+ * This is the predicate that separates "the provider billed us for a $0 tier"
70
+ * from "we never saw a usage payload at all" — a distinction the old
71
+ * `pricing && tokens` guard could not make, because it only inspected the
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.
91
+ * @param {object|null|undefined} tokens
92
+ * @returns {boolean}
93
+ */
94
+ function hasObservedTokens(tokens) {
95
+ if (!tokens || typeof tokens !== 'object') { return false; }
96
+ return (tokens.input || 0) > 0 || (tokens.output || 0) > 0;
97
+ }
98
+
65
99
  /** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
66
100
  function resolveLegCost({ reportedCost, tokens, pricing }) {
67
101
  if (typeof reportedCost === 'number' && reportedCost > 0) {
68
102
  return { amount: reportedCost, currency: 'USD', source: 'reported' };
69
103
  }
70
- if (pricing && tokens) {
104
+ // v4.4 B2: an all-zero token total is an ABSENCE OF OBSERVATION, not a $0
105
+ // bill. Pricing it as `0 × catalog = estimated $0` asserts "this seat was
106
+ // free" — a claim we cannot support, and one that silently under-counted the
107
+ // --max-cost ceiling on real paid runs (diagnosis §0: council-wsgate02 spent
108
+ // $0.9859 against a $0.75 ceiling while spent() believed $0.3720).
109
+ // The v4.2 §4.5 free-local-tier carve-out is DELIBERATELY preserved: a local
110
+ // Ollama/LM Studio seat legitimately costs $0 but still reports tokens, so
111
+ // hasObservedTokens() is true for it and it still resolves to `estimated $0`.
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.
116
+ if (pricing && hasObservedTokens(tokens)) {
71
117
  const est = (tokens.input || 0) * pricing.prompt + (tokens.output || 0) * pricing.completion;
72
- // v4.2 §4.5: a genuine $0 estimate is a REAL priced tier (not unknown/null).
73
118
  if (est >= 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
74
119
  }
75
120
  return { amount: null, currency: 'USD', source: 'unknown' };
76
121
  }
77
122
 
78
- /** Resolve a single run/leg's final usage block from raw totals + the model id. */
79
- function resolveUsage({ model, usageTotals }) {
123
+ /**
124
+ * Resolve a single run/leg's final usage block from raw totals + the model id.
125
+ *
126
+ * @param {object} opts
127
+ * @param {string} opts.model full route id
128
+ * @param {object} opts.usageTotals sumPerMessageUsage output
129
+ * @param {boolean} [opts.subtreeUnknown] v4.4 Task 2 — this leg made a SUBAGENT
130
+ * (`task`) tool call whose CHILD OpenCode session is billed separately, is NOT
131
+ * rolled into the parent session's cost, and could NOT be enumerated (or could
132
+ * only be walked in part). The leg's OWN cost may be perfectly `reported`; what
133
+ * is unknown is its SUBTREE. Kept as a distinct concept from an unpriced leg
134
+ * (which means "we observed no tokens at all") because they are different
135
+ * statements and a reader must be able to tell them apart. Set only when true
136
+ * so an ordinary leg's usage block stays byte-identical.
137
+ * @param {{sessions: number, tokens: object, costReported: number}} [opts.subtree]
138
+ * v4.4.1 CA-1 — the child sessions that WERE enumerated and priced
139
+ * (src/sidecar/child-sessions.js). Attached beside the leg's own cost rather
140
+ * than folded into it: `cost` keeps meaning "this leg's own session", which is
141
+ * what every existing reader already assumes, and the subtree is a separate
142
+ * measurement that the wave rollup adds on top. Folding them together would
143
+ * also let a measured child amount launder an UNKNOWN parent into a
144
+ * `reported`-looking number — `council-wsgate02/wsgate02-s1-3` is exactly that
145
+ * shape (own cost unknown, child session $0.471046).
146
+ */
147
+ function resolveUsage({ model, usageTotals, subtreeUnknown, subtree }) {
80
148
  const totals = usageTotals || emptyUsageTotals();
81
149
  const cost = resolveLegCost({ reportedCost: totals.costReported, tokens: totals.tokens, pricing: lookupPricing(model) });
82
- return { tokens: totals.tokens, cost };
150
+ const usage = { tokens: totals.tokens, cost };
151
+ if (subtree && subtree.sessions > 0) {
152
+ const amount = subtree.costReported;
153
+ usage.subtree = {
154
+ sessions: subtree.sessions,
155
+ tokens: subtree.tokens || emptyUsageTotals().tokens,
156
+ // A child session's price comes from OpenCode's own billing, never from a
157
+ // catalog estimate (the SDK's Session record carries no model id, so an
158
+ // estimate would be a guess). Children that exist but reported nothing are
159
+ // `unknown` — the B2 rule one level down, and never a $0 claim.
160
+ cost: amount > 0
161
+ ? { amount, currency: 'USD', source: 'reported' }
162
+ : { amount: null, currency: 'USD', source: 'unknown' },
163
+ };
164
+ }
165
+ if (subtreeUnknown) { usage.subtreeUnknown = true; }
166
+ return usage;
83
167
  }
84
168
 
85
- /** Aggregate leg usage into a wave-level usage block. Legs without usage count as unpriced. */
169
+ /**
170
+ * Aggregate leg usage into a wave-level usage block. Legs without usage count as
171
+ * unpriced; legs carrying `subtreeUnknown` are counted separately in
172
+ * `subtreeUnknownLegs` — a fully-priced wave can still have an incomplete total.
173
+ *
174
+ * v4.4.1 CA-1: a leg's enumerated CHILD-session spend (`usage.subtree`) is added
175
+ * to the wave total here, and reported separately as `subtreeCost` /
176
+ * `subtreeSessions` so a reader can always see how much of the total came from
177
+ * subagents. It is added INDEPENDENTLY of whether the leg's own cost resolved —
178
+ * they are two different measurements, and dropping a measured child amount
179
+ * because its parent is unknown would be a second under-report on top of the
180
+ * one this whole change exists to close.
181
+ */
86
182
  function sumWaveUsage(legs) {
87
183
  const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
88
184
  let amount = 0; let anyAmount = false;
89
- let reportedLegs = 0, estimatedLegs = 0, unpricedLegs = 0;
185
+ let reportedLegs = 0, estimatedLegs = 0, unpricedLegs = 0, subtreeUnknownLegs = 0;
186
+ let subtreeCost = 0, subtreeSessions = 0;
90
187
  for (const leg of legs) {
91
188
  const u = leg && leg.usage;
189
+ if (u && u.subtreeUnknown) { subtreeUnknownLegs++; }
190
+ const st = u && u.subtree;
191
+ if (st && st.cost && typeof st.cost.amount === 'number') {
192
+ amount += st.cost.amount; anyAmount = true;
193
+ subtreeCost += st.cost.amount; subtreeSessions += st.sessions || 0;
194
+ }
92
195
  if (!u || !u.cost) { unpricedLegs++; continue; }
93
196
  for (const k of Object.keys(tokens)) { tokens[k] += (u.tokens && u.tokens[k]) || 0; }
94
197
  if (typeof u.cost.amount === 'number') { amount += u.cost.amount; anyAmount = true; }
@@ -101,7 +204,8 @@ function sumWaveUsage(legs) {
101
204
  else if (estimatedLegs > 0 && reportedLegs === 0 && unpricedLegs === 0) { source = 'estimated'; }
102
205
  else if (reportedLegs === 0 && estimatedLegs === 0) { source = 'unknown'; }
103
206
  else { source = 'mixed'; }
104
- return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source, reportedLegs, estimatedLegs, unpricedLegs } };
207
+ return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source,
208
+ reportedLegs, estimatedLegs, unpricedLegs, subtreeUnknownLegs, subtreeCost, subtreeSessions } };
105
209
  }
106
210
 
107
211
  /**
@@ -119,4 +223,5 @@ function formatCost(cost) {
119
223
  return (cost.source === 'estimated' || cost.source === 'mixed') ? `~${dollars}` : dollars;
120
224
  }
121
225
 
122
- module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage, formatCost };
226
+ module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, hasObservedTokens,
227
+ resolveLegCost, resolveUsage, sumWaveUsage, formatCost };
@@ -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 };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Council Workspace — artifact read guard (v4.4 §4.5 workspace:read-artifact).
3
+ *
4
+ * Two independent fences:
5
+ * 1. The name must be on the manifest-derived allowlist (fixed names +
6
+ * review-/judge- files for run.json bench seats via v4.0's sanitizeName,
7
+ * which strips every path separator) — traversal is unrepresentable.
8
+ * 2. The realpath of the resolved file must stay inside the realpath of the
9
+ * run dir — a symlinked artifact cannot leak files from outside.
10
+ * >200 KB truncates with a flag (spec §4.5). report.html is deliberately NOT
11
+ * readable here — it opens externally via workspace:open-report.
12
+ */
13
+ 'use strict';
14
+
15
+ const fsReal = require('fs');
16
+ const path = require('path');
17
+ const { sanitizeName } = require('../council/run-launch');
18
+ const { readPointer } = require('./run-scan');
19
+ const { isRealpathContained } = require('../utils/path-fence');
20
+
21
+ const FIXED_ARTIFACTS = Object.freeze(['briefing-stage1.md', 'bundle-stage2.md', 'chair-packet.md', 'chair-output.md', 'tally-input.json']);
22
+ // ⚠️ DE-ROT (F28): v4.1's debate stage writes five MORE run-dir artifact kinds the original
23
+ // allowlist never named, so the Workspace hard-refused every `--debate` output with
24
+ // `artifact not allowed: <name>`. Writers: tally-provisional.json = src/council/run.js:199;
25
+ // revote-bundle.md = run-debate.js:119; debate.json = run-debate.js:261; the per-seat
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-.
30
+ const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
31
+ const MAX_ARTIFACT_BYTES = 200 * 1024;
32
+
33
+ // isRealpathContained itself now lives in ../utils/path-fence.js (the shared "fence 2"
34
+ // primitive: a realpath-based containment test that defeats symlink escapes AND
35
+ // tampered/stale pointers). Re-exported below for backward compatibility — callers
36
+ // outside this module (electron/ipc-workspace.js's workspace:open-report, this
37
+ // file's own tests, and as of the third council-review pass src/workspace/run-detail.js
38
+ // and src/workspace/run-scan.js) all reuse the exact same check rather than
39
+ // re-implementing it. It could not stay defined here: run-scan.js needs it too, and
40
+ // this file already requires run-scan.js for readPointer, so a shared leaf module
41
+ // (no workspace/* deps of its own) is what keeps that from becoming a require cycle.
42
+
43
+ /**
44
+ * Trim a buffer to at most `max` bytes without splitting a multi-byte UTF-8 character.
45
+ * A plain `buf.subarray(0, max)` can land mid-sequence — the tail bytes then decode as
46
+ * U+FFFD replacement characters, which can even push the encoded string back over `max`.
47
+ * Walks back over trailing continuation bytes (10xxxxxx) until it finds a clean boundary,
48
+ * dropping the whole partial character rather than emitting mojibake.
49
+ */
50
+ function truncateUtf8(buf, max) {
51
+ let end = max;
52
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) { end -= 1; }
53
+ return buf.subarray(0, end);
54
+ }
55
+
56
+ /**
57
+ * @param {object} run parsed run.json (may be partial)
58
+ * @returns {string[]} the allowlist. When two or more DISTINCT bench entries sanitize to the
59
+ * same artifact name, a non-enumerable-in-spirit (but plain, test-visible) `collisions`
60
+ * array is attached: `[{sanitized, models: [rawA, rawB, ...]}, ...]`. See the R4
61
+ * council-review note below for why this is surfaced rather than silently deduped.
62
+ */
63
+ function artifactAllowlist(run) {
64
+ const names = [...FIXED_ARTIFACTS];
65
+ const bench = run && Array.isArray(run.bench) ? run.bench : [];
66
+ // ⚠️ DE-ROT (F28): run.json carries a `debate` key ONLY on --debate runs, and it is seeded
67
+ // on the FIRST write (src/council/run.js:74-77), so this gate is safe and keeps the
68
+ // allowlist tight for the common case.
69
+ const debated = !!(run && run.debate);
70
+ if (debated) { names.push(...DEBATE_ARTIFACTS); }
71
+
72
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): sanitizeName is NOT
73
+ // injective — it maps every character outside [a-zA-Z0-9._-] to '-', so two DISTINCT bench
74
+ // entries ('vendor/a', 'vendor?a') both produce 'vendor-a'. Both models would then request
75
+ // the SAME artifact file, and the renderer's `[data-artifact="..."]` lookup (drillIntoJudge)
76
+ // hands back whichever section matches first — prose silently misattributed to the wrong
77
+ // model. That is a run-integrity defect (this run directory genuinely cannot hold both
78
+ // models' review/judge files under distinct names), not a display quirk, so it must be
79
+ // DETECTED and surfaced, never smoothed away by deduping the resulting name list.
80
+ //
81
+ // A bench with genuinely REPEATED identical entries (['gemini', 'gemini']) is a different,
82
+ // harmless case that must keep collapsing to one set of rows (preserved intent) — collapse
83
+ // those via a Set over the RAW bench values FIRST, so identical entries never even reach
84
+ // the collision check below (only entries that are distinct as raw strings but coincide
85
+ // after sanitizeName count as a collision).
86
+ const uniqueModels = [...new Set(bench)];
87
+ const rawBySanitized = new Map(); // sanitized name -> first raw model seen for it
88
+ const collisionModels = new Map(); // sanitized name -> Set(raw models) once >1 raw maps to it
89
+ for (const m of uniqueModels) {
90
+ const s = sanitizeName(m);
91
+ if (rawBySanitized.has(s)) {
92
+ if (!collisionModels.has(s)) { collisionModels.set(s, new Set([rawBySanitized.get(s)])); }
93
+ collisionModels.get(s).add(m);
94
+ } else {
95
+ rawBySanitized.set(s, m);
96
+ }
97
+ }
98
+
99
+ for (const m of uniqueModels) {
100
+ names.push(`review-${sanitizeName(m)}.md`);
101
+ names.push(`judge-${sanitizeName(m)}.md`);
102
+ // rebuttal-/revote- are keyed on the same BENCH ALIAS through the same sanitizeName
103
+ // (materializeDebate is called with `d.raiser` / the revote leg's model — both aliases).
104
+ if (debated) {
105
+ names.push(`rebuttal-${sanitizeName(m)}.md`);
106
+ names.push(`revote-${sanitizeName(m)}.md`);
107
+ }
108
+ }
109
+ // `uniqueModels` already collapsed genuinely-repeated bench entries, so this final Set is
110
+ // now just a belt-and-suspenders no-op for names — it can no longer mask a real collision,
111
+ // since that path is detected above from the RAW (pre-sanitize) values instead.
112
+ const list = [...new Set(names)];
113
+ if (collisionModels.size) {
114
+ list.collisions = [...collisionModels.entries()].map(([sanitized, models]) => ({
115
+ sanitized, models: [...models],
116
+ }));
117
+ }
118
+ return list;
119
+ }
120
+
121
+ /**
122
+ * @param {string} project
123
+ * @param {string} runId
124
+ * @param {string} name artifact filename (must be allowlisted)
125
+ * @param {object} [deps] TEST-ONLY dependency injection: {realpathSync}. Never populate this
126
+ * from renderer/caller-supplied input in production — an attacker-controlled realpathSync
127
+ * would silently erase fence 2 (the realpath containment check below).
128
+ * @returns {{text: string, truncated?: true} | {error: string}}
129
+ */
130
+ function readRunArtifact(project, runId, name, deps = {}) {
131
+ const realpathSync = deps.realpathSync || ((p) => fsReal.realpathSync(p));
132
+ const ptr = readPointer(project, runId);
133
+ if (ptr.error) { return { error: ptr.error }; }
134
+
135
+ let realDir;
136
+ try { realDir = realpathSync(ptr.runDir); }
137
+ catch { return { error: 'run dir unreadable' }; }
138
+
139
+ // ⚠️ COUNCIL REVIEW R2 (A1) / ROUND 4 ORDERING FIX (third live paid council, blocker):
140
+ // this outer fence — "runDir ITSELF (straight from the pointer file's JSON, validated
141
+ // only for truthiness by src/council/run-state.js's readPointer) stays inside project" —
142
+ // must run BEFORE any read reaches the filesystem. It used to run AFTER an unconditional
143
+ // read+JSON.parse of run.json from ptr.runDir: the fence still refused to hand back
144
+ // artifact bytes, so nothing ever leaked, but a tampered/stale pointer could still force
145
+ // this process to read-and-parse attacker-influenced JSON at an arbitrary path before any
146
+ // containment check ran — a parser surface with no corresponding gate. Mirrors
147
+ // src/workspace/run-detail.js's getRunDetail, which already resolves+fences BEFORE
148
+ // reading anything (round 3); this function was the one place in the workspace surface
149
+ // that got the ordering wrong. Also mirrors electron/ipc-workspace.js's
150
+ // workspace:open-report fence (first council review, finding C1): same isRealpathContained
151
+ // helper, same check, same error wording — now correctly ordered on the channel that
152
+ // actually serves artifact bytes (workspace:read-artifact and workspace:fold's
153
+ // chair-output.md read both funnel through this function).
154
+ let realProject;
155
+ try { realProject = realpathSync(project); }
156
+ catch { return { error: 'project unreadable' }; }
157
+ if (!isRealpathContained(realProject, realDir)) {
158
+ return { error: 'run directory escapes project' };
159
+ }
160
+
161
+ let run;
162
+ try { run = JSON.parse(fsReal.readFileSync(path.join(ptr.runDir, 'run.json'), 'utf-8')); }
163
+ // Generic message (round 4): a readFileSync failure's err.message embeds the full
164
+ // resolved path it tried to open — interpolating it here would hand the renderer an
165
+ // internal filesystem path (in the pre-fence-ordering bug, potentially one entirely
166
+ // outside the project) via the IPC response.
167
+ catch { return { error: 'run.json unreadable' }; }
168
+
169
+ if (!artifactAllowlist(run).includes(name)) { return { error: `artifact not allowed: ${name}` }; }
170
+
171
+ let realTarget;
172
+ try { realTarget = realpathSync(path.join(ptr.runDir, name)); }
173
+ catch (err) {
174
+ // ⚠️ v4.4.1 RN-10: this catch used to answer `not written yet: <name>` for ANY realpath
175
+ // failure — ENOENT, EACCES, EPERM, EIO, ELOOP, a dangling symlink — so a permission problem
176
+ // was indistinguishable from a file the council simply has not produced yet. That is not a
177
+ // cosmetic conflation: electron/ipc-workspace.js's workspace:fold reads chair-output.md
178
+ // through this function, and on a permission error it produced a silent CHAIRLESS fold that
179
+ // still reported {ok: true}. The logger.warn it now emits was the mitigation — but it logged
180
+ // this string, so the log said "not written yet" about a file that was right there.
181
+ //
182
+ // ⚠️ Keep the sanitization. Do NOT re-interpolate `err.message`: a realpath failure's message
183
+ // embeds the full resolved path it tried to open, which round 4 deliberately stopped handing
184
+ // back over IPC (see the run.json catch above). `err.code` is a bare symbolic errno with no
185
+ // path in it, and it is the one piece an operator reading the fold warning actually needs —
186
+ // whitelisted to the errno character class so nothing else can ever ride out through here.
187
+ const code = err && typeof err.code === 'string' && /^[A-Z][A-Z0-9_]{1,15}$/.test(err.code)
188
+ ? err.code : 'unknown';
189
+ if (code === 'ENOENT') { return { error: `not written yet: ${name}` }; }
190
+ return { error: `artifact unreadable (${code}): ${name}` };
191
+ }
192
+ if (!isRealpathContained(realDir, realTarget)) {
193
+ return { error: 'artifact escapes run directory' };
194
+ }
195
+
196
+ let buf;
197
+ try { buf = fsReal.readFileSync(realTarget); }
198
+ catch { return { error: 'artifact unreadable' }; }
199
+ if (buf.length > MAX_ARTIFACT_BYTES) {
200
+ return { text: truncateUtf8(buf, MAX_ARTIFACT_BYTES).toString('utf-8'), truncated: true };
201
+ }
202
+ return { text: buf.toString('utf-8') };
203
+ }
204
+
205
+ module.exports = {
206
+ artifactAllowlist, readRunArtifact, isRealpathContained,
207
+ FIXED_ARTIFACTS, DEBATE_ARTIFACTS, MAX_ARTIFACT_BYTES,
208
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Council Workspace — blind-mode name mapping (v4.4 §6.3).
3
+ *
4
+ * Pure inversion helpers over run.json labelMap ({ 'Review A': model }).
5
+ * Finding ids are ALREADY label-space (A1, B2 — v4.0 anonymize), so blind
6
+ * mode never touches ids; it only swaps model display names for labels.
7
+ * Bias hygiene, not security (spec §6.1) — the map is user-readable on disk.
8
+ */
9
+ 'use strict';
10
+
11
+ function buildNamePairs(labelMap) {
12
+ if (!labelMap || typeof labelMap !== 'object') { return []; }
13
+ const pairs = Object.entries(labelMap)
14
+ .filter(([label, model]) => typeof label === 'string' && typeof model === 'string')
15
+ .map(([label, model]) => ({ label, model }));
16
+ pairs.sort((a, b) => a.label.localeCompare(b.label));
17
+ return pairs;
18
+ }
19
+
20
+ function labelFor(model, labelMap) {
21
+ if (!labelMap || typeof labelMap !== 'object') { return null; }
22
+ for (const [label, m] of Object.entries(labelMap)) {
23
+ if (m === model) { return label; }
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function pairFor(model, labelMap) {
29
+ return { model, label: labelFor(model, labelMap) };
30
+ }
31
+
32
+ module.exports = { buildNamePairs, labelFor, pairFor };