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
|
@@ -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
|
+
};
|