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.
Files changed (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +46 -1
  3. package/README.md +8 -4
  4. package/bin/amicus.js +5 -0
  5. package/electron/ipc-workspace.js +283 -0
  6. package/electron/main.js +27 -0
  7. package/electron/preload-workspace.js +40 -0
  8. package/electron/workspace-shell.js +85 -0
  9. package/electron/workspace-ui/index.html +111 -0
  10. package/electron/workspace-ui/live-model.js +101 -0
  11. package/electron/workspace-ui/md-lite.js +119 -0
  12. package/electron/workspace-ui/workspace-app.js +240 -0
  13. package/electron/workspace-ui/workspace-matrix.js +212 -0
  14. package/electron/workspace-ui/workspace-panels.js +226 -0
  15. package/electron/workspace-ui/workspace-render.js +271 -0
  16. package/electron/workspace-ui/workspace-verbs.js +247 -0
  17. package/electron/workspace-ui/workspace.css +172 -0
  18. package/package.json +1 -1
  19. package/schemas/council-run-live.schema.json +57 -0
  20. package/schemas/council-run.schema.json +14 -0
  21. package/schemas/event.schema.json +15 -0
  22. package/schemas/progress.schema.json +37 -0
  23. package/schemas/run-live.schema.json +15 -0
  24. package/schemas/spend.schema.json +26 -1
  25. package/schemas/wave-live.schema.json +15 -0
  26. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  27. package/src/cli-handlers-council-run.js +86 -8
  28. package/src/cli-handlers-run.js +26 -0
  29. package/src/cli-handlers-spend.js +94 -32
  30. package/src/cli-handlers-watch.js +116 -0
  31. package/src/cli.js +58 -1
  32. package/src/council/briefings.js +35 -2
  33. package/src/council/run-budget.js +224 -0
  34. package/src/council/run-chair.js +10 -2
  35. package/src/council/run-debate.js +5 -1
  36. package/src/council/run-launch.js +58 -7
  37. package/src/council/run-stages.js +30 -3
  38. package/src/council/run.js +44 -15
  39. package/src/headless.js +356 -15
  40. package/src/mcp-council-awareness.js +98 -3
  41. package/src/mcp-council-run.js +28 -4
  42. package/src/mcp-notify.js +54 -0
  43. package/src/mcp-server.js +51 -1
  44. package/src/mcp-spend.js +125 -0
  45. package/src/mcp-tools.js +39 -0
  46. package/src/mcp-wait.js +28 -2
  47. package/src/observe/council-legs.js +183 -0
  48. package/src/observe/events.js +156 -0
  49. package/src/observe/follow.js +26 -0
  50. package/src/observe/live-doc.js +56 -0
  51. package/src/observe/on-complete.js +117 -0
  52. package/src/observe/watch-render.js +168 -0
  53. package/src/opencode-client.js +15 -3
  54. package/src/sidecar/child-sessions.js +198 -0
  55. package/src/sidecar/continue.js +32 -0
  56. package/src/sidecar/conversation-mirror.js +111 -37
  57. package/src/sidecar/fallback-chains.js +65 -0
  58. package/src/sidecar/fanout-budget.js +71 -0
  59. package/src/sidecar/fanout-leg-fallback.js +189 -0
  60. package/src/sidecar/fanout-leg.js +81 -27
  61. package/src/sidecar/fanout-retry.js +208 -0
  62. package/src/sidecar/fanout-validate.js +42 -4
  63. package/src/sidecar/fanout.js +54 -41
  64. package/src/sidecar/progress.js +5 -0
  65. package/src/sidecar/resume.js +12 -0
  66. package/src/sidecar/start.js +13 -1
  67. package/src/sidecar/tool-part.js +196 -0
  68. package/src/sidecar/workspace-window.js +62 -0
  69. package/src/spend-query.js +119 -0
  70. package/src/utils/env-num.js +42 -0
  71. package/src/utils/error-classify.js +31 -0
  72. package/src/utils/model-tiers.js +1 -1
  73. package/src/utils/path-fence.js +82 -0
  74. package/src/utils/pricing.js +98 -9
  75. package/src/utils/spend-ledger.js +24 -1
  76. package/src/workspace/artifact-guard.js +187 -0
  77. package/src/workspace/blind-mode.js +32 -0
  78. package/src/workspace/fold-format.js +95 -0
  79. package/src/workspace/live-normalize.js +156 -0
  80. package/src/workspace/matrix-model.js +94 -0
  81. package/src/workspace/run-detail.js +223 -0
  82. package/src/workspace/run-scan.js +148 -0
@@ -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 amount = typeof cost.amount === 'number' ? cost.amount : 0;
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
- * @param {{total:object, byModel:Array, windowDays:number|null, credit:object|null}} opts
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
- return {
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
- function renderHuman({ total, byModel, windowDays, credit }) {
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
- `${formatCost({ amount: m.amount, source: dominantSource(m.sourceMix) }).padStart(9)} ${mix}\n`;
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] [--json]`
159
- * @param {{_:string[], json?:boolean, since?:string}} args
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
- let rows = readSpendRows(deps.dir);
177
- if (windowDays !== null) {
178
- const cutoff = deps.now() - windowDays * 86400000;
179
- rows = rows.filter(r => {
180
- const t = Date.parse(r.ts);
181
- return Number.isFinite(t) && t >= cutoff;
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
- const { total, byModel } = aggregateSpend(rows);
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
- process.stdout.write(JSON.stringify(buildSpendDoc({ total, byModel, windowDays, credit }), null, 2) + '\n');
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 = { handleSpend, aggregateSpend, buildSpendDoc, parseSinceDays };
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':
@@ -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
- 'Your previous review\'s trailing findings JSON failed validation with these errors:',
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:',