amicus 4.2.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -1
- package/README.md +8 -4
- package/bin/amicus.js +5 -0
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +27 -0
- package/electron/preload-workspace.js +40 -0
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +101 -0
- package/electron/workspace-ui/md-lite.js +119 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +212 -0
- package/electron/workspace-ui/workspace-panels.js +226 -0
- package/electron/workspace-ui/workspace-render.js +271 -0
- package/electron/workspace-ui/workspace-verbs.js +247 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +57 -0
- package/schemas/council-run.schema.json +14 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +37 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +86 -8
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +94 -32
- package/src/cli-handlers-watch.js +116 -0
- package/src/cli.js +58 -1
- package/src/council/briefings.js +35 -2
- package/src/council/run-budget.js +224 -0
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +58 -7
- package/src/council/run-stages.js +30 -3
- package/src/council/run.js +44 -15
- package/src/headless.js +356 -15
- package/src/mcp-council-awareness.js +98 -3
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/council-legs.js +183 -0
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +56 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +168 -0
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +198 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +81 -27
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +54 -41
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +119 -0
- package/src/utils/env-num.js +42 -0
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- package/src/utils/path-fence.js +82 -0
- package/src/utils/pricing.js +98 -9
- package/src/utils/spend-ledger.js +24 -1
- package/src/workspace/artifact-guard.js +187 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +95 -0
- package/src/workspace/live-normalize.js +156 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +223 -0
- package/src/workspace/run-scan.js +148 -0
package/src/utils/pricing.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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,
|
|
210
|
+
module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, hasObservedTokens,
|
|
211
|
+
resolveLegCost, resolveUsage, sumWaveUsage, formatCost };
|
|
@@ -46,9 +46,20 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
|
|
|
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
48
|
* @param {{tokens:object, cost:{amount:number|null,currency:string,source:string}}|null} opts.usage
|
|
49
|
+
* @param {string} [opts.op] 'leg' | 'start' | 'continue' | 'resume'
|
|
50
|
+
* @param {string} [opts.status] terminal status
|
|
51
|
+
* @param {string} [opts.councilRunId] council run id (additive attribution)
|
|
52
|
+
* @param {string} [opts.councilName] council name (additive attribution)
|
|
53
|
+
* @param {string} [opts.project] project directory (additive attribution)
|
|
54
|
+
* @param {string} [opts.gateway] resolved gateway ('direct'|'openrouter'|'local', additive attribution)
|
|
55
|
+
* @param {number} [opts.attempt] fallback attempt count (omitted if absent)
|
|
56
|
+
* @param {string} [opts.substitutedFor] substituted model (omitted if absent)
|
|
57
|
+
* @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
|
|
49
58
|
* @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
|
|
50
59
|
*/
|
|
51
|
-
function appendSpend({ taskId, waveId, model, mode, usage
|
|
60
|
+
function appendSpend({ taskId, waveId, model, mode, usage,
|
|
61
|
+
op, status, councilRunId, councilName, project, gateway,
|
|
62
|
+
attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
|
|
52
63
|
if (!usage) { return; }
|
|
53
64
|
try {
|
|
54
65
|
const dir = ctx.dir || getConfigDir();
|
|
@@ -62,7 +73,19 @@ function appendSpend({ taskId, waveId, model, mode, usage }, ctx = {}) {
|
|
|
62
73
|
mode: mode || null,
|
|
63
74
|
tokens: usage.tokens || null,
|
|
64
75
|
cost: usage.cost || null,
|
|
76
|
+
// v4.3 additive attribution (spec 7.1). Nullable dimensions default to
|
|
77
|
+
// null (so a row is always groupable); linkage fields are OMITTED unless
|
|
78
|
+
// present (they only exist on fallback/retry rows).
|
|
79
|
+
op: op || null,
|
|
80
|
+
status: status || null,
|
|
81
|
+
councilRunId: councilRunId || null,
|
|
82
|
+
councilName: councilName || null,
|
|
83
|
+
project: project || null,
|
|
84
|
+
gateway: gateway || null,
|
|
65
85
|
};
|
|
86
|
+
if (attempt !== undefined) { row.attempt = attempt; }
|
|
87
|
+
if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
|
|
88
|
+
if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
|
|
66
89
|
fs.appendFileSync(path.join(dir, SPEND_LEDGER_FILE), JSON.stringify(row) + '\n');
|
|
67
90
|
} catch (e) {
|
|
68
91
|
logger.debug('spend-ledger append failed (best-effort, run unaffected)', { taskId, error: e.message });
|
|
@@ -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 };
|