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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.md +6 -3
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +59 -3
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +277 -0
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- package/src/workspace/run-scan.js +148 -0
package/src/utils/pricing.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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,
|
|
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}
|
|
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
|
-
/**
|
|
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; } })
|
|
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 };
|