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
@@ -0,0 +1,183 @@
1
+ // src/observe/council-legs.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/council-legs
6
+ * Per-leg row builder for the composed council live doc
7
+ * (buildCouncilStatusPayload in src/mcp-council-awareness.js). Split out of
8
+ * that file to stay under the 300-line gate (DE-ROT Task 0.5, closes F01:
9
+ * `usageLegs` was computed then discarded — no `legs[]` ever reached the
10
+ * payload, so the live Seats panel had no data source).
11
+ *
12
+ * One row per leg id, built UNCONDITIONALLY: unlike the usage rollup (gated
13
+ * on `enriched.usage` in buildCouncilStatusPayload), a live seats panel needs
14
+ * just-started legs — the ones with no usage yet — just as much as priced
15
+ * ones. Field names mirror the wave branch (src/mcp-server.js:592-608) so
16
+ * live-normalize.js (Task 14) has one vocabulary to map, not two.
17
+ *
18
+ * `modelInput` + `role` (F36/F34 correction): a live leg's `model` is the
19
+ * RESOLVED executable id (metadata.model), never the council ALIAS that
20
+ * run.json's bench/chair/critic/lenses and roleFor's rule are keyed on — so
21
+ * deriving role from `model` is a silent no-op (Role column permanently
22
+ * em-dash) and blind mode's labelOf(alias) lookup never matches (real model
23
+ * id leaks). The alias IS on disk per-leg, though: every council leg goes
24
+ * through src/sidecar/fanout-leg.js's runSingleAttempt, which calls
25
+ * `writeLegPatch(legDir, { parentWave, modelInput })` synchronously,
26
+ * immediately after leg creation (fanout-leg.js:101) — well before any
27
+ * status poll could reasonably observe it missing. So this module reads
28
+ * `modelInput` straight off the leg's own metadata.json, no run.json join
29
+ * needed for the alias itself.
30
+ */
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const { readProgress, isStalled } = require('../sidecar/progress');
35
+ const { enrichLegUsage, TERMINAL } = require('./live-doc');
36
+ const { roleFor } = require('../council/run-stages');
37
+
38
+ /**
39
+ * A leg's council role. The chair stage is the one case alias identity
40
+ * cannot resolve: run-chair.js's fallback chain (ch1/ch2 = run.chair's own
41
+ * alias, ch3 = a DIFFERENT ledger-promoted alias, ch4 = whichever succeeded)
42
+ * means a chair leg's modelInput does not reliably equal run.json's `chair`
43
+ * field while the chain is still in flight (that field is only checkpointed
44
+ * once the WHOLE chain resolves, src/council/run-chair.js:122) — so alias
45
+ * matching would miss ch3/ch4 mid-run. The stage that owns the leg is the
46
+ * authoritative signal instead (plan's F34 correction: "derive role from the
47
+ * stage that owns the leg"). Every other stage (stage1/stage2/debate-*)
48
+ * reuses roleFor (src/council/run-stages.js) keyed on modelInput — a
49
+ * model's seat/critic/lens identity is stable whether it's reviewing
50
+ * (stage1) or judging (stage2), and a repair/debate leg relaunches the SAME
51
+ * alias as its origin leg, so the identity carries through unchanged.
52
+ * @returns {string|null} null when modelInput is unknown (truthful — never a guess)
53
+ */
54
+ function legRole({ bench, critic, lenses, stageName, modelInput }) {
55
+ if (!modelInput) { return null; }
56
+ if (stageName === 'chair') { return 'chair'; }
57
+ return roleFor({ models: bench, critic, lenses }, modelInput);
58
+ }
59
+
60
+ /**
61
+ * One composed row for a leg, plus the ms-since-activity behind its `stalled`
62
+ * flag (or null when not stalled/not yet measurable) so the caller can roll
63
+ * several legs' staleness into one run-level summary without re-parsing
64
+ * `lastActivityAt` back into a timestamp.
65
+ * @param {string} project
66
+ * @param {string} legId
67
+ * @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
68
+ * @returns {{row: object, stalledMs: number|null}}
69
+ */
70
+ function buildLegRow(project, legId, runCtx) {
71
+ const { getSessionDir } = require('../session-manager');
72
+ const legDir = getSessionDir(project, legId);
73
+ let meta = {};
74
+ try { meta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); }
75
+ catch { /* leg metadata not written yet — just-started leg */ }
76
+ // Truthful null, never metadata.model as a fallback: showing the resolved
77
+ // id where the alias was expected is exactly the F36 bug (blind mode would
78
+ // leak the real model id instead of degrading to an em-dash).
79
+ const modelInput = meta.modelInput || null;
80
+ const row = {
81
+ taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
82
+ modelInput, role: legRole({ ...runCtx, modelInput }),
83
+ };
84
+ let stalledMs = null;
85
+ let p = null;
86
+ try {
87
+ p = readProgress(legDir);
88
+ row.messages = p.messages;
89
+ row.stage = p.stage;
90
+ row.latestPreview = p.latestPreview;
91
+ row.lastActivityAt = p.lastActivityAt;
92
+ row.stalled = row.status === 'running' && isStalled(p.lastActivityMs);
93
+ if (row.stalled) { stalledMs = p.lastActivityMs; }
94
+ } catch { /* no progress.json yet — a just-started leg; base fields only. */ }
95
+
96
+ // council review C3: this is a SEPARATE try from readProgress's above, on
97
+ // purpose. The old code wrapped both in one try, so a pricing-resolution
98
+ // failure (an unknown model, a corrupt catalog row) landed in the exact same
99
+ // catch as "progress.json doesn't exist yet" — an operator (or the workspace's
100
+ // cost-by-seat panel) could not tell "pricing lookup failed" from "hasn't
101
+ // billed yet"; both rendered as a permanently blank cost cell. `usageError`
102
+ // makes the failure mode truthful and distinguishable, additive to the row
103
+ // (N3's undefined-key discipline still holds: only set usage/usageError when
104
+ // there is something to say).
105
+ //
106
+ // v4.4 B3 (diagnosis §4/§7.3): for a TERMINAL leg, metadata.json's `usage`
107
+ // block wins over the progress.json snapshot. progress.json's usage is stamped
108
+ // ONLY on 'receiving' flushes — which fire on text/tool/reasoning GROWTH, i.e.
109
+ // always strictly before OpenCode's finalization stamp — so on real paid runs
110
+ // 31 of 35 legs ended with an all-zero snapshot while metadata.json held
111
+ // thousands of real tokens and a reported cost. Reading the snapshot for a
112
+ // finished leg made every completed seat look free in the live doc.
113
+ // headless.js now also writes a terminal 'complete' progress record carrying
114
+ // the settled usage, which fixes the DATA; this makes the READER prefer the
115
+ // authoritative source either way, including for every leg already on disk.
116
+ // A still-RUNNING leg keeps reading progress.json — metadata.usage does not
117
+ // exist until the leg finalizes, and read-time resolution is what keeps a live
118
+ // in-flight cost current (live-doc.js's stated design).
119
+ if (TERMINAL.has(row.status) && meta.usage && meta.usage.cost) {
120
+ row.usage = meta.usage;
121
+ } else if (p && p.usage) {
122
+ try {
123
+ const enriched = enrichLegUsage(row, p.usage);
124
+ if (enriched.usage) { row.usage = enriched.usage; }
125
+ } catch (err) {
126
+ row.usageError = err.message;
127
+ }
128
+ }
129
+ return { row, stalledMs };
130
+ }
131
+
132
+ /**
133
+ * Rows for every leg id, plus a run-level stall rollup. A council run fans
134
+ * legs across several parallel sub-waves at once (seat wave, chair chain,
135
+ * lens/critic solos), so — unlike the single-leg wave branch, which only
136
+ * ever flags its own row — a run-level banner needs one summary rather than
137
+ * making the caller inspect every row.
138
+ *
139
+ * The rollup means exactly what the workspace's dead-run banner claims ("no
140
+ * leg activity for Xm — the run may be dead"): the run is stalled only when
141
+ * EVERY still-running leg is stalled. A single stalled leg alongside another
142
+ * leg that is actively producing (message/cost climbing) must NOT set the
143
+ * flag — that was the old (max-based) bug, observed live: `glm` sat stalled
144
+ * for two minutes while `qwen-coder` was visibly working, and the banner
145
+ * fired anyway. A leg that has already finished (any non-'running' status)
146
+ * is terminal — it is excluded from the "every leg" population entirely: it
147
+ * can neither keep the run "healthy" by counting as active, nor drag it
148
+ * "dead" by counting as stalled. Per-leg `row.stalled` is untouched by this —
149
+ * it stays the accurate, per-row signal it already was.
150
+ *
151
+ * When the run genuinely is stalled, `stalledForSeconds` is the SHORTEST
152
+ * idle duration among the stalled running legs, not the longest: that is the
153
+ * honest answer to "how long has the whole run been quiet" — something was
154
+ * still happening as recently as the most-recently-active (but still
155
+ * over-threshold) leg's last activity.
156
+ * @param {string} project
157
+ * @param {string[]} legIds
158
+ * @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
159
+ * run.json's alias-valued fields (bench/critic/lenses) + the active stage's
160
+ * name, threaded through to legRole — this module never reads run.json itself.
161
+ * @returns {{rows: object[], stalled?: true, stalledForSeconds?: number}}
162
+ */
163
+ function buildLegRows(project, legIds, runCtx) {
164
+ const rows = [];
165
+ let runningCount = 0;
166
+ const stalledRunningMs = [];
167
+ for (const legId of legIds) {
168
+ const { row, stalledMs } = buildLegRow(project, legId, runCtx);
169
+ rows.push(row);
170
+ if (row.status === 'running') {
171
+ runningCount += 1;
172
+ if (stalledMs !== null) { stalledRunningMs.push(stalledMs); }
173
+ }
174
+ }
175
+ const out = { rows };
176
+ if (runningCount > 0 && stalledRunningMs.length === runningCount) {
177
+ out.stalled = true;
178
+ out.stalledForSeconds = Math.floor(Math.min(...stalledRunningMs) / 1000);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ module.exports = { buildLegRows };
@@ -0,0 +1,156 @@
1
+ // src/observe/events.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/events
6
+ * Surface B (spec 4.2): the append-only milestone event stream, one
7
+ * events.jsonl per wave dir / council run dir. Single-writer (the owning
8
+ * orchestrator) so ordering is trivially correct; a torn final line on a hard
9
+ * crash is acceptable and skipped by the tail reader (same tradeoff as the two
10
+ * ledgers). appendEvent NEVER throws (spec 8) — emitting an event must never
11
+ * fail a wave/leg. The reader is a poll-stat tail: no fs.watch anywhere
12
+ * (Windows reference platform, spec 3.1).
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { logger } = require('../utils/logger');
18
+
19
+ const EVENTS_FILE = 'events.jsonl';
20
+ const EVENTS_SCHEMA_VERSION = 1;
21
+
22
+ /**
23
+ * Append one enveloped event line. Best-effort; swallows all failure.
24
+ * Reserved envelope keys — do not reuse these as payload field names, the
25
+ * stamped value always wins (spread order): schemaVersion, type, event, ts, id.
26
+ * @param {string} dir wave/council-run dir
27
+ * @param {{event:string, id:string}} payload event name + owning id + fields
28
+ */
29
+ function appendEvent(dir, payload) {
30
+ try {
31
+ const { event, id, ...rest } = payload || {};
32
+ const line = JSON.stringify({
33
+ schemaVersion: EVENTS_SCHEMA_VERSION, type: 'event',
34
+ event, ts: new Date().toISOString(), id, ...rest,
35
+ }) + '\n';
36
+ fs.appendFileSync(path.join(dir, EVENTS_FILE), line);
37
+ } catch (e) {
38
+ logger.debug('events append failed (best-effort, run unaffected)', { error: e.message });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Create a poll-stat tail over an events file. Returns { poll() } — each call
44
+ * yields the events appended since the previous call (empty on no growth,
45
+ * missing file, or a transient open error). Holds an unterminated tail.
46
+ * @param {string} file absolute path to events.jsonl
47
+ */
48
+ function createEventTail(file) {
49
+ let offset = 0;
50
+ let carry = '';
51
+ return {
52
+ poll() {
53
+ let stat;
54
+ try { stat = fs.statSync(file); }
55
+ catch { return []; } // not-yet-exists / transient -> missed tick
56
+ if (stat.size <= offset) { return []; }
57
+ let chunk;
58
+ try {
59
+ const fd = fs.openSync(file, 'r');
60
+ try {
61
+ const buf = Buffer.alloc(stat.size - offset);
62
+ fs.readSync(fd, buf, 0, buf.length, offset);
63
+ chunk = buf.toString('utf-8');
64
+ } finally { fs.closeSync(fd); }
65
+ } catch { return []; } // EBUSY/EPERM -> missed tick, retry next poll
66
+ offset = stat.size;
67
+ const text = carry + chunk;
68
+ const nl = text.lastIndexOf('\n');
69
+ if (nl === -1) { carry = text; return []; }
70
+ carry = text.slice(nl + 1);
71
+ const out = [];
72
+ for (const line of text.slice(0, nl).split('\n')) {
73
+ if (!line.trim()) { continue; }
74
+ try { out.push(JSON.parse(line)); } catch { /* skip torn/corrupt */ }
75
+ }
76
+ return out;
77
+ },
78
+ };
79
+ }
80
+
81
+ // ---- Milestone emit helpers (Task 7, spec 4.2 vocabulary) ----
82
+ // Centralized here (not in fanout.js / run.js / run-chair.js / run-debate.js)
83
+ // per the v4.3 Task 7 structural decision: those four files sit close to the
84
+ // 300-line hard gate, so every call site below is a thin wrapper over
85
+ // appendEvent — which already never throws — so every helper inherits that
86
+ // never-fails guarantee for free. Keep this module dependency-free (fs + path
87
+ // + logger only); do not import result-schema or anything heavier here.
88
+ //
89
+ // Task 13 dual-sink: every helper takes an OPTIONAL trailing `follow` arg
90
+ // ({onEvent(event)}, from observe/follow.js). appendEvent (disk) stays
91
+ // UNCONDITIONAL; when `follow` is present, the SAME raw event object (pre
92
+ // envelope) is also handed to follow.onEvent — a live stderr mirror, not a
93
+ // durable record. Callers that omit `follow` (every pre-Task-13 call site)
94
+ // get the old behavior unchanged.
95
+
96
+ /** Wave lifecycle start: models resolved (post-routing) + derived leg ids. */
97
+ function emitWaveStarted(waveDir, waveId, models, legIds, follow) {
98
+ const evt = { event: 'wave-started', id: waveId, models, legIds };
99
+ appendEvent(waveDir, evt);
100
+ if (follow) { follow.onEvent(evt); }
101
+ }
102
+
103
+ /** Wave lifecycle end: fires AFTER wave.json is written (ordering guarantee). */
104
+ function emitWaveTerminal(waveDir, waveId, { status, counts, usage, exitCode }, follow) {
105
+ const evt = { event: 'wave-terminal', id: waveId, status, counts, usage, exitCode };
106
+ appendEvent(waveDir, evt);
107
+ if (follow) { follow.onEvent(evt); }
108
+ }
109
+
110
+ /** Leg lifecycle start, into the OWNING wave's events.jsonl (not the leg dir). */
111
+ function emitLegStarted(waveDir, waveId, legId, model, modelInput, follow) {
112
+ const evt = { event: 'leg-started', id: waveId, legId, model, modelInput };
113
+ appendEvent(waveDir, evt);
114
+ if (follow) { follow.onEvent(evt); }
115
+ }
116
+
117
+ /** Leg lifecycle end: fires AFTER the leg metadata patch + ledger append. */
118
+ function emitLegTerminal(waveDir, waveId, legId, { model, status, durationMs, usage }, follow) {
119
+ const evt = { event: 'leg-terminal', id: waveId, legId, model, status, durationMs, usage };
120
+ appendEvent(waveDir, evt);
121
+ if (follow) { follow.onEvent(evt); }
122
+ }
123
+
124
+ /** Council run lifecycle start. */
125
+ function emitRunStarted(runDir, runId, { bench, chair }, follow) {
126
+ const evt = { event: 'run-started', id: runId, bench, chair };
127
+ appendEvent(runDir, evt);
128
+ if (follow) { follow.onEvent(evt); }
129
+ }
130
+
131
+ /** Entering a council stage (stage1, stage2, chair, debate-defense, debate-revote, tally, verdict, ...). */
132
+ function emitStageStarted(runDir, runId, stage, waveId, follow) {
133
+ const evt = { event: 'stage-started', id: runId, stage, waveId };
134
+ appendEvent(runDir, evt);
135
+ if (follow) { follow.onEvent(evt); }
136
+ }
137
+
138
+ /** Leaving a council stage (status: complete/error/skipped/aborted). */
139
+ function emitStageTerminal(runDir, runId, stage, status, waveId, follow) {
140
+ const evt = { event: 'stage-terminal', id: runId, stage, status, waveId };
141
+ appendEvent(runDir, evt);
142
+ if (follow) { follow.onEvent(evt); }
143
+ }
144
+
145
+ /** Council run lifecycle end: fires AFTER the terminal run.json checkpoint. */
146
+ function emitRunTerminal(runDir, runId, status, exitCode, follow) {
147
+ const evt = { event: 'run-terminal', id: runId, status, exitCode };
148
+ appendEvent(runDir, evt);
149
+ if (follow) { follow.onEvent(evt); }
150
+ }
151
+
152
+ module.exports = {
153
+ appendEvent, createEventTail, EVENTS_FILE, EVENTS_SCHEMA_VERSION,
154
+ emitWaveStarted, emitWaveTerminal, emitLegStarted, emitLegTerminal,
155
+ emitRunStarted, emitStageStarted, emitStageTerminal, emitRunTerminal,
156
+ };
@@ -0,0 +1,26 @@
1
+ // src/observe/follow.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/follow
6
+ * --follow (spec 5.2): stream a run's OWN events as they are emitted, to
7
+ * stderr — no tailing, the orchestrator is the emitter. json mode -> NDJSON
8
+ * event lines (CI: --json --follow 2>progress.ndjson); human mode -> terse
9
+ * per-event lines replacing the 15 s heartbeat table. stdout contracts stay
10
+ * byte-identical (the --json final doc / human summary are untouched).
11
+ */
12
+
13
+ const { renderPlainLines } = require('./watch-render');
14
+
15
+ function createFollowPrinter({ json, stream } = {}) {
16
+ const out = stream || process.stderr;
17
+ return {
18
+ onEvent(event) {
19
+ if (json) { out.write(JSON.stringify(event) + '\n'); return; }
20
+ const [line] = renderPlainLines([event], null);
21
+ if (line) { out.write(line + '\n'); }
22
+ },
23
+ };
24
+ }
25
+
26
+ module.exports = { createFollowPrinter };
@@ -0,0 +1,56 @@
1
+ // src/observe/live-doc.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/live-doc
6
+ * Surface C (spec 4.3): the composed live doc is the amicus_status rollup with
7
+ * an additive `view:'live'` marker + per-leg usage. This module owns the WAVE +
8
+ * single-session composed shape. (DE-ROT: the COUNCIL composed doc is built in
9
+ * src/mcp-council-awareness.js:buildCouncilStatusPayload, NOT here — Task 9 Step 5
10
+ * marks it live + adds per-stage usage there.) Cost is resolved at READ time from progress.json's raw
11
+ * usage (spec 4.1) so pricing stays current; cost-by-seat NEVER touches a
12
+ * ledger (A8). The `type` is deliberately NOT renamed (resolved Q8) — `view`
13
+ * disambiguates live composed docs from terminal wave.json/run.json.
14
+ */
15
+
16
+ const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
17
+
18
+ const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout']);
19
+
20
+ /**
21
+ * Attach read-time-resolved usage to a leg from its raw progress usage.
22
+ *
23
+ * v4.4.1 CA-1: the terminal progress record carries the leg's enumerated CHILD
24
+ * (subagent) session spend as `usage.subtree` / `usage.subtreeUnknown`
25
+ * (src/headless.js). Those must be forwarded, not dropped: the live workspace
26
+ * and `amicus watch` read progress.json directly, so silently keeping only
27
+ * {tokens, cost} here would make the GUI's cost-by-seat and its wave rollup
28
+ * disagree with run.json by exactly the child-session amount — reintroducing
29
+ * the under-report one surface down from where it was fixed.
30
+ */
31
+ function enrichLegUsage(leg, progressUsage) {
32
+ if (!progressUsage || !progressUsage.tokens) { return leg; }
33
+ const resolved = resolveUsage({
34
+ model: leg.model,
35
+ usageTotals: progressUsage,
36
+ subtree: progressUsage.subtree,
37
+ subtreeUnknown: progressUsage.subtreeUnknown,
38
+ });
39
+ const usage = { tokens: resolved.tokens, cost: resolved.cost };
40
+ if (resolved.subtree) { usage.subtree = resolved.subtree; }
41
+ if (resolved.subtreeUnknown) { usage.subtreeUnknown = true; }
42
+ return { ...leg, usage };
43
+ }
44
+
45
+ /** Stamp view:'live' on a non-terminal composed doc; no-op when terminal. */
46
+ function markLive(doc) {
47
+ if (doc && !TERMINAL.has(doc.status)) { doc.view = 'live'; }
48
+ return doc;
49
+ }
50
+
51
+ /** Sum enriched leg usage into a wave-level {tokens, cost} rollup. */
52
+ function rollupWaveUsage(legs) {
53
+ return sumWaveUsage((legs || []).map((l) => ({ usage: l.usage })));
54
+ }
55
+
56
+ module.exports = { enrichLegUsage, markLive, rollupWaveUsage, TERMINAL };
@@ -0,0 +1,117 @@
1
+ // src/observe/on-complete.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/on-complete
6
+ * CLI --on-complete exec hook (spec 5.3). The command is user-authored on the
7
+ * command line of THIS invocation — the same trust level as typing it into the
8
+ * shell. Amicus never sources hook commands from config/briefings/model output
9
+ * (D8 withdrawn) and never interpolates anything into the command string. The
10
+ * payload rides via ENVIRONMENT only, ids/paths only — never model-generated
11
+ * text — so untrusted previews can never enter a user's shell pipeline. The
12
+ * hook can never change the run's exit code, docs, or events. CLI-only: MCP
13
+ * never gets exec (Task 15 gives MCP a notify-only hook).
14
+ */
15
+
16
+ const HOOK_TIMEOUT_MS = Number(process.env.AMICUS_HOOK_TIMEOUT_MS) || 60000;
17
+
18
+ /** ids/paths ONLY (spec 5.3). Every value is a string; cost/paths default ''. */
19
+ function buildHookEnv(info) {
20
+ return {
21
+ AMICUS_TASK_ID: String(info.taskId || ''),
22
+ AMICUS_TYPE: String(info.type || ''),
23
+ AMICUS_STATUS: String(info.status || ''),
24
+ AMICUS_EXIT_CODE: String(info.exitCode !== null && info.exitCode !== undefined ? info.exitCode : ''),
25
+ AMICUS_RESULT_FILE: String(info.resultFile || ''),
26
+ AMICUS_EVENTS_FILE: String(info.eventsFile || ''),
27
+ AMICUS_COST: String(info.cost || ''),
28
+ AMICUS_PROJECT: String(info.project || ''),
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Fire the exec hook once at terminal state. Never throws/rejects; a non-zero
34
+ * exit or timeout is a warning only — the run's exit code/docs/events are
35
+ * never touched by this function.
36
+ */
37
+ function runOnComplete(cmd, info, deps = {}) {
38
+ const spawn = deps.spawn || require('child_process').spawn;
39
+ const logger = deps.logger || require('../utils/logger').logger;
40
+ const timeoutMs = deps.timeoutMs || HOOK_TIMEOUT_MS;
41
+ return new Promise((resolve) => {
42
+ let child;
43
+ try {
44
+ child = spawn(cmd, {
45
+ shell: true, cwd: info.project || process.cwd(),
46
+ env: { ...process.env, ...buildHookEnv(info) },
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ } catch (e) {
50
+ logger.warn('on-complete hook failed to spawn (run unaffected)', { error: e.message });
51
+ return resolve();
52
+ }
53
+ let done = false;
54
+ const finish = () => { if (!done) { done = true; clearTimeout(timer); resolve(); } };
55
+ const timer = setTimeout(() => {
56
+ logger.warn('on-complete hook timed out — killing (run unaffected)', { timeoutMs });
57
+ try { child.kill(); } catch { /* already gone */ }
58
+ finish();
59
+ }, timeoutMs);
60
+ if (child.stdout) { child.stdout.on('data', (d) => process.stderr.write(d)); }
61
+ if (child.stderr) { child.stderr.on('data', (d) => process.stderr.write(d)); }
62
+ child.on('error', (e) => { logger.warn('on-complete hook error (run unaffected)', { error: e.message }); finish(); });
63
+ child.on('close', (code) => {
64
+ if (code && code !== 0) { logger.warn('on-complete hook exited non-zero (run unaffected)', { code }); }
65
+ finish();
66
+ });
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Thin fire helper for fanout's two terminal sites (normal finalize + the
72
+ * all-legs-failed short-circuit). No-ops on a falsy/non-string cmd so call
73
+ * sites stay one line with no guard of their own.
74
+ */
75
+ async function fireWaveOnComplete(cmd, wave, { waveId, waveDir, wavePath, exitCode, project }, deps) {
76
+ if (!cmd || typeof cmd !== 'string') { return; }
77
+ try {
78
+ const path = require('path');
79
+ const { formatCost } = require('../utils/pricing');
80
+ const { EVENTS_FILE } = require('./events');
81
+ await runOnComplete(cmd, {
82
+ taskId: waveId, type: 'wave', status: wave.status, exitCode,
83
+ resultFile: wavePath, eventsFile: path.join(waveDir, EVENTS_FILE),
84
+ cost: wave.usage && wave.usage.cost ? formatCost(wave.usage.cost) : '',
85
+ project,
86
+ }, deps);
87
+ } catch (e) {
88
+ const logger = (deps && deps.logger) || require('../utils/logger').logger;
89
+ logger.warn('on-complete hook assembly failed (run unaffected)', { error: e.message });
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Thin fire helper for council run.js's single finalize choke point. No-ops
95
+ * on a falsy/non-string cmd so the call site stays one line.
96
+ */
97
+ async function fireCouncilOnComplete(cmd, run, { runId, runDir, exitCode, project }, deps) {
98
+ if (!cmd || typeof cmd !== 'string') { return; }
99
+ try {
100
+ const path = require('path');
101
+ const { formatCost } = require('../utils/pricing');
102
+ const { EVENTS_FILE } = require('./events');
103
+ await runOnComplete(cmd, {
104
+ taskId: runId, type: 'council-run', status: run.status, exitCode,
105
+ resultFile: path.join(runDir, 'run.json'), eventsFile: path.join(runDir, EVENTS_FILE),
106
+ cost: run.usage && run.usage.cost ? formatCost(run.usage.cost) : '',
107
+ project,
108
+ }, deps);
109
+ } catch (e) {
110
+ const logger = (deps && deps.logger) || require('../utils/logger').logger;
111
+ logger.warn('on-complete hook assembly failed (run unaffected)', { error: e.message });
112
+ }
113
+ }
114
+
115
+ module.exports = {
116
+ buildHookEnv, runOnComplete, fireWaveOnComplete, fireCouncilOnComplete, HOOK_TIMEOUT_MS,
117
+ };