amicus 4.3.0 → 4.4.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 (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +4 -3
  4. package/electron/ipc-workspace.js +283 -0
  5. package/electron/main.js +27 -0
  6. package/electron/preload-workspace.js +40 -0
  7. package/electron/workspace-shell.js +85 -0
  8. package/electron/workspace-ui/index.html +111 -0
  9. package/electron/workspace-ui/live-model.js +101 -0
  10. package/electron/workspace-ui/md-lite.js +119 -0
  11. package/electron/workspace-ui/workspace-app.js +240 -0
  12. package/electron/workspace-ui/workspace-matrix.js +212 -0
  13. package/electron/workspace-ui/workspace-panels.js +226 -0
  14. package/electron/workspace-ui/workspace-render.js +271 -0
  15. package/electron/workspace-ui/workspace-verbs.js +247 -0
  16. package/electron/workspace-ui/workspace.css +172 -0
  17. package/package.json +1 -1
  18. package/schemas/council-run-live.schema.json +25 -1
  19. package/schemas/council-run.schema.json +14 -0
  20. package/schemas/progress.schema.json +14 -1
  21. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  22. package/src/cli-handlers-council-run.js +25 -3
  23. package/src/cli-handlers-spend.js +32 -5
  24. package/src/cli-handlers-watch.js +37 -10
  25. package/src/council/briefings.js +35 -2
  26. package/src/council/run-budget.js +224 -0
  27. package/src/council/run-launch.js +44 -6
  28. package/src/council/run-stages.js +17 -3
  29. package/src/council/run.js +12 -11
  30. package/src/headless.js +347 -14
  31. package/src/mcp-council-awareness.js +53 -3
  32. package/src/observe/council-legs.js +183 -0
  33. package/src/observe/live-doc.js +21 -3
  34. package/src/observe/watch-render.js +19 -0
  35. package/src/opencode-client.js +15 -3
  36. package/src/sidecar/child-sessions.js +198 -0
  37. package/src/sidecar/conversation-mirror.js +111 -37
  38. package/src/sidecar/fanout-budget.js +71 -0
  39. package/src/sidecar/fanout-leg.js +23 -1
  40. package/src/sidecar/fanout.js +4 -11
  41. package/src/sidecar/tool-part.js +196 -0
  42. package/src/sidecar/workspace-window.js +62 -0
  43. package/src/spend-query.js +21 -6
  44. package/src/utils/env-num.js +42 -0
  45. package/src/utils/path-fence.js +82 -0
  46. package/src/utils/pricing.js +98 -9
  47. package/src/workspace/artifact-guard.js +187 -0
  48. package/src/workspace/blind-mode.js +32 -0
  49. package/src/workspace/fold-format.js +95 -0
  50. package/src/workspace/live-normalize.js +156 -0
  51. package/src/workspace/matrix-model.js +94 -0
  52. package/src/workspace/run-detail.js +223 -0
  53. package/src/workspace/run-scan.js +148 -0
@@ -62,33 +62,120 @@ function lookupPricing(modelId) {
62
62
  return null;
63
63
  }
64
64
 
65
+ /**
66
+ * Did we actually OBSERVE any token usage for this leg? (v4.4 B2)
67
+ *
68
+ * This is the predicate that separates "the provider billed us for a $0 tier"
69
+ * from "we never saw a usage payload at all" — a distinction the old
70
+ * `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.
74
+ * @param {object|null|undefined} tokens
75
+ * @returns {boolean}
76
+ */
77
+ function hasObservedTokens(tokens) {
78
+ 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;
84
+ }
85
+
65
86
  /** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
66
87
  function resolveLegCost({ reportedCost, tokens, pricing }) {
67
88
  if (typeof reportedCost === 'number' && reportedCost > 0) {
68
89
  return { amount: reportedCost, currency: 'USD', source: 'reported' };
69
90
  }
70
- if (pricing && tokens) {
91
+ // v4.4 B2: an all-zero token total is an ABSENCE OF OBSERVATION, not a $0
92
+ // bill. Pricing it as `0 × catalog = estimated $0` asserts "this seat was
93
+ // free" — a claim we cannot support, and one that silently under-counted the
94
+ // --max-cost ceiling on real paid runs (diagnosis §0: council-wsgate02 spent
95
+ // $0.9859 against a $0.75 ceiling while spent() believed $0.3720).
96
+ // The v4.2 §4.5 free-local-tier carve-out is DELIBERATELY preserved: a local
97
+ // Ollama/LM Studio seat legitimately costs $0 but still reports tokens, so
98
+ // 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`.
100
+ if (pricing && hasObservedTokens(tokens)) {
71
101
  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
102
  if (est >= 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
74
103
  }
75
104
  return { amount: null, currency: 'USD', source: 'unknown' };
76
105
  }
77
106
 
78
- /** Resolve a single run/leg's final usage block from raw totals + the model id. */
79
- function resolveUsage({ model, usageTotals }) {
107
+ /**
108
+ * Resolve a single run/leg's final usage block from raw totals + the model id.
109
+ *
110
+ * @param {object} opts
111
+ * @param {string} opts.model full route id
112
+ * @param {object} opts.usageTotals sumPerMessageUsage output
113
+ * @param {boolean} [opts.subtreeUnknown] v4.4 Task 2 — this leg made a SUBAGENT
114
+ * (`task`) tool call whose CHILD OpenCode session is billed separately, is NOT
115
+ * rolled into the parent session's cost, and could NOT be enumerated (or could
116
+ * only be walked in part). The leg's OWN cost may be perfectly `reported`; what
117
+ * is unknown is its SUBTREE. Kept as a distinct concept from an unpriced leg
118
+ * (which means "we observed no tokens at all") because they are different
119
+ * statements and a reader must be able to tell them apart. Set only when true
120
+ * so an ordinary leg's usage block stays byte-identical.
121
+ * @param {{sessions: number, tokens: object, costReported: number}} [opts.subtree]
122
+ * v4.4.1 CA-1 — the child sessions that WERE enumerated and priced
123
+ * (src/sidecar/child-sessions.js). Attached beside the leg's own cost rather
124
+ * than folded into it: `cost` keeps meaning "this leg's own session", which is
125
+ * what every existing reader already assumes, and the subtree is a separate
126
+ * measurement that the wave rollup adds on top. Folding them together would
127
+ * also let a measured child amount launder an UNKNOWN parent into a
128
+ * `reported`-looking number — `council-wsgate02/wsgate02-s1-3` is exactly that
129
+ * shape (own cost unknown, child session $0.471046).
130
+ */
131
+ function resolveUsage({ model, usageTotals, subtreeUnknown, subtree }) {
80
132
  const totals = usageTotals || emptyUsageTotals();
81
133
  const cost = resolveLegCost({ reportedCost: totals.costReported, tokens: totals.tokens, pricing: lookupPricing(model) });
82
- return { tokens: totals.tokens, cost };
134
+ const usage = { tokens: totals.tokens, cost };
135
+ if (subtree && subtree.sessions > 0) {
136
+ const amount = subtree.costReported;
137
+ usage.subtree = {
138
+ sessions: subtree.sessions,
139
+ tokens: subtree.tokens || emptyUsageTotals().tokens,
140
+ // A child session's price comes from OpenCode's own billing, never from a
141
+ // catalog estimate (the SDK's Session record carries no model id, so an
142
+ // estimate would be a guess). Children that exist but reported nothing are
143
+ // `unknown` — the B2 rule one level down, and never a $0 claim.
144
+ cost: amount > 0
145
+ ? { amount, currency: 'USD', source: 'reported' }
146
+ : { amount: null, currency: 'USD', source: 'unknown' },
147
+ };
148
+ }
149
+ if (subtreeUnknown) { usage.subtreeUnknown = true; }
150
+ return usage;
83
151
  }
84
152
 
85
- /** Aggregate leg usage into a wave-level usage block. Legs without usage count as unpriced. */
153
+ /**
154
+ * Aggregate leg usage into a wave-level usage block. Legs without usage count as
155
+ * unpriced; legs carrying `subtreeUnknown` are counted separately in
156
+ * `subtreeUnknownLegs` — a fully-priced wave can still have an incomplete total.
157
+ *
158
+ * v4.4.1 CA-1: a leg's enumerated CHILD-session spend (`usage.subtree`) is added
159
+ * to the wave total here, and reported separately as `subtreeCost` /
160
+ * `subtreeSessions` so a reader can always see how much of the total came from
161
+ * subagents. It is added INDEPENDENTLY of whether the leg's own cost resolved —
162
+ * they are two different measurements, and dropping a measured child amount
163
+ * because its parent is unknown would be a second under-report on top of the
164
+ * one this whole change exists to close.
165
+ */
86
166
  function sumWaveUsage(legs) {
87
167
  const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
88
168
  let amount = 0; let anyAmount = false;
89
- let reportedLegs = 0, estimatedLegs = 0, unpricedLegs = 0;
169
+ let reportedLegs = 0, estimatedLegs = 0, unpricedLegs = 0, subtreeUnknownLegs = 0;
170
+ let subtreeCost = 0, subtreeSessions = 0;
90
171
  for (const leg of legs) {
91
172
  const u = leg && leg.usage;
173
+ if (u && u.subtreeUnknown) { subtreeUnknownLegs++; }
174
+ const st = u && u.subtree;
175
+ if (st && st.cost && typeof st.cost.amount === 'number') {
176
+ amount += st.cost.amount; anyAmount = true;
177
+ subtreeCost += st.cost.amount; subtreeSessions += st.sessions || 0;
178
+ }
92
179
  if (!u || !u.cost) { unpricedLegs++; continue; }
93
180
  for (const k of Object.keys(tokens)) { tokens[k] += (u.tokens && u.tokens[k]) || 0; }
94
181
  if (typeof u.cost.amount === 'number') { amount += u.cost.amount; anyAmount = true; }
@@ -101,7 +188,8 @@ function sumWaveUsage(legs) {
101
188
  else if (estimatedLegs > 0 && reportedLegs === 0 && unpricedLegs === 0) { source = 'estimated'; }
102
189
  else if (reportedLegs === 0 && estimatedLegs === 0) { source = 'unknown'; }
103
190
  else { source = 'mixed'; }
104
- return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source, reportedLegs, estimatedLegs, unpricedLegs } };
191
+ return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source,
192
+ reportedLegs, estimatedLegs, unpricedLegs, subtreeUnknownLegs, subtreeCost, subtreeSessions } };
105
193
  }
106
194
 
107
195
  /**
@@ -119,4 +207,5 @@ function formatCost(cost) {
119
207
  return (cost.source === 'estimated' || cost.source === 'mixed') ? `~${dollars}` : dollars;
120
208
  }
121
209
 
122
- module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage, formatCost };
210
+ module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, hasObservedTokens,
211
+ resolveLegCost, resolveUsage, sumWaveUsage, formatCost };
@@ -0,0 +1,187 @@
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
+ const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
28
+ const MAX_ARTIFACT_BYTES = 200 * 1024;
29
+
30
+ // isRealpathContained itself now lives in ../utils/path-fence.js (the shared "fence 2"
31
+ // primitive: a realpath-based containment test that defeats symlink escapes AND
32
+ // tampered/stale pointers). Re-exported below for backward compatibility — callers
33
+ // outside this module (electron/ipc-workspace.js's workspace:open-report, this
34
+ // file's own tests, and as of the third council-review pass src/workspace/run-detail.js
35
+ // and src/workspace/run-scan.js) all reuse the exact same check rather than
36
+ // re-implementing it. It could not stay defined here: run-scan.js needs it too, and
37
+ // this file already requires run-scan.js for readPointer, so a shared leaf module
38
+ // (no workspace/* deps of its own) is what keeps that from becoming a require cycle.
39
+
40
+ /**
41
+ * Trim a buffer to at most `max` bytes without splitting a multi-byte UTF-8 character.
42
+ * A plain `buf.subarray(0, max)` can land mid-sequence — the tail bytes then decode as
43
+ * U+FFFD replacement characters, which can even push the encoded string back over `max`.
44
+ * Walks back over trailing continuation bytes (10xxxxxx) until it finds a clean boundary,
45
+ * dropping the whole partial character rather than emitting mojibake.
46
+ */
47
+ function truncateUtf8(buf, max) {
48
+ let end = max;
49
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) { end -= 1; }
50
+ return buf.subarray(0, end);
51
+ }
52
+
53
+ /**
54
+ * @param {object} run parsed run.json (may be partial)
55
+ * @returns {string[]} the allowlist. When two or more DISTINCT bench entries sanitize to the
56
+ * same artifact name, a non-enumerable-in-spirit (but plain, test-visible) `collisions`
57
+ * array is attached: `[{sanitized, models: [rawA, rawB, ...]}, ...]`. See the R4
58
+ * council-review note below for why this is surfaced rather than silently deduped.
59
+ */
60
+ function artifactAllowlist(run) {
61
+ const names = [...FIXED_ARTIFACTS];
62
+ const bench = run && Array.isArray(run.bench) ? run.bench : [];
63
+ // ⚠️ DE-ROT (F28): run.json carries a `debate` key ONLY on --debate runs, and it is seeded
64
+ // on the FIRST write (src/council/run.js:74-77), so this gate is safe and keeps the
65
+ // allowlist tight for the common case.
66
+ const debated = !!(run && run.debate);
67
+ if (debated) { names.push(...DEBATE_ARTIFACTS); }
68
+
69
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): sanitizeName is NOT
70
+ // injective — it maps every character outside [a-zA-Z0-9._-] to '-', so two DISTINCT bench
71
+ // entries ('vendor/a', 'vendor?a') both produce 'vendor-a'. Both models would then request
72
+ // the SAME artifact file, and the renderer's `[data-artifact="..."]` lookup (drillIntoJudge)
73
+ // hands back whichever section matches first — prose silently misattributed to the wrong
74
+ // model. That is a run-integrity defect (this run directory genuinely cannot hold both
75
+ // models' review/judge files under distinct names), not a display quirk, so it must be
76
+ // DETECTED and surfaced, never smoothed away by deduping the resulting name list.
77
+ //
78
+ // A bench with genuinely REPEATED identical entries (['gemini', 'gemini']) is a different,
79
+ // harmless case that must keep collapsing to one set of rows (preserved intent) — collapse
80
+ // those via a Set over the RAW bench values FIRST, so identical entries never even reach
81
+ // the collision check below (only entries that are distinct as raw strings but coincide
82
+ // after sanitizeName count as a collision).
83
+ const uniqueModels = [...new Set(bench)];
84
+ const rawBySanitized = new Map(); // sanitized name -> first raw model seen for it
85
+ const collisionModels = new Map(); // sanitized name -> Set(raw models) once >1 raw maps to it
86
+ for (const m of uniqueModels) {
87
+ const s = sanitizeName(m);
88
+ if (rawBySanitized.has(s)) {
89
+ if (!collisionModels.has(s)) { collisionModels.set(s, new Set([rawBySanitized.get(s)])); }
90
+ collisionModels.get(s).add(m);
91
+ } else {
92
+ rawBySanitized.set(s, m);
93
+ }
94
+ }
95
+
96
+ 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).
101
+ if (debated) {
102
+ names.push(`rebuttal-${sanitizeName(m)}.md`);
103
+ names.push(`revote-${sanitizeName(m)}.md`);
104
+ }
105
+ }
106
+ // `uniqueModels` already collapsed genuinely-repeated bench entries, so this final Set is
107
+ // now just a belt-and-suspenders no-op for names — it can no longer mask a real collision,
108
+ // since that path is detected above from the RAW (pre-sanitize) values instead.
109
+ const list = [...new Set(names)];
110
+ if (collisionModels.size) {
111
+ list.collisions = [...collisionModels.entries()].map(([sanitized, models]) => ({
112
+ sanitized, models: [...models],
113
+ }));
114
+ }
115
+ return list;
116
+ }
117
+
118
+ /**
119
+ * @param {string} project
120
+ * @param {string} runId
121
+ * @param {string} name artifact filename (must be allowlisted)
122
+ * @param {object} [deps] TEST-ONLY dependency injection: {realpathSync}. Never populate this
123
+ * from renderer/caller-supplied input in production — an attacker-controlled realpathSync
124
+ * would silently erase fence 2 (the realpath containment check below).
125
+ * @returns {{text: string, truncated?: true} | {error: string}}
126
+ */
127
+ function readRunArtifact(project, runId, name, deps = {}) {
128
+ const realpathSync = deps.realpathSync || ((p) => fsReal.realpathSync(p));
129
+ const ptr = readPointer(project, runId);
130
+ if (ptr.error) { return { error: ptr.error }; }
131
+
132
+ let realDir;
133
+ try { realDir = realpathSync(ptr.runDir); }
134
+ catch { return { error: 'run dir unreadable' }; }
135
+
136
+ // ⚠️ COUNCIL REVIEW R2 (A1) / ROUND 4 ORDERING FIX (third live paid council, blocker):
137
+ // this outer fence — "runDir ITSELF (straight from the pointer file's JSON, validated
138
+ // only for truthiness by src/council/run-state.js's readPointer) stays inside project" —
139
+ // must run BEFORE any read reaches the filesystem. It used to run AFTER an unconditional
140
+ // read+JSON.parse of run.json from ptr.runDir: the fence still refused to hand back
141
+ // artifact bytes, so nothing ever leaked, but a tampered/stale pointer could still force
142
+ // this process to read-and-parse attacker-influenced JSON at an arbitrary path before any
143
+ // containment check ran — a parser surface with no corresponding gate. Mirrors
144
+ // src/workspace/run-detail.js's getRunDetail, which already resolves+fences BEFORE
145
+ // reading anything (round 3); this function was the one place in the workspace surface
146
+ // that got the ordering wrong. Also mirrors electron/ipc-workspace.js's
147
+ // workspace:open-report fence (first council review, finding C1): same isRealpathContained
148
+ // helper, same check, same error wording — now correctly ordered on the channel that
149
+ // actually serves artifact bytes (workspace:read-artifact and workspace:fold's
150
+ // chair-output.md read both funnel through this function).
151
+ let realProject;
152
+ try { realProject = realpathSync(project); }
153
+ catch { return { error: 'project unreadable' }; }
154
+ if (!isRealpathContained(realProject, realDir)) {
155
+ return { error: 'run directory escapes project' };
156
+ }
157
+
158
+ let run;
159
+ try { run = JSON.parse(fsReal.readFileSync(path.join(ptr.runDir, 'run.json'), 'utf-8')); }
160
+ // Generic message (round 4): a readFileSync failure's err.message embeds the full
161
+ // resolved path it tried to open — interpolating it here would hand the renderer an
162
+ // internal filesystem path (in the pre-fence-ordering bug, potentially one entirely
163
+ // outside the project) via the IPC response.
164
+ catch { return { error: 'run.json unreadable' }; }
165
+
166
+ if (!artifactAllowlist(run).includes(name)) { return { error: `artifact not allowed: ${name}` }; }
167
+
168
+ let realTarget;
169
+ try { realTarget = realpathSync(path.join(ptr.runDir, name)); }
170
+ catch { return { error: `not written yet: ${name}` }; }
171
+ if (!isRealpathContained(realDir, realTarget)) {
172
+ return { error: 'artifact escapes run directory' };
173
+ }
174
+
175
+ let buf;
176
+ try { buf = fsReal.readFileSync(realTarget); }
177
+ catch { return { error: 'artifact unreadable' }; }
178
+ if (buf.length > MAX_ARTIFACT_BYTES) {
179
+ return { text: truncateUtf8(buf, MAX_ARTIFACT_BYTES).toString('utf-8'), truncated: true };
180
+ }
181
+ return { text: buf.toString('utf-8') };
182
+ }
183
+
184
+ module.exports = {
185
+ artifactAllowlist, readRunArtifact, isRealpathContained,
186
+ FIXED_ARTIFACTS, DEBATE_ARTIFACTS, MAX_ARTIFACT_BYTES,
187
+ };
@@ -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 };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Council Workspace — fold payload builder (v4.4 §7).
3
+ *
4
+ * MIRRORS the shipped fold-header builder `formatFoldOutput`
5
+ * (src/headless.js:775-789, exported at :797, re-exported from src/index.js:20)
6
+ * — byte-for-byte the same 8-line head. src/headless.js is the SOURCE OF
7
+ * TRUTH; keep the head in sync with it. The duplication is deliberate:
8
+ * requiring headless.js transitively pulls opencode-client / progress /
9
+ * conversation-mirror at require time (src/headless.js:8-17), which
10
+ * src/workspace/ must stay free of.
11
+ * ⚠️ DE-ROT (F58): the header used to claim it "reuses the v4.0 marker/nonce
12
+ * contract exactly" while silently dropping formatFoldOutput's nonce-required
13
+ * throw (src/headless.js:776-778). The guard is restored in buildFoldText below.
14
+ * No model call — the chair result already exists on disk, so a workspace fold
15
+ * is a local read+format.
16
+ * The chair body is UNTRUSTED model text: it passes through stripFoldMarkers
17
+ * before embedding, so chair prose containing a marker can never truncate or
18
+ * spoof the fold (the exact hazard the nonce closure exists for).
19
+ * Degradation mirrors the engine's ladder: no chair → VERDICT: none + tally
20
+ * summary; pre-tally → stage/status summary. Never blocked, always labeled.
21
+ */
22
+ 'use strict';
23
+
24
+ const { buildFoldMarker, stripFoldMarkers } = require('../utils/fold-marker');
25
+ const { formatCost } = require('../utils/pricing');
26
+
27
+ function ok(doc) { return doc && !doc.parseError ? doc : null; }
28
+
29
+ function tierLine(tierCounts) {
30
+ const t = tierCounts || {};
31
+ const n = (k) => (typeof t[k] === 'number' ? t[k] : 0);
32
+ return `Tiers: Confirmed ${n('Confirmed')} · Disputed ${n('Disputed')} · Contested ${n('Contested')} · Singleton ${n('Singleton')}`;
33
+ }
34
+
35
+ function stageSummary(run) {
36
+ const stages = Array.isArray(run.stages) ? run.stages : [];
37
+ return stages.length ? stages.map((s) => `${s.name}: ${s.status}`).join(' · ') : 'no stages recorded';
38
+ }
39
+
40
+ /**
41
+ * @param {object} o {nonce, project, run, tally?, verdict?, chairText?}
42
+ * @returns {string} the fold block (marker first line; no trailing newline)
43
+ */
44
+ function buildFoldText(o) {
45
+ // ⚠️ DE-ROT (F58): mirror formatFoldOutput's v4.0 §9 guard (src/headless.js:776).
46
+ // Without it a missing nonce emits `[SIDECAR_FOLD:]`, which the hex-only marker
47
+ // regex (src/utils/fold-marker.js:68) never parses — a silently unfoldable block.
48
+ if (!o || !o.nonce) { throw new TypeError('buildFoldText requires a per-run nonce (v4.0 §9)'); }
49
+ const run = o.run || {};
50
+ const verdict = ok(o.verdict);
51
+ const tally = ok(o.tally);
52
+ // Review follow-up #2: verdict.json is NOT re-validated here (the
53
+ // amicus_verdict MCP path types overallVerdict as a bare z.string().nullable()
54
+ // — mcp-tools.js:428), so a multi-line or marker-bearing value must never
55
+ // reach the head verbatim: an embedded '\n' would shift every line below
56
+ // VERDICT: (a raw string containing '\n' becomes several elements once the
57
+ // head array is '\n'-joined), and an embedded marker could spoof the fold.
58
+ // Safe on the shipped engine path (parseChairVerdict returns a canonical
59
+ // CHAIR_VERDICTS phrase) — this is defense-in-depth, not a fix for a real
60
+ // producer.
61
+ const overall = verdict && verdict.overallVerdict
62
+ ? stripFoldMarkers(String(verdict.overallVerdict)).replace(/[\r\n]+/g, ' ').trim()
63
+ : null;
64
+ const tierCounts = (verdict && verdict.tierCounts) || (tally && tally.tierCounts) || null;
65
+ const cost = run.usage && run.usage.cost ? run.usage.cost : null;
66
+
67
+ const head = [
68
+ buildFoldMarker(o.nonce),
69
+ `Model: ${run.chair || 'unknown'}`,
70
+ `Session: ${run.runId || 'unknown'}`,
71
+ 'Client: council-workspace',
72
+ `CWD: ${o.project}`,
73
+ 'Mode: council',
74
+ '---',
75
+ `VERDICT: ${overall || 'none'}`,
76
+ ];
77
+ if (tierCounts) {
78
+ head.push(tierLine(tierCounts));
79
+ } else {
80
+ head.push(`Run: ${run.status || 'unknown'} — ${stageSummary(run)}`);
81
+ }
82
+ head.push(`Cost: ${formatCost(cost)}${cost && cost.source ? ` (${cost.source})` : ''}`);
83
+
84
+ // Review follow-up #1: strip FIRST, then test emptiness on the STRIPPED
85
+ // result — not the raw one. A chair body consisting solely of a marker
86
+ // line (plus whitespace) is non-empty raw but strips to '', and must fall
87
+ // back to the tally-summary label rather than embedding a blank body.
88
+ const stripped = o.chairText ? stripFoldMarkers(String(o.chairText)).trim() : '';
89
+ const body = stripped
90
+ || (tierCounts ? '(no chair output — tally summary above)' : '(pre-tally: stage summary above)');
91
+
92
+ return `${head.join('\n')}\n${body}`;
93
+ }
94
+
95
+ module.exports = { buildFoldText };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Council Workspace — live-doc normalization (v4.4 §3 A1/A2/A4 seam).
3
+ *
4
+ * ONE defensive mapping from the v4.3 composed live doc (the amicus_status
5
+ * rollup stamped view:'live') to the renderer's seat model. If the merged
6
+ * composed-doc shape ever drifts, THIS file moves and nothing else does.
7
+ * Liveness/staleness is passed through, never invented here (A4).
8
+ *
9
+ * The council composed doc (buildCouncilStatusPayload, src/mcp-council-
10
+ * awareness.js) is UNVERSIONED (F63) and carries: {taskId, type, runId,
11
+ * runDir, status, currentStage, stages:[{name,status,waveId}], legsTotal,
12
+ * legsComplete, elapsed, exitCode, version, view:'live', usage?, reason?,
13
+ * legs:[{taskId, model, modelInput, role, status, messages, stage,
14
+ * latestPreview, lastActivityAt, stalled, usage?}], stalled?,
15
+ * stalledForSeconds?}. Do NOT copy the WAVE doc's shape (src/mcp-server.js:
16
+ * 592-662) — that is a different document, gated on metadata.type === 'wave'.
17
+ */
18
+ 'use strict';
19
+
20
+ const { formatCost } = require('../utils/pricing');
21
+ const { TERMINAL_STATUSES } = require('./run-detail');
22
+
23
+ function legRowsOf(doc) {
24
+ if (Array.isArray(doc.legs)) { return doc.legs; }
25
+ if (doc.wave && Array.isArray(doc.wave.legs)) { return doc.wave.legs; }
26
+ return [];
27
+ }
28
+
29
+ function numOrNull(v) { return typeof v === 'number' ? v : null; }
30
+
31
+ function seatOf(leg) {
32
+ const usage = leg.usage || null;
33
+ const tokens = usage && usage.tokens ? usage.tokens : null;
34
+ return {
35
+ id: leg.taskId || leg.legId || null,
36
+ // ⚠️ DE-ROT (F34/F36): `model` and `modelInput` are TWO SEPARATE fields, never collapsed.
37
+ // A live leg's `model` is the resolved executable id (e.g. `google/gemini-2.5`); `modelInput`
38
+ // is the council ALIAS (e.g. `gemini`) that run.json's labelMap and blind mode's labelFor()
39
+ // key on (src/council/anonymize.js:30 stamps labelMap values from the alias, never the
40
+ // resolved id). The already-shipped electron/workspace-ui/live-model.js:55 reads
41
+ // `seat.modelInput || seat.model` to pick the alias for its label lookup — collapsing the two
42
+ // into one field here would silently break blind mode (a resolved-id lookup never matches
43
+ // labelMap, leaking the real model id instead of degrading to a label or an em-dash).
44
+ model: leg.model || null,
45
+ modelInput: leg.modelInput || null,
46
+ role: leg.role || null,
47
+ status: leg.status || 'unknown',
48
+ // ⚠️ PRE-FLIGHT (P5): `leg.phase` is dead weight — Task 0.5 does not emit it. `leg.stage` IS
49
+ // emitted (src/observe/council-legs.js:88), so it is the only source; no fallback to invent.
50
+ stage: leg.stage || null,
51
+ messages: leg.messages === undefined ? null : leg.messages,
52
+ tokensIn: tokens ? numOrNull(tokens.input) : null,
53
+ tokensOut: tokens ? numOrNull(tokens.output) : null,
54
+ costDisplay: usage && usage.cost ? formatCost(usage.cost) : null,
55
+ // ⚠️ PRE-FLIGHT (P5): maps the ISO `lastActivityAt` only. `leg.lastActivity` /
56
+ // `leg.latestActivity` do not exist on a real leg row (`latestActivity` is an action LABEL —
57
+ // "Using <tool>" — not a time), so falling back to either would put prose in a timestamp
58
+ // column or leave it permanently null.
59
+ lastActivity: leg.lastActivityAt || null,
60
+ latestPreview: leg.latestPreview || null,
61
+ stalled: leg.stalled === true,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * How many legs in this stage rollup contributed NO amount (v4.4 §8). Read off
67
+ * sumWaveUsage's `unpricedLegs` (src/observe/live-doc.js rollupWaveUsage), which
68
+ * the composed doc already carries — nothing new is invented renderer-side.
69
+ */
70
+ function liveUnknownLegs(doc) {
71
+ const c = doc.usage && doc.usage.cost ? doc.usage.cost : null;
72
+ return (c && typeof c.unpricedLegs === 'number') ? c.unpricedLegs : 0;
73
+ }
74
+
75
+ /**
76
+ * How many legs in this rollup have an unattributed subagent SUBTREE (v4.4
77
+ * Task 2) — their own cost is known, but they spawned a child OpenCode session
78
+ * that is billed separately and never enumerated. Distinct from an unpriced leg,
79
+ * and the reason a fully-priced total can still be short.
80
+ */
81
+ function liveSubtreeUnknownLegs(doc) {
82
+ const c = doc.usage && doc.usage.cost ? doc.usage.cost : null;
83
+ return (c && typeof c.subtreeUnknownLegs === 'number') ? c.subtreeUnknownLegs : 0;
84
+ }
85
+
86
+ function liveCostDisplay(doc) {
87
+ if (!doc.usage || !doc.usage.cost) { return null; }
88
+ const base = formatCost(doc.usage.cost);
89
+ const parts = [];
90
+ const unknown = liveUnknownLegs(doc);
91
+ const subtree = liveSubtreeUnknownLegs(doc);
92
+ if (unknown > 0) { parts.push(`${unknown} unknown`); }
93
+ if (subtree > 0) { parts.push(`${subtree} subagent subtree`); }
94
+ return parts.length > 0 ? `${base} + ${parts.join(' + ')}` : base;
95
+ }
96
+
97
+ /**
98
+ * @param {object} doc composed live doc (amicus_status payload)
99
+ * @returns {object} LiveModel (see plan Shared contracts); {ok:false, error?} on junk
100
+ */
101
+ function normalizeLive(doc) {
102
+ if (!doc || typeof doc !== 'object') { return { ok: false, error: 'no live doc' }; }
103
+ const stages = Array.isArray(doc.stages) ? doc.stages : null;
104
+ const active = stages ? (stages.find((s) => s.status === 'running') || null) : null;
105
+ const status = doc.status || 'unknown';
106
+ return {
107
+ ok: true,
108
+ view: doc.view || null,
109
+ runId: doc.runId || doc.taskId || null,
110
+ status,
111
+ // ⚠️ PRE-FLIGHT (P6): the fallback read `doc.stage`, which NO producer emits — the council
112
+ // payload's field is `currentStage` (src/mcp-council-awareness.js:155). Harmless today only by
113
+ // coincidence: `currentStage` is computed from the same `stages.find(status === 'running')`
114
+ // predicate as `active`, so both arms are null in exactly the same cases. But it is the same
115
+ // read-a-field-nobody-writes class as P5, and this is the arm that would matter if `stages`
116
+ // ever arrived non-array. Read the field that exists.
117
+ stageName: active ? active.name : (doc.currentStage || null),
118
+ stages,
119
+ seats: legRowsOf(doc).map(seatOf),
120
+ // The two counters the payload already ships (src/mcp-council-awareness.js:149) — the honest
121
+ // fallback readout if a seat row is ever unavailable.
122
+ legsTotal: typeof doc.legsTotal === 'number' ? doc.legsTotal : null,
123
+ legsComplete: typeof doc.legsComplete === 'number' ? doc.legsComplete : null,
124
+ // ⚠️ DE-ROT (F39): these two are ACTIVE-STAGE spend, not the run total.
125
+ // buildCouncilStatusPayload rolls up only the legs of the currently-RUNNING stage's sub-waves
126
+ // (mcp-council-awareness.js:136-146) and omits `usage` entirely until one of those legs flushes
127
+ // progress.usage (:154), so the number under-reports and RESETS at each stage boundary; stages
128
+ // with no `project` (tally, verdict) contribute nothing. Decision: keep the field, LABEL it
129
+ // stage-scoped in the renderer (Task 15 suffixes the gauge text "(this stage)"). Do NOT try to
130
+ // add it onto `derived.cost.costAmount` — that is null for the entire life of the live loop
131
+ // (run-detail reads run.json's `usage`, which stays null until finalize(), run.js:98-102), so
132
+ // "adding" is just a rename of the same stage figure. The only run total is the terminal one.
133
+ // v4.4 §8: same treatment as the terminal panel (run-detail.js costPanel) —
134
+ // a stage rollup that omits unpriced legs reads as the full stage spend.
135
+ costDisplay: liveCostDisplay(doc),
136
+ costAmount: doc.usage && doc.usage.cost && typeof doc.usage.cost.amount === 'number' ? doc.usage.cost.amount : null,
137
+ costUnknownLegs: liveUnknownLegs(doc),
138
+ costSubtreeUnknownLegs: liveSubtreeUnknownLegs(doc),
139
+ costExact: liveUnknownLegs(doc) === 0 && liveSubtreeUnknownLegs(doc) === 0,
140
+ flags: {
141
+ // ⚠️ DE-ROT (F03): `crashed` exists nowhere on the composed doc — Task 0.5 deliberately did
142
+ // not add one (out of scope; see task-0.5-report.md). A crashed council instead flips
143
+ // `status` to 'error' and stamps `reason` from run.error
144
+ // (src/mcp-council-awareness.js:110-123) — that is the only real signal, so `crashed` is
145
+ // DERIVED from it here rather than read off a `doc.crashed` field that no real payload ever
146
+ // sets. This is still "never invented renderer-side" (A4): the derivation lives in this
147
+ // seam, not in electron/workspace-ui/*, and uses only fields the data layer actually wrote.
148
+ crashed: status === 'error' && Boolean(doc.reason),
149
+ stalled: doc.stalled === true,
150
+ stalledForSeconds: typeof doc.stalledForSeconds === 'number' ? doc.stalledForSeconds : null,
151
+ },
152
+ terminal: TERMINAL_STATUSES.includes(status),
153
+ };
154
+ }
155
+
156
+ module.exports = { normalizeLive };