amicus 4.3.0 → 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.md +6 -3
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +59 -3
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +277 -0
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- package/src/workspace/run-scan.js +148 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — fold payload builder (v4.4 §7).
|
|
3
|
+
*
|
|
4
|
+
* MIRRORS the shipped fold-header builder `formatFoldOutput`
|
|
5
|
+
* (src/headless.js `formatFoldOutput`, exported from src/headless.js and
|
|
6
|
+
* re-exported from src/index.js) — byte-for-byte the same **7-line** head:
|
|
7
|
+
* marker, Model, Session, Client, CWD, Mode, `---`. ⚠️ v4.4.1 DOC-5: this said
|
|
8
|
+
* "8-line" for two releases. Line 8 (`VERDICT:`) is council's OWN addition and
|
|
9
|
+
* has no counterpart in formatFoldOutput, whose 8th element is the summary
|
|
10
|
+
* body. Only the first 7 lines are the shared contract; anyone changing the
|
|
11
|
+
* shared format must sync those and leave `VERDICT:` alone.
|
|
12
|
+
* src/headless.js is the SOURCE OF TRUTH; keep the head in sync with it.
|
|
13
|
+
* (Line numbers deliberately omitted — the previous `:775-789`/`:797` citation
|
|
14
|
+
* had drifted by ~500 lines.) The duplication is deliberate:
|
|
15
|
+
* requiring headless.js transitively pulls opencode-client / progress /
|
|
16
|
+
* conversation-mirror at require time (src/headless.js:8-17), which
|
|
17
|
+
* src/workspace/ must stay free of.
|
|
18
|
+
* ⚠️ DE-ROT (F58): the header used to claim it "reuses the v4.0 marker/nonce
|
|
19
|
+
* contract exactly" while silently dropping formatFoldOutput's nonce-required
|
|
20
|
+
* throw (src/headless.js:776-778). The guard is restored in buildFoldText below.
|
|
21
|
+
* No model call — the chair result already exists on disk, so a workspace fold
|
|
22
|
+
* is a local read+format.
|
|
23
|
+
* The chair body is UNTRUSTED model text: it passes through stripFoldMarkers
|
|
24
|
+
* before embedding, so chair prose containing a marker can never truncate or
|
|
25
|
+
* spoof the fold (the exact hazard the nonce closure exists for).
|
|
26
|
+
* Degradation mirrors the engine's ladder: no chair → VERDICT: none + tally
|
|
27
|
+
* summary; pre-tally → stage/status summary. Never blocked, always labeled.
|
|
28
|
+
*/
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const { buildFoldMarker, stripFoldMarkers } = require('../utils/fold-marker');
|
|
32
|
+
const { formatCost } = require('../utils/pricing');
|
|
33
|
+
|
|
34
|
+
function ok(doc) { return doc && !doc.parseError ? doc : null; }
|
|
35
|
+
|
|
36
|
+
function tierLine(tierCounts) {
|
|
37
|
+
const t = tierCounts || {};
|
|
38
|
+
const n = (k) => (typeof t[k] === 'number' ? t[k] : 0);
|
|
39
|
+
return `Tiers: Confirmed ${n('Confirmed')} · Disputed ${n('Disputed')} · Contested ${n('Contested')} · Singleton ${n('Singleton')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stageSummary(run) {
|
|
43
|
+
const stages = Array.isArray(run.stages) ? run.stages : [];
|
|
44
|
+
return stages.length ? stages.map((s) => `${s.name}: ${s.status}`).join(' · ') : 'no stages recorded';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} o {nonce, project, run, tally?, verdict?, chairText?}
|
|
49
|
+
* @returns {string} the fold block (marker first line; no trailing newline)
|
|
50
|
+
*/
|
|
51
|
+
function buildFoldText(o) {
|
|
52
|
+
// ⚠️ DE-ROT (F58): mirror formatFoldOutput's v4.0 §9 guard (src/headless.js:776).
|
|
53
|
+
// Without it a missing nonce emits `[SIDECAR_FOLD:]`, which the hex-only marker
|
|
54
|
+
// regex (src/utils/fold-marker.js:68) never parses — a silently unfoldable block.
|
|
55
|
+
if (!o || !o.nonce) { throw new TypeError('buildFoldText requires a per-run nonce (v4.0 §9)'); }
|
|
56
|
+
const run = o.run || {};
|
|
57
|
+
const verdict = ok(o.verdict);
|
|
58
|
+
const tally = ok(o.tally);
|
|
59
|
+
// Review follow-up #2: verdict.json is NOT re-validated here (the
|
|
60
|
+
// amicus_verdict MCP path types overallVerdict as a bare z.string().nullable()
|
|
61
|
+
// — mcp-tools.js:428), so a multi-line or marker-bearing value must never
|
|
62
|
+
// reach the head verbatim: an embedded '\n' would shift every line below
|
|
63
|
+
// VERDICT: (a raw string containing '\n' becomes several elements once the
|
|
64
|
+
// head array is '\n'-joined), and an embedded marker could spoof the fold.
|
|
65
|
+
// Safe on the shipped engine path (parseChairVerdict returns a canonical
|
|
66
|
+
// CHAIR_VERDICTS phrase) — this is defense-in-depth, not a fix for a real
|
|
67
|
+
// producer.
|
|
68
|
+
const overall = verdict && verdict.overallVerdict
|
|
69
|
+
? stripFoldMarkers(String(verdict.overallVerdict)).replace(/[\r\n]+/g, ' ').trim()
|
|
70
|
+
: null;
|
|
71
|
+
const tierCounts = (verdict && verdict.tierCounts) || (tally && tally.tierCounts) || null;
|
|
72
|
+
const cost = run.usage && run.usage.cost ? run.usage.cost : null;
|
|
73
|
+
|
|
74
|
+
const head = [
|
|
75
|
+
buildFoldMarker(o.nonce),
|
|
76
|
+
`Model: ${run.chair || 'unknown'}`,
|
|
77
|
+
`Session: ${run.runId || 'unknown'}`,
|
|
78
|
+
'Client: council-workspace',
|
|
79
|
+
`CWD: ${o.project}`,
|
|
80
|
+
'Mode: council',
|
|
81
|
+
'---',
|
|
82
|
+
`VERDICT: ${overall || 'none'}`,
|
|
83
|
+
];
|
|
84
|
+
if (tierCounts) {
|
|
85
|
+
head.push(tierLine(tierCounts));
|
|
86
|
+
} else {
|
|
87
|
+
head.push(`Run: ${run.status || 'unknown'} — ${stageSummary(run)}`);
|
|
88
|
+
}
|
|
89
|
+
// ⚠️ v4.4.1 DOC-5: this used to append ` (${cost.source})` for EVERY source,
|
|
90
|
+
// on top of a glyph formatCost had already spent on the same fact —
|
|
91
|
+
// `~$0.0100 (estimated)`, `? (unknown)`. formatCost (src/utils/pricing.js)
|
|
92
|
+
// prefixes `~` for both 'estimated' and 'mixed', and returns a bare `?` when
|
|
93
|
+
// the source is 'unknown'. So for 'estimated' and 'unknown' the word was pure
|
|
94
|
+
// repetition and is gone.
|
|
95
|
+
//
|
|
96
|
+
// Two sources still spell themselves out, because the glyph vocabulary cannot
|
|
97
|
+
// express them:
|
|
98
|
+
// - `reported` — a plain `$0.4321` is also what an unrecognised/absent
|
|
99
|
+
// source renders as, so the absence of a glyph cannot mean "exact".
|
|
100
|
+
// - `mixed` — `~` encodes *inexact*, not *which kind of inexact*. Collapsing
|
|
101
|
+
// 'mixed' into a bare `~$…` makes it indistinguishable from 'estimated',
|
|
102
|
+
// and the two are not the same claim: 'mixed' means some legs reported
|
|
103
|
+
// real usage and some were estimated, i.e. part of this number is
|
|
104
|
+
// measured. That is strictly more information than 'estimated', and this
|
|
105
|
+
// is the fold line of a release whose theme is cost truthfulness.
|
|
106
|
+
// Each thing is still said exactly once — the `~` says "inexact", the word
|
|
107
|
+
// says "which kind", and neither restates the other.
|
|
108
|
+
const costSourceLabel = cost && (cost.source === 'reported' || cost.source === 'mixed')
|
|
109
|
+
? ` (${cost.source})`
|
|
110
|
+
: '';
|
|
111
|
+
head.push(`Cost: ${formatCost(cost)}${costSourceLabel}`);
|
|
112
|
+
|
|
113
|
+
// Review follow-up #1: strip FIRST, then test emptiness on the STRIPPED
|
|
114
|
+
// result — not the raw one. A chair body consisting solely of a marker
|
|
115
|
+
// line (plus whitespace) is non-empty raw but strips to '', and must fall
|
|
116
|
+
// back to the tally-summary label rather than embedding a blank body.
|
|
117
|
+
const stripped = o.chairText ? stripFoldMarkers(String(o.chairText)).trim() : '';
|
|
118
|
+
const body = stripped
|
|
119
|
+
|| (tierCounts ? '(no chair output — tally summary above)' : '(pre-tally: stage summary above)');
|
|
120
|
+
|
|
121
|
+
return `${head.join('\n')}\n${body}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { buildFoldText };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — live-doc normalization (v4.4 §3 A1/A2/A4 seam).
|
|
3
|
+
*
|
|
4
|
+
* ONE defensive mapping from the v4.3 composed live doc (the amicus_status
|
|
5
|
+
* rollup stamped view:'live') to the renderer's seat model. If the merged
|
|
6
|
+
* composed-doc shape ever drifts, THIS file moves and nothing else does.
|
|
7
|
+
* Liveness/staleness is passed through, never invented here (A4).
|
|
8
|
+
*
|
|
9
|
+
* The council composed doc (buildCouncilStatusPayload, src/mcp-council-
|
|
10
|
+
* awareness.js) is UNVERSIONED (F63) and carries: {taskId, type, runId,
|
|
11
|
+
* runDir, status, currentStage, stages:[{name,status,waveId}], legsTotal,
|
|
12
|
+
* legsComplete, elapsed, exitCode, version, view:'live', usage?, reason?,
|
|
13
|
+
* legs:[{taskId, model, modelInput, role, status, messages, stage,
|
|
14
|
+
* latestPreview, lastActivityAt, stalled, usage?}], stalled?,
|
|
15
|
+
* stalledForSeconds?}. Do NOT copy the WAVE doc's shape (src/mcp-server.js:
|
|
16
|
+
* 592-662) — that is a different document, gated on metadata.type === 'wave'.
|
|
17
|
+
*/
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const { formatCost } = require('../utils/pricing');
|
|
21
|
+
const { TERMINAL_STATUSES } = require('./run-detail');
|
|
22
|
+
|
|
23
|
+
// ⚠️ v4.4.1 RN-7: the `doc.wave.legs` fallback that used to sit here is GONE, deliberately.
|
|
24
|
+
// No producer nests legs under `wave` — the WAVE composed doc (src/mcp-server.js:592-662)
|
|
25
|
+
// carries a TOP-LEVEL `legs`, exactly like the council one, and this file's header already
|
|
26
|
+
// says not to model the wave doc at all. The arm asserted a shape that does not exist, and
|
|
27
|
+
// wsgate01's reviewer believed it. If a nested shape ever appears, add it back WITH a
|
|
28
|
+
// producer to point at.
|
|
29
|
+
function legRowsOf(doc) {
|
|
30
|
+
return Array.isArray(doc.legs) ? doc.legs : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function numOrNull(v) { return typeof v === 'number' ? v : null; }
|
|
34
|
+
|
|
35
|
+
function seatOf(leg) {
|
|
36
|
+
const usage = leg.usage || null;
|
|
37
|
+
const tokens = usage && usage.tokens ? usage.tokens : null;
|
|
38
|
+
return {
|
|
39
|
+
// ⚠️ v4.4.1 RN-7: `leg.legId` was a second dead fallback here — no leg row, council or
|
|
40
|
+
// wave, has ever carried it (src/observe/council-legs.js stamps `taskId`). Deleted.
|
|
41
|
+
id: leg.taskId || null,
|
|
42
|
+
// ⚠️ DE-ROT (F34/F36): `model` and `modelInput` are TWO SEPARATE fields, never collapsed.
|
|
43
|
+
// A live leg's `model` is the resolved executable id (e.g. `google/gemini-2.5`); `modelInput`
|
|
44
|
+
// is the council ALIAS (e.g. `gemini`) that run.json's labelMap and blind mode's labelFor()
|
|
45
|
+
// key on (src/council/anonymize.js:30 stamps labelMap values from the alias, never the
|
|
46
|
+
// resolved id). The already-shipped electron/workspace-ui/live-model.js:55 reads
|
|
47
|
+
// `seat.modelInput || seat.model` to pick the alias for its label lookup — collapsing the two
|
|
48
|
+
// into one field here would silently break blind mode (a resolved-id lookup never matches
|
|
49
|
+
// labelMap, leaking the real model id instead of degrading to a label or an em-dash).
|
|
50
|
+
model: leg.model || null,
|
|
51
|
+
modelInput: leg.modelInput || null,
|
|
52
|
+
role: leg.role || null,
|
|
53
|
+
status: leg.status || 'unknown',
|
|
54
|
+
// ⚠️ PRE-FLIGHT (P5): `leg.phase` is dead weight — Task 0.5 does not emit it. `leg.stage` IS
|
|
55
|
+
// emitted (src/observe/council-legs.js:88), so it is the only source; no fallback to invent.
|
|
56
|
+
stage: leg.stage || null,
|
|
57
|
+
messages: leg.messages === undefined ? null : leg.messages,
|
|
58
|
+
tokensIn: tokens ? numOrNull(tokens.input) : null,
|
|
59
|
+
tokensOut: tokens ? numOrNull(tokens.output) : null,
|
|
60
|
+
costDisplay: usage && usage.cost ? formatCost(usage.cost) : null,
|
|
61
|
+
// ⚠️ PRE-FLIGHT (P5): maps the ISO `lastActivityAt` only. `leg.lastActivity` /
|
|
62
|
+
// `leg.latestActivity` do not exist on a real leg row (`latestActivity` is an action LABEL —
|
|
63
|
+
// "Using <tool>" — not a time), so falling back to either would put prose in a timestamp
|
|
64
|
+
// column or leave it permanently null.
|
|
65
|
+
lastActivity: leg.lastActivityAt || null,
|
|
66
|
+
latestPreview: leg.latestPreview || null,
|
|
67
|
+
stalled: leg.stalled === true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How many legs in this stage rollup contributed NO amount (v4.4 §8). Read off
|
|
73
|
+
* sumWaveUsage's `unpricedLegs` (src/observe/live-doc.js rollupWaveUsage), which
|
|
74
|
+
* the composed doc already carries — nothing new is invented renderer-side.
|
|
75
|
+
*/
|
|
76
|
+
function liveUnknownLegs(doc) {
|
|
77
|
+
const c = doc.usage && doc.usage.cost ? doc.usage.cost : null;
|
|
78
|
+
return (c && typeof c.unpricedLegs === 'number') ? c.unpricedLegs : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How many legs in this rollup have an unattributed subagent SUBTREE (v4.4
|
|
83
|
+
* Task 2) — their own cost is known, but they spawned a child OpenCode session
|
|
84
|
+
* that is billed separately and never enumerated. Distinct from an unpriced leg,
|
|
85
|
+
* and the reason a fully-priced total can still be short.
|
|
86
|
+
*/
|
|
87
|
+
function liveSubtreeUnknownLegs(doc) {
|
|
88
|
+
const c = doc.usage && doc.usage.cost ? doc.usage.cost : null;
|
|
89
|
+
return (c && typeof c.subtreeUnknownLegs === 'number') ? c.subtreeUnknownLegs : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function liveCostDisplay(doc) {
|
|
93
|
+
if (!doc.usage || !doc.usage.cost) { return null; }
|
|
94
|
+
const base = formatCost(doc.usage.cost);
|
|
95
|
+
const parts = [];
|
|
96
|
+
const unknown = liveUnknownLegs(doc);
|
|
97
|
+
const subtree = liveSubtreeUnknownLegs(doc);
|
|
98
|
+
if (unknown > 0) { parts.push(`${unknown} unknown`); }
|
|
99
|
+
if (subtree > 0) { parts.push(`${subtree} subagent subtree`); }
|
|
100
|
+
return parts.length > 0 ? `${base} + ${parts.join(' + ')}` : base;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {object} doc composed live doc (amicus_status payload)
|
|
105
|
+
* @returns {object} LiveModel (see plan Shared contracts); {ok:false, error?} on junk
|
|
106
|
+
*/
|
|
107
|
+
function normalizeLive(doc) {
|
|
108
|
+
if (!doc || typeof doc !== 'object') { return { ok: false, error: 'no live doc' }; }
|
|
109
|
+
const stages = Array.isArray(doc.stages) ? doc.stages : null;
|
|
110
|
+
const active = stages ? (stages.find((s) => s.status === 'running') || null) : null;
|
|
111
|
+
const status = doc.status || 'unknown';
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
view: doc.view || null,
|
|
115
|
+
runId: doc.runId || doc.taskId || null,
|
|
116
|
+
status,
|
|
117
|
+
// ⚠️ v4.4.1 RN-7 (was PRE-FLIGHT P6): the `|| doc.currentStage` fallback is GONE. P6 had
|
|
118
|
+
// already corrected it once — from the phantom `doc.stage` to the real `currentStage` — and
|
|
119
|
+
// its own comment conceded the arm was "harmless today only by coincidence". It is in fact
|
|
120
|
+
// strictly unreachable: buildCouncilStatusPayload writes `stages` as `(run.stages || []).map(…)`
|
|
121
|
+
// (always an array) and derives `currentStage` from the SAME
|
|
122
|
+
// `stages.find(s => s.status === 'running')` predicate `active` uses one line up
|
|
123
|
+
// (src/mcp-council-awareness.js:155-157), so `active` is null in exactly the cases
|
|
124
|
+
// `currentStage` is too. Keeping it asserted a divergence between the two fields that no
|
|
125
|
+
// producer can create.
|
|
126
|
+
stageName: active ? active.name : null,
|
|
127
|
+
stages,
|
|
128
|
+
seats: legRowsOf(doc).map(seatOf),
|
|
129
|
+
// ⚠️ v4.4.1 RN-8 (D1 ruling: delete the promise). `legsTotal`/`legsComplete` used to be
|
|
130
|
+
// mapped here under a comment promising them as "the honest fallback readout if a seat row
|
|
131
|
+
// is ever unavailable" — a UI fallback nobody ever wired. Nothing in electron/workspace-ui/
|
|
132
|
+
// read either field. Documenting a feature that does not exist is worse than not having it,
|
|
133
|
+
// so the fields and the promise are both gone. ⚠️ The SAME names on the composed doc
|
|
134
|
+
// (src/mcp-council-awareness.js) and in src/cli-handlers-status.js / src/mcp-wait.js ARE
|
|
135
|
+
// consumed — this deletion is scoped to the workspace's LiveModel only. If a future task
|
|
136
|
+
// wants the counters in the GUI, re-add them WITH the renderer that paints them.
|
|
137
|
+
// ⚠️ DE-ROT (F39): these two are ACTIVE-STAGE spend, not the run total.
|
|
138
|
+
// buildCouncilStatusPayload rolls up only the legs of the currently-RUNNING stage's sub-waves
|
|
139
|
+
// (mcp-council-awareness.js:136-146) and omits `usage` entirely until one of those legs flushes
|
|
140
|
+
// progress.usage (:154), so the number under-reports and RESETS at each stage boundary; stages
|
|
141
|
+
// with no `project` (tally, verdict) contribute nothing. Decision: keep the field, LABEL it
|
|
142
|
+
// stage-scoped in the renderer (Task 15 suffixes the gauge text "(this stage)"). Do NOT try to
|
|
143
|
+
// add it onto `derived.cost.costAmount` — that is null for the entire life of the live loop
|
|
144
|
+
// (run-detail reads run.json's `usage`, which stays null until finalize(), run.js:98-102), so
|
|
145
|
+
// "adding" is just a rename of the same stage figure. The only run total is the terminal one.
|
|
146
|
+
// v4.4 §8: same treatment as the terminal panel (run-detail.js costPanel) —
|
|
147
|
+
// a stage rollup that omits unpriced legs reads as the full stage spend.
|
|
148
|
+
costDisplay: liveCostDisplay(doc),
|
|
149
|
+
costAmount: doc.usage && doc.usage.cost && typeof doc.usage.cost.amount === 'number' ? doc.usage.cost.amount : null,
|
|
150
|
+
costUnknownLegs: liveUnknownLegs(doc),
|
|
151
|
+
costSubtreeUnknownLegs: liveSubtreeUnknownLegs(doc),
|
|
152
|
+
costExact: liveUnknownLegs(doc) === 0 && liveSubtreeUnknownLegs(doc) === 0,
|
|
153
|
+
flags: {
|
|
154
|
+
// ⚠️ DE-ROT (F03): `crashed` exists nowhere on the composed doc — Task 0.5 deliberately did
|
|
155
|
+
// not add one (out of scope; see task-0.5-report.md). A crashed council instead flips
|
|
156
|
+
// `status` to 'error' and stamps `reason` from run.error
|
|
157
|
+
// (src/mcp-council-awareness.js:110-123) — that is the only real signal, so `crashed` is
|
|
158
|
+
// DERIVED from it here rather than read off a `doc.crashed` field that no real payload ever
|
|
159
|
+
// sets. This is still "never invented renderer-side" (A4): the derivation lives in this
|
|
160
|
+
// seam, not in electron/workspace-ui/*, and uses only fields the data layer actually wrote.
|
|
161
|
+
crashed: status === 'error' && Boolean(doc.reason),
|
|
162
|
+
stalled: doc.stalled === true,
|
|
163
|
+
stalledForSeconds: typeof doc.stalledForSeconds === 'number' ? doc.stalledForSeconds : null,
|
|
164
|
+
},
|
|
165
|
+
terminal: TERMINAL_STATUSES.includes(status),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { normalizeLive };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — adjudication matrix view model (v4.4 §5.2).
|
|
3
|
+
*
|
|
4
|
+
* Pure: tally.json + labelMap (+ verdict.json) → renderable rows/cells.
|
|
5
|
+
* Symbols come from council/report.js SYMBOL (single source — the report and
|
|
6
|
+
* the workspace can never disagree about what the symbols mean). Every
|
|
7
|
+
* name-bearing field carries BOTH spellings ({model, label}) so the
|
|
8
|
+
* renderer's blind toggle is a pure display flip with no re-fetch. Missing
|
|
9
|
+
* votes (partial waves) are blank cells, never invented neutrals — tier math
|
|
10
|
+
* already excluded them (v4.0).
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ DE-ROT (F07): `tally()` writes `tierOverride: null` on EVERY finding,
|
|
13
|
+
* unconditionally (src/council/tally.js:106) — it is never a real source for
|
|
14
|
+
* either the override badge or the post-override tier. Only `buildVerdict`
|
|
15
|
+
* materializes `{from,to,reason}` and rewrites `tier` to `tierOverride.to`
|
|
16
|
+
* (src/council/verdict.js:33-37). So both fields are joined in from
|
|
17
|
+
* verdict.findings[] by `id`; when verdict is absent/unparseable (caller
|
|
18
|
+
* passes null/undefined, or a finding has no verdict-side counterpart) the
|
|
19
|
+
* row falls back to tally's own (pre-override) tier and renders no badge.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const { SYMBOL } = require('../council/report');
|
|
24
|
+
const { pairFor } = require('./blind-mode');
|
|
25
|
+
|
|
26
|
+
/** Index verdict.findings[] by id, tolerating an absent/malformed verdict doc. */
|
|
27
|
+
function indexVerdictFindings(verdict) {
|
|
28
|
+
const byId = new Map();
|
|
29
|
+
if (verdict && Array.isArray(verdict.findings)) {
|
|
30
|
+
for (const vf of verdict.findings) {
|
|
31
|
+
if (vf && typeof vf.id === 'string') { byId.set(vf.id, vf); }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return byId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} tally parsed tally.json
|
|
39
|
+
* @param {object} labelMap run.json labelMap
|
|
40
|
+
* @param {object|null} [verdict] parsed verdict.json — source of truth for
|
|
41
|
+
* tierOverride and the post-override tier (⚠️ DE-ROT F07). Omitted, null,
|
|
42
|
+
* or a finding missing from it falls back to tally's tier with no badge.
|
|
43
|
+
* @returns {object} MatrixModel (see plan Shared contracts)
|
|
44
|
+
*/
|
|
45
|
+
function buildMatrixModel(tally, labelMap, verdict) {
|
|
46
|
+
const map = labelMap || {};
|
|
47
|
+
const judges = tally && tally.meta && Array.isArray(tally.meta.models) ? tally.meta.models : [];
|
|
48
|
+
const findings = tally && Array.isArray(tally.findings) ? tally.findings : [];
|
|
49
|
+
const verdictById = indexVerdictFindings(verdict);
|
|
50
|
+
|
|
51
|
+
const rows = findings.map((f) => {
|
|
52
|
+
const votes = {};
|
|
53
|
+
for (const adj of (Array.isArray(f.adjudications) ? f.adjudications : [])) {
|
|
54
|
+
if (!adj || typeof adj.judge !== 'string') { continue; }
|
|
55
|
+
votes[adj.judge] = adj.verdict;
|
|
56
|
+
}
|
|
57
|
+
const vf = verdictById.get(f.id);
|
|
58
|
+
return {
|
|
59
|
+
id: f.id,
|
|
60
|
+
severity: f.severity || null,
|
|
61
|
+
tier: (vf ? vf.tier : null) || f.tier || null,
|
|
62
|
+
thin: f.confidence === 'thin',
|
|
63
|
+
tierOverride: (vf && vf.tierOverride) || null,
|
|
64
|
+
// ⚠️ DE-ROT (F29): v4.1 decorates tally.json findings in place with
|
|
65
|
+
// `debate: {action, previousTier}` (src/council/debate.js:71-75; action ∈
|
|
66
|
+
// defended|amended|withdrawn|no-response) and verdict.json carries it through
|
|
67
|
+
// (src/council/verdict.js:43). Consumed by electron/workspace-ui/workspace-matrix.js's
|
|
68
|
+
// renderMatrix, which renders a `.debate-badge` in the tier cell (alongside the
|
|
69
|
+
// thin/tierOverride badges) so a withdrawn/amended/defended/no-response finding never
|
|
70
|
+
// renders as an ordinary live row. Absent on non-debate runs, hence `|| null`.
|
|
71
|
+
debate: f.debate || null,
|
|
72
|
+
raiser: pairFor(f.raiser, map),
|
|
73
|
+
basis: f.basis || { a: 0, d: 0, n: 0 },
|
|
74
|
+
cells: judges.map((j) => {
|
|
75
|
+
const vote = Object.prototype.hasOwnProperty.call(votes, j) ? votes[j] : null;
|
|
76
|
+
return {
|
|
77
|
+
judge: pairFor(j, map),
|
|
78
|
+
verdict: vote,
|
|
79
|
+
sym: vote ? (SYMBOL[vote] || '?') : ' ',
|
|
80
|
+
isRaiser: j === f.raiser,
|
|
81
|
+
};
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
judges: judges.map((j) => pairFor(j, map)),
|
|
88
|
+
rows,
|
|
89
|
+
tierCounts: (tally && tally.tierCounts) || null,
|
|
90
|
+
judged: !(tally && tally.judged === false),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { buildMatrixModel };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — run detail: defensive parse of run.json / tally.json /
|
|
3
|
+
* verdict.json + the derived view models the renderer paints (v4.4 §4.5
|
|
4
|
+
* workspace:get-run, §5.2, §9). Malformed JSON yields {parseError, rawPath}
|
|
5
|
+
* per document — one bad file never blanks the whole run (spec §9 row 1).
|
|
6
|
+
* Read-only (§6.2).
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { formatCost } = require('../utils/pricing');
|
|
13
|
+
const { readPointer } = require('./run-scan');
|
|
14
|
+
const { buildNamePairs } = require('./blind-mode');
|
|
15
|
+
const { buildMatrixModel } = require('./matrix-model');
|
|
16
|
+
const { artifactAllowlist } = require('./artifact-guard');
|
|
17
|
+
const { isRealpathContained } = require('../utils/path-fence');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shared terminal-status list (also mirrored renderer-side in live-model.js).
|
|
21
|
+
* ⚠️ DE-ROT (F26): deliberate HAND-COPY of src/observe/live-doc.js:18 `TERMINAL`,
|
|
22
|
+
* names and order byte-identical. Do NOT require() it — Task 3 is Phase 1
|
|
23
|
+
* ("zero v4.3") and live-doc.js is v4.3. Not to be confused with the shipped
|
|
24
|
+
* src/utils/result-schema.js:13 TERMINAL_STATUSES, which is the LEG set (no 'partial').
|
|
25
|
+
*/
|
|
26
|
+
// ⚠️ v4.4.1 A1: 'timed-out' added alongside 'timeout' — see the long note at
|
|
27
|
+
// src/observe/live-doc.js:18 for why both spellings are real and which producer writes each.
|
|
28
|
+
// Council run.json only ever carries aborted|complete|error|partial (run-finalize.js:38
|
|
29
|
+
// statusForExit), so the new name is inert for THIS module's own reads; it is carried anyway
|
|
30
|
+
// because the drift pin demands byte-identity with the source list, and that pin is the only
|
|
31
|
+
// thing keeping the three copies honest.
|
|
32
|
+
const TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'timed-out', 'idle-timeout'];
|
|
33
|
+
|
|
34
|
+
/** Friendly labels for known v4.0 stage names; unknown names pass through raw
|
|
35
|
+
* — the graceful-when-present rule (spec §5.2). */
|
|
36
|
+
// ⚠️ PRE-FLIGHT (P1): this table was a DIFFERENT 7-key table whose keys and values both
|
|
37
|
+
// disagreed with Task 12's renderer mirror — while Task 12 Step 1 pins them with
|
|
38
|
+
// `expect(STAGE_LABELS).toEqual(rd.STAGE_LABELS)` (deep equality, guaranteed red). It also
|
|
39
|
+
// carried `repairs` and `debate`, two names DE-ROT F10 proved the engine NEVER writes.
|
|
40
|
+
// Task 12's table is now the single source and is reproduced here byte-for-byte. If you edit
|
|
41
|
+
// one, edit both — the drift pin is the only thing keeping them honest.
|
|
42
|
+
const STAGE_LABELS = {
|
|
43
|
+
stage1: 'Stage 1 — independent review',
|
|
44
|
+
stage2: 'Stage 2 — peer cross-review',
|
|
45
|
+
'debate-defense': 'Debate — defense',
|
|
46
|
+
'debate-revote': 'Debate — re-vote',
|
|
47
|
+
'tally-provisional': 'Tally (provisional)',
|
|
48
|
+
tally: 'Tally',
|
|
49
|
+
'tally-final': 'Tally (final)',
|
|
50
|
+
chair: 'Chair synthesis',
|
|
51
|
+
verdict: 'Verdict',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function readDoc(runDir, name) {
|
|
55
|
+
const rawPath = path.join(runDir, name);
|
|
56
|
+
if (!fs.existsSync(rawPath)) { return null; }
|
|
57
|
+
try { return JSON.parse(fs.readFileSync(rawPath, 'utf-8')); }
|
|
58
|
+
catch (err) { return { parseError: err.message, rawPath }; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stageRail(run) {
|
|
62
|
+
const stages = Array.isArray(run.stages) ? run.stages : [];
|
|
63
|
+
return stages.filter((s) => s && typeof s === 'object').map((s) => ({
|
|
64
|
+
name: s.name,
|
|
65
|
+
label: STAGE_LABELS[s.name] || String(s.name),
|
|
66
|
+
status: s.status || 'pending',
|
|
67
|
+
startedAt: s.startedAt || null,
|
|
68
|
+
completedAt: s.completedAt || null,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function costPanel(run, tally) {
|
|
73
|
+
const stats = tally && Array.isArray(tally.runStats) ? tally.runStats : [];
|
|
74
|
+
const rows = stats.map((r) => ({
|
|
75
|
+
model: r.model,
|
|
76
|
+
role: r.role || (r.wasChair ? 'chair' : 'seat'),
|
|
77
|
+
status: r.status || 'unknown',
|
|
78
|
+
durationMs: r.durationMs === undefined ? null : r.durationMs,
|
|
79
|
+
costDisplay: formatCost(r.usage && r.usage.cost),
|
|
80
|
+
}));
|
|
81
|
+
const cost = run.usage && run.usage.cost ? run.usage.cost : null;
|
|
82
|
+
// v4.4 §8: the run total must not read as exact when any seat is unpriced.
|
|
83
|
+
// `run.usage.unknownLegs` is the v4.4 field src/council/run.js stamps; a run
|
|
84
|
+
// written before that (or by any other producer) still carries the count inside
|
|
85
|
+
// sumWaveUsage's `cost.unpricedLegs`, so read that as the fallback rather than
|
|
86
|
+
// silently claiming exactness for every historical run on disk.
|
|
87
|
+
const unknownLegs = run.usage && typeof run.usage.unknownLegs === 'number'
|
|
88
|
+
? run.usage.unknownLegs
|
|
89
|
+
: ((cost && cost.unpricedLegs) || 0);
|
|
90
|
+
// v4.4 Task 2: a leg whose own cost is `reported` can STILL leave the run total
|
|
91
|
+
// short — a subagent's child session is billed separately and never enumerated
|
|
92
|
+
// (`council-wsgate01`: 7/7 legs reported, $0.0215 short, 100% one child session).
|
|
93
|
+
const subtreeUnknownLegs = run.usage && typeof run.usage.subtreeUnknownLegs === 'number'
|
|
94
|
+
? run.usage.subtreeUnknownLegs
|
|
95
|
+
: ((cost && cost.subtreeUnknownLegs) || 0);
|
|
96
|
+
const total = formatCost(cost);
|
|
97
|
+
const suffixes = [];
|
|
98
|
+
if (unknownLegs > 0) { suffixes.push(`${unknownLegs} unknown`); }
|
|
99
|
+
if (subtreeUnknownLegs > 0) { suffixes.push(`${subtreeUnknownLegs} subagent subtree`); }
|
|
100
|
+
return {
|
|
101
|
+
rows,
|
|
102
|
+
totalDisplay: suffixes.length > 0 ? `${total} + ${suffixes.join(' + ')}` : total,
|
|
103
|
+
costAmount: cost && typeof cost.amount === 'number' ? cost.amount : null,
|
|
104
|
+
// The gauge's guard (workspace-render.js renderGauge): false means "the
|
|
105
|
+
// percentage below is a LOWER BOUND", so it must render indeterminate.
|
|
106
|
+
// PREFER the producer's own flag when run.json carries it — recomputing it
|
|
107
|
+
// from `unknownLegs` alone would silently re-assert exactness for a run whose
|
|
108
|
+
// writer already determined the total is incomplete for a different reason.
|
|
109
|
+
costExact: run.usage && typeof run.usage.costExact === 'boolean'
|
|
110
|
+
? run.usage.costExact
|
|
111
|
+
: (unknownLegs === 0 && subtreeUnknownLegs === 0),
|
|
112
|
+
unknownLegs,
|
|
113
|
+
subtreeUnknownLegs,
|
|
114
|
+
maxCost: run.options && typeof run.options.maxCost === 'number' ? run.options.maxCost : null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ⚠️ PRE-FLIGHT (P3): F04's correction is implemented here rather than left as prose.
|
|
119
|
+
// VERIFIED on shipped main (Task 0): `finalize(exitCode, error)` writes `error: error || null`
|
|
120
|
+
// (src/council/run.js:98-100), and `return finalize(degraded.value ? 2 : 0)` (:293) is the ONLY
|
|
121
|
+
// exit-2 path — it passes NO error. Every error-bearing call is `finalize(1, …)`. So on a
|
|
122
|
+
// `status:'partial'` run — precisely the run this panel exists to explain — `run.error` is
|
|
123
|
+
// GUARANTEED null, and the old one-line formula rendered "undefined: undefined".
|
|
124
|
+
// Name the stage instead. Stage status is a closed set (DE-ROT F19): running / complete /
|
|
125
|
+
// skipped / error, and `run-chair.js:114` writes 'error' for a chair that failed after retry +
|
|
126
|
+
// fallback promotion, 'skipped' (:89) for one the cost ceiling skipped.
|
|
127
|
+
function degradedReason(run) {
|
|
128
|
+
// exit-1 path: the engine wrote a structured {code, message}.
|
|
129
|
+
if (run.error && run.error.code) { return `${run.error.code}: ${run.error.message}`; }
|
|
130
|
+
const stages = Array.isArray(run.stages) ? run.stages : [];
|
|
131
|
+
const failed = stages.find((s) => s && s.status === 'error');
|
|
132
|
+
if (failed) { return `${STAGE_LABELS[failed.name] || failed.name} stage failed`; }
|
|
133
|
+
const skipped = stages.find((s) => s && s.status === 'skipped');
|
|
134
|
+
if (skipped) { return `${STAGE_LABELS[skipped.name] || skipped.name} stage was skipped (cost ceiling)`; }
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function verdictPanel(run, verdict) {
|
|
139
|
+
const reason = degradedReason(run);
|
|
140
|
+
if (!verdict || verdict.parseError) {
|
|
141
|
+
return { present: false, overallVerdict: null, tierCounts: null, streetCred: [], decisions: [], reason };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
present: true,
|
|
145
|
+
overallVerdict: verdict.overallVerdict === undefined ? null : verdict.overallVerdict,
|
|
146
|
+
tierCounts: verdict.tierCounts || null,
|
|
147
|
+
streetCred: Array.isArray(verdict.streetCred) ? verdict.streetCred : [],
|
|
148
|
+
decisions: (Array.isArray(verdict.findings) ? verdict.findings : [])
|
|
149
|
+
.filter((f) => f && f.decision)
|
|
150
|
+
.map((f) => ({ id: f.id, decision: f.decision, applied: f.applied === true })),
|
|
151
|
+
reason,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} project
|
|
157
|
+
* @param {string} runId (with or without the council- prefix)
|
|
158
|
+
* @returns {object} RunDetail (see plan Shared contracts)
|
|
159
|
+
*/
|
|
160
|
+
function getRunDetail(project, runId) {
|
|
161
|
+
const ptr = readPointer(project, runId);
|
|
162
|
+
if (ptr.error) { return { runId: ptr.runId, error: ptr.error }; }
|
|
163
|
+
const runDir = ptr.runDir;
|
|
164
|
+
|
|
165
|
+
// Outer containment fence (third council-review pass): readPointer's shipped
|
|
166
|
+
// implementation (src/council/run-state.js:133-139) validates the pointer file's
|
|
167
|
+
// {runId, runDir} JSON only for truthiness, so a tampered or stale pointer can point
|
|
168
|
+
// runDir anywhere on disk. Mirrors src/workspace/artifact-guard.js's readRunArtifact
|
|
169
|
+
// outer fence and electron/ipc-workspace.js's workspace:open-report fence — same
|
|
170
|
+
// isRealpathContained helper (src/utils/path-fence.js), same check. Own
|
|
171
|
+
// distinguishable error string: getRunDetail's other error shapes ('run.json missing',
|
|
172
|
+
// the readPointer-sourced messages) are asserted by name in several suites and must
|
|
173
|
+
// not collide with this one. Checked BEFORE any read reaches the filesystem, unlike
|
|
174
|
+
// readRunArtifact (which reads run.json first) — getRunDetail has no allowlist-shaped
|
|
175
|
+
// reason to read anything from an escaping runDir at all.
|
|
176
|
+
let realProject, realRunDir;
|
|
177
|
+
try { realProject = fs.realpathSync(project); }
|
|
178
|
+
catch (err) { return { runId: ptr.runId, runDir, error: `project unreadable: ${err.message}` }; }
|
|
179
|
+
try { realRunDir = fs.realpathSync(runDir); }
|
|
180
|
+
catch (err) { return { runId: ptr.runId, runDir, error: `run dir unreadable: ${err.message}` }; }
|
|
181
|
+
if (!isRealpathContained(realProject, realRunDir)) {
|
|
182
|
+
return { runId: ptr.runId, runDir, error: 'run directory escapes project' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const run = readDoc(runDir, 'run.json');
|
|
186
|
+
if (!run) { return { runId: ptr.runId, runDir, error: 'run.json missing' }; }
|
|
187
|
+
const tally = readDoc(runDir, 'tally.json');
|
|
188
|
+
const verdict = readDoc(runDir, 'verdict.json');
|
|
189
|
+
|
|
190
|
+
let derived = null;
|
|
191
|
+
// Computed once, outside the `!run.parseError` guard's block so both the derived model
|
|
192
|
+
// (artifactCollisions) and the artifacts presence map below reuse the same call.
|
|
193
|
+
const artifactNames = run.parseError ? [] : artifactAllowlist(run);
|
|
194
|
+
if (!run.parseError) {
|
|
195
|
+
const labelMap = run.labelMap && typeof run.labelMap === 'object' ? run.labelMap : {};
|
|
196
|
+
const tallyOk = tally && !tally.parseError ? tally : null;
|
|
197
|
+
derived = {
|
|
198
|
+
schemaSupported: run.schemaVersion === 2,
|
|
199
|
+
names: buildNamePairs(labelMap),
|
|
200
|
+
stageRail: stageRail(run),
|
|
201
|
+
// ⚠️ DE-ROT (F07): 3-arg signature — tierOverride + post-override tier live in verdict.json.
|
|
202
|
+
matrix: tallyOk ? buildMatrixModel(tallyOk, labelMap, verdict && !verdict.parseError ? verdict : null) : null,
|
|
203
|
+
cost: costPanel(run, tallyOk),
|
|
204
|
+
verdictPanel: verdictPanel(run, verdict),
|
|
205
|
+
// ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): two distinct bench
|
|
206
|
+
// entries that sanitize to the same artifact name (artifact-guard.js's
|
|
207
|
+
// artifactAllowlist) is a run-integrity defect — this run directory cannot hold both
|
|
208
|
+
// models' review/judge files under distinct names, so drillIntoJudge's artifact lookup
|
|
209
|
+
// would otherwise silently misattribute prose. Surfaced here (rather than only inside
|
|
210
|
+
// the low-level allowlist helper) so the renderer can warn the user directly.
|
|
211
|
+
artifactCollisions: artifactNames.collisions || [],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const artifacts = {};
|
|
216
|
+
const names = artifactNames;
|
|
217
|
+
for (const name of [...names, 'report.html', 'run.json', 'tally.json', 'verdict.json']) {
|
|
218
|
+
try {
|
|
219
|
+
const st = fs.statSync(path.join(runDir, name));
|
|
220
|
+
artifacts[name] = { present: true, bytes: st.size };
|
|
221
|
+
} catch {
|
|
222
|
+
artifacts[name] = { present: false, bytes: 0 };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { runId: ptr.runId, runDir, run, tally, verdict, artifacts, derived };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { getRunDetail, costPanel, TERMINAL_STATUSES, STAGE_LABELS };
|