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/cli-handlers-run.js
CHANGED
|
@@ -116,6 +116,21 @@ async function handleStart(args) {
|
|
|
116
116
|
async function handleFanout(args) {
|
|
117
117
|
const useJson = !!args.json;
|
|
118
118
|
|
|
119
|
+
// --retry-failed <waveId> (v4.3 Task 19, spec 6.1): a completely different
|
|
120
|
+
// path from the --prompt/--models launch below (no briefing, no required
|
|
121
|
+
// --models — the original wave's failed legs supply their own saved
|
|
122
|
+
// context) — dispatch BEFORE any of that validation runs. --models here is
|
|
123
|
+
// optional and, when present, filters which failed legs get retried.
|
|
124
|
+
if (args['retry-failed']) {
|
|
125
|
+
const { retryFailedWave } = require('./sidecar/fanout-retry');
|
|
126
|
+
const { parseModelsList } = require('./sidecar/fanout-validate');
|
|
127
|
+
const { exitCode, errorDoc } = await retryFailedWave(String(args['retry-failed']), args.cwd || process.cwd(), {
|
|
128
|
+
models: parseModelsList(args.models), json: useJson,
|
|
129
|
+
});
|
|
130
|
+
if (errorDoc && useJson) { process.stdout.write(JSON.stringify(errorDoc) + '\n'); }
|
|
131
|
+
return exitCode;
|
|
132
|
+
}
|
|
133
|
+
|
|
119
134
|
// FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
|
|
120
135
|
// --gateway via validateStartArgs (cli.js) — fanout never did, so a typo'd
|
|
121
136
|
// value silently fell through to resolveGatewayMode's pass-through instead
|
|
@@ -178,6 +193,8 @@ async function handleFanout(args) {
|
|
|
178
193
|
// Direct require — the src/index.js public re-export is added later (Task 13)
|
|
179
194
|
const { runFanout } = require('./sidecar/fanout');
|
|
180
195
|
const { loadConfig, resolveGatewayMode } = require('./utils/config');
|
|
196
|
+
const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
|
|
197
|
+
const { readCache } = require('./utils/model-catalog');
|
|
181
198
|
const cfg = loadConfig() || {};
|
|
182
199
|
const { exitCode } = await runFanout({
|
|
183
200
|
models: args.models,
|
|
@@ -211,6 +228,15 @@ async function handleFanout(args) {
|
|
|
211
228
|
maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,
|
|
212
229
|
noCostGate: !!args['no-cost-gate'],
|
|
213
230
|
maxCostPerMtok: cfg.maxCostPerMtok,
|
|
231
|
+
follow: !!args.follow,
|
|
232
|
+
onComplete: args['on-complete'],
|
|
233
|
+
// v4.3 Task 18 (spec §6.2): opt-in cheaper-model substitution. --fallback
|
|
234
|
+
// forces on, --no-fallback forces off; unset defers to config `fallbacks.enabled`.
|
|
235
|
+
fallback: resolveFallbackConfig({
|
|
236
|
+
flagFallback: args.fallback === true ? true : (args['no-fallback'] ? false : undefined),
|
|
237
|
+
config: cfg,
|
|
238
|
+
}),
|
|
239
|
+
catalog: (readCache() || {}).models || [],
|
|
214
240
|
});
|
|
215
241
|
return exitCode;
|
|
216
242
|
}
|
|
@@ -14,11 +14,20 @@
|
|
|
14
14
|
* not a forked/independent counter) rather than adding to a full file. If
|
|
15
15
|
* result-schema.js is ever split/slimmed, buildSpendDoc is the one to fold
|
|
16
16
|
* back in alongside buildCatalogDoc/buildDoctorDoc.
|
|
17
|
+
*
|
|
18
|
+
* filterRows/groupRows/computeWasted (spec §7.3/§6.3 query layer) live in the
|
|
19
|
+
* sibling ./spend-query.js instead of here: adding them inline pushed this
|
|
20
|
+
* file to 306 lines, over the 300-line size gate. They're re-exported below
|
|
21
|
+
* so existing/brief-specified imports of them from THIS module still resolve.
|
|
22
|
+
* GROUP_DIMS/ROWS_CAP (Task 5) live there too, as the single source shared
|
|
23
|
+
* with the MCP `amicus_spend` tool (src/mcp-tools.js, src/mcp-spend.js) —
|
|
24
|
+
* re-exported below for the same reason.
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
27
|
const { readSpendRows } = require('./utils/spend-ledger');
|
|
20
28
|
const { formatCost } = require('./utils/pricing');
|
|
21
29
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
30
|
+
const { filterRows, groupRows, computeWasted, emptyTokens, addTokens, GROUP_DIMS, ROWS_CAP } = require('./spend-query');
|
|
22
31
|
|
|
23
32
|
const CREDIT_CHECK_TIMEOUT_MS = 5000;
|
|
24
33
|
|
|
@@ -29,36 +38,36 @@ function parseSinceDays(since) {
|
|
|
29
38
|
return m ? parseInt(m[1], 10) : null;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
function emptyTokens() {
|
|
33
|
-
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function addTokens(into, tokens) {
|
|
37
|
-
if (!tokens) { return; }
|
|
38
|
-
for (const k of Object.keys(into)) { into[k] += tokens[k] || 0; }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
41
|
/**
|
|
42
42
|
* Aggregate ledger rows into a total + per-model rollup, most-expensive-first.
|
|
43
43
|
* A row with a null cost.amount contributes 0 to totals but is still counted
|
|
44
44
|
* in `runs` and its source bucket — visibility into "how many runs are
|
|
45
45
|
* unpriced" matters as much as the dollar figure.
|
|
46
|
+
*
|
|
47
|
+
* v4.4: `unpricedRows` is the count of rows that contributed NOTHING to
|
|
48
|
+
* `amount` (no numeric cost). `sourceMix.unknown` is adjacent but not the same
|
|
49
|
+
* question — it buckets by the row's declared source, whereas this counts what
|
|
50
|
+
* the arithmetic actually saw. It exists so the renderers can say "$X plus N
|
|
51
|
+
* unknown" instead of coercing null→0 and printing a measured-looking $0.0000
|
|
52
|
+
* (diagnosis §8, final paragraph).
|
|
46
53
|
* @param {Array<object>} rows
|
|
47
54
|
*/
|
|
48
55
|
function aggregateSpend(rows) {
|
|
49
|
-
const total = { amount: 0, tokens: emptyTokens(), runs: rows.length, sourceMix: { reported: 0, estimated: 0, unknown: 0 } };
|
|
56
|
+
const total = { amount: 0, tokens: emptyTokens(), runs: rows.length, unpricedRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } };
|
|
50
57
|
const byModelMap = new Map();
|
|
51
58
|
for (const r of rows) {
|
|
52
59
|
const model = r.model || 'unknown';
|
|
53
60
|
if (!byModelMap.has(model)) {
|
|
54
|
-
byModelMap.set(model, { model, amount: 0, tokens: emptyTokens(), runs: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } });
|
|
61
|
+
byModelMap.set(model, { model, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } });
|
|
55
62
|
}
|
|
56
63
|
const bucket = byModelMap.get(model);
|
|
57
64
|
bucket.runs += 1;
|
|
58
65
|
addTokens(bucket.tokens, r.tokens);
|
|
59
66
|
addTokens(total.tokens, r.tokens);
|
|
60
67
|
const cost = r.cost || {};
|
|
61
|
-
const
|
|
68
|
+
const priced = typeof cost.amount === 'number';
|
|
69
|
+
const amount = priced ? cost.amount : 0;
|
|
70
|
+
if (!priced) { bucket.unpricedRows += 1; total.unpricedRows += 1; }
|
|
62
71
|
bucket.amount += amount;
|
|
63
72
|
total.amount += amount;
|
|
64
73
|
// Any source string outside {reported,estimated} buckets as unknown —
|
|
@@ -75,11 +84,16 @@ function aggregateSpend(rows) {
|
|
|
75
84
|
* Build the `--json` spend document. schemaVersion reuses result-schema's
|
|
76
85
|
* SCHEMA_VERSION (not a forked counter) — see module docblock for why this
|
|
77
86
|
* builder lives here instead of alongside buildCatalogDoc/buildDoctorDoc.
|
|
78
|
-
*
|
|
87
|
+
* New fields (filters/groupBy/groups/wasted/rows/rowsTruncated, spec §7.3)
|
|
88
|
+
* are all additive and only appear when the caller passes them — byte-compat
|
|
89
|
+
* for callers/tests that only ever passed {total, byModel, windowDays, credit}.
|
|
90
|
+
* @param {{total:object, byModel:Array, windowDays:number|null, credit:object|null,
|
|
91
|
+
* filters?:object, groupBy?:string, groups?:Array, wasted?:object,
|
|
92
|
+
* rows?:Array, rowsTruncated?:boolean}} opts
|
|
79
93
|
*/
|
|
80
|
-
function buildSpendDoc({ total, byModel, windowDays, credit }) {
|
|
94
|
+
function buildSpendDoc({ total, byModel, windowDays, credit, filters, groupBy, groups, wasted, rows, rowsTruncated }) {
|
|
81
95
|
const { SCHEMA_VERSION } = require('./utils/result-schema');
|
|
82
|
-
|
|
96
|
+
const doc = {
|
|
83
97
|
schemaVersion: SCHEMA_VERSION,
|
|
84
98
|
type: 'spend',
|
|
85
99
|
windowDays: windowDays !== undefined ? windowDays : null,
|
|
@@ -87,6 +101,12 @@ function buildSpendDoc({ total, byModel, windowDays, credit }) {
|
|
|
87
101
|
byModel,
|
|
88
102
|
credit: credit || null,
|
|
89
103
|
};
|
|
104
|
+
if (filters !== undefined) { doc.filters = filters; }
|
|
105
|
+
if (groupBy !== undefined) { doc.groupBy = groupBy; }
|
|
106
|
+
if (groups !== undefined) { doc.groups = groups; }
|
|
107
|
+
if (wasted !== undefined) { doc.wasted = wasted; }
|
|
108
|
+
if (rows !== undefined) { doc.rows = rows; doc.rowsTruncated = !!rowsTruncated; }
|
|
109
|
+
return doc;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
/** Real deps; tests override via the second handleSpend arg. */
|
|
@@ -128,7 +148,19 @@ async function fetchCreditFooter(deps) {
|
|
|
128
148
|
return res || null;
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Cost cell for one rollup bucket. v4.4: a bucket in which NO row carried a
|
|
153
|
+
* numeric amount has no dollar figure to show — it renders formatCost's `?`
|
|
154
|
+
* rather than `$0.0000`, which previously read as a measured zero and was the
|
|
155
|
+
* only thing contradicting the (correct but easily missed) `u` count.
|
|
156
|
+
*/
|
|
157
|
+
function bucketCost(bucket) {
|
|
158
|
+
const unpriced = bucket.unpricedRows || 0;
|
|
159
|
+
const hasKnown = (bucket.runs || 0) > unpriced;
|
|
160
|
+
return formatCost({ amount: hasKnown ? bucket.amount : null, source: dominantSource(bucket.sourceMix) });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderHuman({ total, byModel, windowDays, credit, wasted }) {
|
|
132
164
|
if (total.runs === 0) { return 'No spend recorded yet.\n'; }
|
|
133
165
|
let out = windowDays ? `amicus spend (last ${windowDays}d)\n\n` : 'amicus spend (all time)\n\n';
|
|
134
166
|
out += 'model runs tokens(in/out) cost sources\n';
|
|
@@ -137,9 +169,18 @@ function renderHuman({ total, byModel, windowDays, credit }) {
|
|
|
137
169
|
const tokCol = `${m.tokens.input}/${m.tokens.output}`;
|
|
138
170
|
out += `${String(m.model).slice(0, 48).padEnd(48)} ${String(m.runs).padStart(4)} ` +
|
|
139
171
|
`${tokCol.padStart(15)} ` +
|
|
140
|
-
`${
|
|
172
|
+
`${bucketCost(m).padStart(9)} ${mix}\n`;
|
|
173
|
+
}
|
|
174
|
+
out += `\nTotal: ${bucketCost(total)} across ${total.runs} run(s)\n`;
|
|
175
|
+
// v4.4: the total is a FLOOR whenever any row is unpriced. Stated on its own
|
|
176
|
+
// line rather than squeezed into the fixed-width cost column.
|
|
177
|
+
if (total.unpricedRows > 0) {
|
|
178
|
+
out += `${total.unpricedRows} unpriced row(s) — cost unknown and NOT in the total; `
|
|
179
|
+
+ 'real spend is at least this much.\n';
|
|
180
|
+
}
|
|
181
|
+
if (wasted && wasted.runs > 0) {
|
|
182
|
+
out += `Wasted (failed runs): ${formatCost({ amount: wasted.amount, source: 'mixed' })} across ${wasted.runs} rows — see amicus spend --failed\n`;
|
|
141
183
|
}
|
|
142
|
-
out += `\nTotal: ${formatCost({ amount: total.amount, source: dominantSource(total.sourceMix) })} across ${total.runs} run(s)\n`;
|
|
143
184
|
if (credit && typeof credit.limitRemaining === 'number') {
|
|
144
185
|
out += `OpenRouter credit remaining: $${credit.limitRemaining}\n`;
|
|
145
186
|
}
|
|
@@ -155,8 +196,11 @@ function dominantSource(mix) {
|
|
|
155
196
|
}
|
|
156
197
|
|
|
157
198
|
/**
|
|
158
|
-
* `amicus spend [--since 7d] [--
|
|
159
|
-
*
|
|
199
|
+
* `amicus spend [--since 7d] [--wave <id>] [--council <runId|name>] [--project <path|.>]
|
|
200
|
+
* [--model <id-or-prefix>] [--op <op>] [--failed] [--group-by <dim>] [--rows] [--json]`
|
|
201
|
+
* @param {{_:string[], json?:boolean, since?:string, wave?:string, council?:string,
|
|
202
|
+
* project?:string|boolean, model?:string, op?:string, failed?:boolean,
|
|
203
|
+
* 'group-by'?:string, rows?:boolean}} args
|
|
160
204
|
* @param {object} [depsOverride] test seam
|
|
161
205
|
* @returns {Promise<number>} exit code
|
|
162
206
|
*/
|
|
@@ -173,26 +217,44 @@ async function handleSpend(args, depsOverride = {}) {
|
|
|
173
217
|
}
|
|
174
218
|
}
|
|
175
219
|
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
220
|
+
const groupBy = args['group-by'] || 'model';
|
|
221
|
+
if (!GROUP_DIMS.includes(groupBy)) {
|
|
222
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `invalid --group-by '${groupBy}'`,
|
|
223
|
+
hint: `--group-by one of: ${GROUP_DIMS.join('|')}` });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const rows = readSpendRows(deps.dir);
|
|
227
|
+
const filters = {
|
|
228
|
+
wave: args.wave, council: args.council, model: args.model,
|
|
229
|
+
op: args.op, failed: !!args.failed,
|
|
230
|
+
project: args.project === '.' || args.project === true ? process.cwd() : args.project,
|
|
231
|
+
};
|
|
232
|
+
const filtered = filterRows(rows, { ...filters, since: windowDays, now: windowDays !== null ? deps.now() : undefined });
|
|
233
|
+
|
|
234
|
+
const { total, byModel } = aggregateSpend(filtered);
|
|
235
|
+
const groups = groupRows(filtered, groupBy);
|
|
236
|
+
const wasted = computeWasted(filtered);
|
|
237
|
+
let rowsOut, rowsTruncated;
|
|
238
|
+
if (args.rows) {
|
|
239
|
+
rowsTruncated = filtered.length > ROWS_CAP;
|
|
240
|
+
rowsOut = filtered.slice(0, ROWS_CAP);
|
|
183
241
|
}
|
|
184
242
|
|
|
185
|
-
|
|
186
|
-
// Nothing recorded (or nothing in the --since window): skip the network
|
|
243
|
+
// Nothing recorded (or nothing survives the filters): skip the network
|
|
187
244
|
// credit probe entirely — there's no rollup to attach it to either way.
|
|
188
245
|
const credit = total.runs === 0 ? null : await fetchCreditFooter(deps).catch(() => null);
|
|
189
246
|
|
|
190
247
|
if (useJson) {
|
|
191
|
-
|
|
248
|
+
const doc = buildSpendDoc({ total, byModel, windowDays, credit,
|
|
249
|
+
filters, groupBy, groups, wasted, rows: rowsOut, rowsTruncated });
|
|
250
|
+
process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
|
|
192
251
|
return 0;
|
|
193
252
|
}
|
|
194
|
-
process.stdout.write(renderHuman({ total, byModel, windowDays, credit }));
|
|
253
|
+
process.stdout.write(renderHuman({ total, byModel, windowDays, credit, wasted }));
|
|
195
254
|
return 0;
|
|
196
255
|
}
|
|
197
256
|
|
|
198
|
-
module.exports = {
|
|
257
|
+
module.exports = {
|
|
258
|
+
handleSpend, aggregateSpend, buildSpendDoc, parseSinceDays,
|
|
259
|
+
filterRows, groupRows, computeWasted, GROUP_DIMS, ROWS_CAP,
|
|
260
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/cli-handlers-watch.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `amicus watch <id>` (spec 5.1) — render any in-flight (or terminal) run from
|
|
6
|
+
* any process, reading only the data layer (Surfaces A/B/C). This file owns id
|
|
7
|
+
* resolution + the command entry; the pure renderers live in
|
|
8
|
+
* src/observe/watch-render.js (Task 12). No fs.watch — a poll loop over the
|
|
9
|
+
* composed doc (via handlers.amicus_status) + the events tail.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { validateTaskId } = require('./utils/validators');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a watch id to a wave / council / solo target (pure over disk).
|
|
18
|
+
* Resolution order (spec 5.1): council pointer file -> council; else session
|
|
19
|
+
* metadata (type:'wave' -> wave, else -> solo); nothing readable -> unknown.
|
|
20
|
+
* Uses the SAME canonical path builders the rest of the codebase resolves
|
|
21
|
+
* sessions/pointers with — readPointer (council/run-state.js) and
|
|
22
|
+
* getSessionDir (session-manager.js) — rather than hand-rolling disk paths,
|
|
23
|
+
* so watch can never drift from how start/status/council resolve ids.
|
|
24
|
+
* @param {string} id
|
|
25
|
+
* @param {string} project
|
|
26
|
+
* @returns {{kind:'wave'|'council'|'solo'|'unknown', id: string, runDir?: string}}
|
|
27
|
+
*/
|
|
28
|
+
function resolveWatchTarget(id, project) {
|
|
29
|
+
const clean = String(id).replace(/^council-/, '');
|
|
30
|
+
|
|
31
|
+
const { readPointer } = require('./council/run-state');
|
|
32
|
+
const { containsOnDisk } = require('./utils/path-fence');
|
|
33
|
+
const ptr = readPointer(project, clean);
|
|
34
|
+
// readPointer validates `council-<id>.json`'s {runId, runDir} only for
|
|
35
|
+
// truthiness (run-state.js:133-139), so a tampered or stale pointer can point
|
|
36
|
+
// runDir anywhere on disk — and this resolver's runDir is what
|
|
37
|
+
// observe/watch-render.js opens events.jsonl from. Same realpath-containment
|
|
38
|
+
// fence the v4.4 workspace reads use (src/utils/path-fence.js); a real
|
|
39
|
+
// runDir is always nested inside project (src/mcp-council-run.js:109 enforces
|
|
40
|
+
// it at creation time), so nothing legitimate is refused. An escaping pointer
|
|
41
|
+
// falls through to the session lookup and then 'unknown' — the same outcome a
|
|
42
|
+
// missing pointer already produces, so handleWatch's BAD_SESSION contract is
|
|
43
|
+
// unchanged.
|
|
44
|
+
if (ptr && containsOnDisk(project, ptr.runDir)) {
|
|
45
|
+
return { kind: 'council', id: clean, runDir: ptr.runDir };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// getSessionDir THROWS on a path-traversal id ('..' / separators) — this
|
|
49
|
+
// resolver is exported and docblocked "pure over disk", so it must be
|
|
50
|
+
// total for arbitrary input, not just for ids that already passed
|
|
51
|
+
// validateTaskId in the wired CLI path (handleWatch, below). A throw here
|
|
52
|
+
// (traversal or otherwise) falls through to 'unknown' like any other
|
|
53
|
+
// unreadable id, rather than propagating out of a "pure" resolver.
|
|
54
|
+
try {
|
|
55
|
+
const { getSessionDir } = require('./session-manager');
|
|
56
|
+
const metaPath = path.join(getSessionDir(project, clean), 'metadata.json');
|
|
57
|
+
if (fs.existsSync(metaPath)) {
|
|
58
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
59
|
+
return { kind: meta.type === 'wave' ? 'wave' : 'solo', id: clean };
|
|
60
|
+
}
|
|
61
|
+
} catch { /* fall through to unknown */ }
|
|
62
|
+
return { kind: 'unknown', id: clean };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `amicus watch [--ui] <id> [--json|--plain] [--interval <sec>] [--project <p>]`
|
|
67
|
+
* @param {object} args parsed CLI args
|
|
68
|
+
* @returns {Promise<number>} exit code (render loop: Task 12)
|
|
69
|
+
*/
|
|
70
|
+
async function handleWatch(args) {
|
|
71
|
+
// v4.4: `amicus watch [--ui] [runId]` — GUI watch is the same verb (spec
|
|
72
|
+
// §4.4), not a separate command. Handled at the very top, above id
|
|
73
|
+
// resolution: unlike the machine-readable path, `--ui` alone (no runId)
|
|
74
|
+
// is valid and opens the Council Workspace run-list landing, so this
|
|
75
|
+
// branch must run BEFORE the "id is required" gate below.
|
|
76
|
+
if (args.ui) {
|
|
77
|
+
if (args.json) {
|
|
78
|
+
process.stderr.write('Error: --ui is interactive-only (no --json). Use amicus watch <id> --json for machine output.\n');
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
// --project wins over --cwd (same precedence as the non-UI path below) —
|
|
82
|
+
// DE-ROT F46: `--project` is the documented first-priority option for
|
|
83
|
+
// `watch`; resolving from --cwd only would silently point the workspace
|
|
84
|
+
// at the wrong directory when a run was launched elsewhere.
|
|
85
|
+
const project = args.project || args.cwd || process.cwd();
|
|
86
|
+
const runId = args._[1] ? String(args._[1]) : '';
|
|
87
|
+
const { launchWorkspaceWindow } = require('./sidecar/workspace-window');
|
|
88
|
+
const res = await launchWorkspaceWindow({ project, runId });
|
|
89
|
+
if (res.error) { process.stderr.write(`${res.error}\n`); }
|
|
90
|
+
return res.code;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
94
|
+
const id = args._[1];
|
|
95
|
+
if (!id || id === true) {
|
|
96
|
+
process.stderr.write('Error: id is required for watch\nUsage: amicus watch <id> [--json] [--plain] [--interval <sec>]\n');
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const check = validateTaskId(String(id));
|
|
100
|
+
if (!check.valid) { process.stderr.write(`${check.error}\n`); return 1; }
|
|
101
|
+
|
|
102
|
+
const project = args.project || args.cwd || process.cwd();
|
|
103
|
+
const target = resolveWatchTarget(String(id), project);
|
|
104
|
+
if (target.kind === 'unknown') {
|
|
105
|
+
return failJson(!!args.json, {
|
|
106
|
+
code: ERROR_CODES.BAD_SESSION,
|
|
107
|
+
message: `watch: id '${id}' not found or unreadable in ${project}`,
|
|
108
|
+
hint: 'Pass --project if the run was launched elsewhere.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { runWatchLoop } = require('./observe/watch-render');
|
|
113
|
+
return runWatchLoop(target, args, project);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { handleWatch, resolveWatchTarget };
|
package/src/cli.js
CHANGED
|
@@ -143,6 +143,12 @@ function isBooleanFlag(key) {
|
|
|
143
143
|
'render', // council verdict: also refresh report.html next to the decided verdict
|
|
144
144
|
'claude', // init: register for Claude Code only (Task 15)
|
|
145
145
|
'desktop', // init: register for Claude Desktop only (Task 15)
|
|
146
|
+
'failed', // spend: only non-complete (wasted) rows (v4.3 Task 4, spec §7.3)
|
|
147
|
+
'rows', // spend: include matching raw rows, capped at 1000 (v4.3 Task 4)
|
|
148
|
+
'plain', // watch: milestone log lines instead of the refresh table (v4.3 Task 11)
|
|
149
|
+
'ui', // watch: open the Council Workspace window; v4.4 seam (v4.3 Task 11)
|
|
150
|
+
'follow', // fanout / council run: stream this run's own events to stderr (v4.3 Task 13)
|
|
151
|
+
'fallback', // fanout / council run: opt-in cheaper-model substitution (v4.3 Task 18, spec 6.2); --no-fallback negates via the generic no-* catch-all below
|
|
146
152
|
];
|
|
147
153
|
return booleanFlags.includes(key);
|
|
148
154
|
}
|
|
@@ -369,6 +375,7 @@ Commands:
|
|
|
369
375
|
council verdict <tally.json> [--decisions <d.json>] [-o <out.json>] Build + write verdict.json
|
|
370
376
|
doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
|
|
371
377
|
spend [--since 7d] [--json] Cross-run cost rollup from the spend ledger
|
|
378
|
+
watch <id> [--json] [--plain] [--interval <sec>] Live-render a run from any terminal
|
|
372
379
|
abort Abort a running session (or --all)
|
|
373
380
|
setup Configure default model and aliases
|
|
374
381
|
--api-keys Open API key setup window
|
|
@@ -431,10 +438,27 @@ Options for 'fanout':
|
|
|
431
438
|
~32KB Windows argument cap). Mutually exclusive
|
|
432
439
|
with --prompt. Also works with 'start'.
|
|
433
440
|
--wave-id <id> Explicit wave ID (leg IDs become <id>-1..N)
|
|
441
|
+
--retry-failed <waveId> Relaunch ONLY that wave's failed/timed-out/crashed/
|
|
442
|
+
aborted legs as a NEW linked wave, using each leg's
|
|
443
|
+
own saved context (byte-identical retry). Skips
|
|
444
|
+
--prompt/--models; --models filters which failed
|
|
445
|
+
legs to retry. wave.json is never modified.
|
|
434
446
|
--json Emit the wave result as stable JSON on stdout
|
|
435
447
|
--max-cost <$> Refuse the wave if the estimated total exceeds $ (soft ceiling)
|
|
436
448
|
--no-cost-gate Disable the budget gate (per-$/Mtok threshold + ceiling) for this run
|
|
449
|
+
--fallback / --no-fallback Opt-in cheaper-model substitution on a classified
|
|
450
|
+
rate-limit/overload leg failure (spec 6.2). Overrides
|
|
451
|
+
config fallbacks.enabled when passed; default: config, else off.
|
|
437
452
|
--gateway <mode> Routing: auto (direct-first), direct, or openrouter
|
|
453
|
+
--follow Stream this run's events to stderr as they happen (--json -> NDJSON)
|
|
454
|
+
--on-complete <cmd> Run a shell command once, at terminal state, after
|
|
455
|
+
wave.json is durable. The command is user-authored
|
|
456
|
+
on THIS command line (CLI-only — never sourced from
|
|
457
|
+
config/briefings/model output); payload rides via
|
|
458
|
+
env only (AMICUS_TASK_ID/TYPE/STATUS/EXIT_CODE/
|
|
459
|
+
RESULT_FILE/EVENTS_FILE/COST/PROJECT), never model
|
|
460
|
+
text. Child stdout/stderr go to amicus stderr.
|
|
461
|
+
Never changes the wave's exit code, docs, or events.
|
|
438
462
|
Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
|
|
439
463
|
--no-context, --context-*, --mcp*, --no-validate-model, --cwd
|
|
440
464
|
Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
|
|
@@ -519,7 +543,8 @@ Subcommands for 'council':
|
|
|
519
543
|
[--chair <model>] [--critic <model>] [--lenses s1,s2,...]
|
|
520
544
|
[--out-dir <dir>] [--json] [--max-cost <usd>] [--timeout <min>]
|
|
521
545
|
[--gateway auto|direct|openrouter] [--no-validate-model]
|
|
522
|
-
[--debate] [--claude-review <file>] [--no-cost-gate]
|
|
546
|
+
[--debate] [--claude-review <file>] [--no-cost-gate] [--follow]
|
|
547
|
+
[--fallback] [--no-fallback] [--on-complete <cmd>]
|
|
523
548
|
Run the full headless council engine (v4.0).
|
|
524
549
|
Chair default: deepseek (must NOT be a bench seat).
|
|
525
550
|
--critic and --lenses are mutually exclusive.
|
|
@@ -527,6 +552,21 @@ Subcommands for 'council':
|
|
|
527
552
|
--claude-review <file> enters Claude's own review as
|
|
528
553
|
a judged entry; --no-cost-gate disables the per-leg
|
|
529
554
|
price gate for the whole run (repairs + chair).
|
|
555
|
+
--fallback/--no-fallback opts stage legs (Stage-1 +
|
|
556
|
+
Stage-2) into cheaper-model substitution on a
|
|
557
|
+
classified rate-limit/overload failure (spec 6.2);
|
|
558
|
+
the chair never substitutes via chains.
|
|
559
|
+
--follow streams run events to stderr as they
|
|
560
|
+
happen (--json -> NDJSON).
|
|
561
|
+
--on-complete <cmd> runs a shell command once, at
|
|
562
|
+
terminal state, after run.json is durable. The
|
|
563
|
+
command is user-authored on THIS command line
|
|
564
|
+
(CLI-only — never sourced from config/briefings/
|
|
565
|
+
model output); payload rides via env only
|
|
566
|
+
(AMICUS_TASK_ID/TYPE/STATUS/EXIT_CODE/RESULT_FILE/
|
|
567
|
+
EVENTS_FILE/COST/PROJECT), never model text. Child
|
|
568
|
+
stdout/stderr go to amicus stderr. Never changes
|
|
569
|
+
the run's exit code, docs, or events.
|
|
530
570
|
Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
|
|
531
571
|
save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
|
|
532
572
|
--json Machine-readable output
|
|
@@ -544,9 +584,26 @@ Options for 'doctor':
|
|
|
544
584
|
spend: `
|
|
545
585
|
Options for 'spend':
|
|
546
586
|
--since <Nd> Restrict to the last N days (e.g. --since 7d)
|
|
587
|
+
--wave <id> Only rows from this fan-out wave
|
|
588
|
+
--council <runId|name> Only rows from this council run (id or preset name)
|
|
589
|
+
--project <path|.> Only rows from this project ('.' = cwd)
|
|
590
|
+
--model <id-or-prefix> Only rows whose model starts with this
|
|
591
|
+
--op <start|continue|resume|leg> Only rows with this operation
|
|
592
|
+
--failed Only non-complete (wasted) rows
|
|
593
|
+
--group-by <model|wave|council|project|op|day> Rollup dimension (default model)
|
|
594
|
+
--rows Include matching raw rows (capped at 1000)
|
|
547
595
|
--json Machine-readable output (versioned spend doc)
|
|
548
596
|
Reads ~/.config/amicus/spend-ledger.jsonl (one row per completed run/leg).
|
|
549
597
|
Shows remaining OpenRouter credit when a key is configured.
|
|
598
|
+
`,
|
|
599
|
+
watch: `
|
|
600
|
+
Options for 'watch':
|
|
601
|
+
<id> A fan-out wave id, council run id, or session id
|
|
602
|
+
--project <path> Project the run was launched in (default cwd)
|
|
603
|
+
--interval <sec> Refresh interval (default 2, floor 0.5)
|
|
604
|
+
--plain Milestone log lines instead of the refresh table
|
|
605
|
+
--json NDJSON: tailed events + composed doc on change + final doc
|
|
606
|
+
--ui Open the Council Workspace window (v4.4; interactive-only)
|
|
550
607
|
`,
|
|
551
608
|
setup: `
|
|
552
609
|
Options for 'setup':
|
package/src/council/briefings.js
CHANGED
|
@@ -121,13 +121,46 @@ function buildLensBriefing({ lens, briefing, date }) {
|
|
|
121
121
|
* repair loop). References ONLY the json-shape fragment — never the
|
|
122
122
|
* "prose review THEN json" framing — so a headless model isn't handed license
|
|
123
123
|
* to write a whole new prose review on a tight repair turn.
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ LC-6. This used to carry the validation ERRORS without the REVIEW they
|
|
126
|
+
* were errors about, and a repair solo is a FRESH session with no memory of
|
|
127
|
+
* the review turn. Three of five paid councils burned a seat on it:
|
|
128
|
+
* - wsgate02 `qwen` — refused twice: "I don't have a previous review to correct"
|
|
129
|
+
* - wsgate04 `glm` — refused twice: "the previous review's content was
|
|
130
|
+
* excluded by the caller… I will not fabricate findings"
|
|
131
|
+
* - costgate01 `grok` — COMPLIED, by inventing a self-referential finding
|
|
132
|
+
* about its own empty output, which then entered
|
|
133
|
+
* tally.json, the street-cred table and the chair
|
|
134
|
+
* synthesis as C1 and reached a human's decision.
|
|
135
|
+
* Honest models lose the seat (a 4-model bench silently adjudicating on 3,
|
|
136
|
+
* still paying for the fourth); compliant ones poison the record.
|
|
137
|
+
*
|
|
138
|
+
* `review` is the text that ACTUALLY failed — the original review on the first
|
|
139
|
+
* attempt, the previous repair's output on the second — so the errors and the
|
|
140
|
+
* artifact they describe are always the same thing. It is embedded verbatim and
|
|
141
|
+
* uncapped: the largest real case was 35 KB, and a silent truncation would
|
|
142
|
+
* recreate this defect in a subtler form (repairing a review the model can only
|
|
143
|
+
* half see).
|
|
144
|
+
* @param {{errors?: Array<{code: string, detail: string}>, review?: string}} args
|
|
124
145
|
*/
|
|
125
|
-
function buildFindingsRepairPrompt({ errors }) {
|
|
146
|
+
function buildFindingsRepairPrompt({ errors, review }) {
|
|
126
147
|
const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
|
|
148
|
+
const text = typeof review === 'string' ? review.trim() : '';
|
|
149
|
+
// The absent case is stated, never papered over with an empty block: a model
|
|
150
|
+
// asked to repair nothing must be told to report nothing rather than left to
|
|
151
|
+
// guess, which is precisely what produced grok's invented finding.
|
|
152
|
+
const prior = text
|
|
153
|
+
? ['--- YOUR PREVIOUS REVIEW (verbatim — this is the text to correct) ---',
|
|
154
|
+
text,
|
|
155
|
+
'--- END OF YOUR PREVIOUS REVIEW ---'].join('\n')
|
|
156
|
+
: 'Your previous response was empty — there is no prior review text to correct. ' +
|
|
157
|
+
'Do not invent findings to satisfy the schema: emit an empty "findings" array ' +
|
|
158
|
+
'and say so in "overall".';
|
|
127
159
|
return [
|
|
128
160
|
'Do NOT use any tools or read any files; everything is in this message; begin ' +
|
|
129
161
|
'immediately with the JSON block.',
|
|
130
|
-
|
|
162
|
+
prior,
|
|
163
|
+
'That review\'s trailing findings JSON failed validation with these errors:',
|
|
131
164
|
lines,
|
|
132
165
|
'Re-emit ONLY the corrected findings JSON block (the same findings, fixed — do not ' +
|
|
133
166
|
'add or remove findings), as a single fenced ```json block:',
|