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,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,223 @@
|
|
|
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
|
+
const TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout'];
|
|
27
|
+
|
|
28
|
+
/** Friendly labels for known v4.0 stage names; unknown names pass through raw
|
|
29
|
+
* — the graceful-when-present rule (spec §5.2). */
|
|
30
|
+
// ⚠️ PRE-FLIGHT (P1): this table was a DIFFERENT 7-key table whose keys and values both
|
|
31
|
+
// disagreed with Task 12's renderer mirror — while Task 12 Step 1 pins them with
|
|
32
|
+
// `expect(STAGE_LABELS).toEqual(rd.STAGE_LABELS)` (deep equality, guaranteed red). It also
|
|
33
|
+
// carried `repairs` and `debate`, two names DE-ROT F10 proved the engine NEVER writes.
|
|
34
|
+
// Task 12's table is now the single source and is reproduced here byte-for-byte. If you edit
|
|
35
|
+
// one, edit both — the drift pin is the only thing keeping them honest.
|
|
36
|
+
const STAGE_LABELS = {
|
|
37
|
+
stage1: 'Stage 1 — independent review',
|
|
38
|
+
stage2: 'Stage 2 — peer cross-review',
|
|
39
|
+
'debate-defense': 'Debate — defense',
|
|
40
|
+
'debate-revote': 'Debate — re-vote',
|
|
41
|
+
'tally-provisional': 'Tally (provisional)',
|
|
42
|
+
tally: 'Tally',
|
|
43
|
+
'tally-final': 'Tally (final)',
|
|
44
|
+
chair: 'Chair synthesis',
|
|
45
|
+
verdict: 'Verdict',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function readDoc(runDir, name) {
|
|
49
|
+
const rawPath = path.join(runDir, name);
|
|
50
|
+
if (!fs.existsSync(rawPath)) { return null; }
|
|
51
|
+
try { return JSON.parse(fs.readFileSync(rawPath, 'utf-8')); }
|
|
52
|
+
catch (err) { return { parseError: err.message, rawPath }; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stageRail(run) {
|
|
56
|
+
const stages = Array.isArray(run.stages) ? run.stages : [];
|
|
57
|
+
return stages.filter((s) => s && typeof s === 'object').map((s) => ({
|
|
58
|
+
name: s.name,
|
|
59
|
+
label: STAGE_LABELS[s.name] || String(s.name),
|
|
60
|
+
status: s.status || 'pending',
|
|
61
|
+
startedAt: s.startedAt || null,
|
|
62
|
+
completedAt: s.completedAt || null,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function costPanel(run, tally) {
|
|
67
|
+
const stats = tally && Array.isArray(tally.runStats) ? tally.runStats : [];
|
|
68
|
+
const rows = stats.map((r) => ({
|
|
69
|
+
model: r.model,
|
|
70
|
+
role: r.role || (r.wasChair ? 'chair' : 'seat'),
|
|
71
|
+
status: r.status || 'unknown',
|
|
72
|
+
durationMs: r.durationMs === undefined ? null : r.durationMs,
|
|
73
|
+
costDisplay: formatCost(r.usage && r.usage.cost),
|
|
74
|
+
}));
|
|
75
|
+
const cost = run.usage && run.usage.cost ? run.usage.cost : null;
|
|
76
|
+
// v4.4 §8: the run total must not read as exact when any seat is unpriced.
|
|
77
|
+
// `run.usage.unknownLegs` is the v4.4 field src/council/run.js stamps; a run
|
|
78
|
+
// written before that (or by any other producer) still carries the count inside
|
|
79
|
+
// sumWaveUsage's `cost.unpricedLegs`, so read that as the fallback rather than
|
|
80
|
+
// silently claiming exactness for every historical run on disk.
|
|
81
|
+
const unknownLegs = run.usage && typeof run.usage.unknownLegs === 'number'
|
|
82
|
+
? run.usage.unknownLegs
|
|
83
|
+
: ((cost && cost.unpricedLegs) || 0);
|
|
84
|
+
// v4.4 Task 2: a leg whose own cost is `reported` can STILL leave the run total
|
|
85
|
+
// short — a subagent's child session is billed separately and never enumerated
|
|
86
|
+
// (`council-wsgate01`: 7/7 legs reported, $0.0215 short, 100% one child session).
|
|
87
|
+
const subtreeUnknownLegs = run.usage && typeof run.usage.subtreeUnknownLegs === 'number'
|
|
88
|
+
? run.usage.subtreeUnknownLegs
|
|
89
|
+
: ((cost && cost.subtreeUnknownLegs) || 0);
|
|
90
|
+
const total = formatCost(cost);
|
|
91
|
+
const suffixes = [];
|
|
92
|
+
if (unknownLegs > 0) { suffixes.push(`${unknownLegs} unknown`); }
|
|
93
|
+
if (subtreeUnknownLegs > 0) { suffixes.push(`${subtreeUnknownLegs} subagent subtree`); }
|
|
94
|
+
return {
|
|
95
|
+
rows,
|
|
96
|
+
totalDisplay: suffixes.length > 0 ? `${total} + ${suffixes.join(' + ')}` : total,
|
|
97
|
+
costAmount: cost && typeof cost.amount === 'number' ? cost.amount : null,
|
|
98
|
+
// The gauge's guard (workspace-render.js renderGauge): false means "the
|
|
99
|
+
// percentage below is a LOWER BOUND", so it must render indeterminate.
|
|
100
|
+
// PREFER the producer's own flag when run.json carries it — recomputing it
|
|
101
|
+
// from `unknownLegs` alone would silently re-assert exactness for a run whose
|
|
102
|
+
// writer already determined the total is incomplete for a different reason.
|
|
103
|
+
costExact: run.usage && typeof run.usage.costExact === 'boolean'
|
|
104
|
+
? run.usage.costExact
|
|
105
|
+
: (unknownLegs === 0 && subtreeUnknownLegs === 0),
|
|
106
|
+
unknownLegs,
|
|
107
|
+
subtreeUnknownLegs,
|
|
108
|
+
maxCost: run.options && typeof run.options.maxCost === 'number' ? run.options.maxCost : null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ⚠️ PRE-FLIGHT (P3): F04's correction is implemented here rather than left as prose.
|
|
113
|
+
// VERIFIED on shipped main (Task 0): `finalize(exitCode, error)` writes `error: error || null`
|
|
114
|
+
// (src/council/run.js:98-100), and `return finalize(degraded.value ? 2 : 0)` (:293) is the ONLY
|
|
115
|
+
// exit-2 path — it passes NO error. Every error-bearing call is `finalize(1, …)`. So on a
|
|
116
|
+
// `status:'partial'` run — precisely the run this panel exists to explain — `run.error` is
|
|
117
|
+
// GUARANTEED null, and the old one-line formula rendered "undefined: undefined".
|
|
118
|
+
// Name the stage instead. Stage status is a closed set (DE-ROT F19): running / complete /
|
|
119
|
+
// skipped / error, and `run-chair.js:114` writes 'error' for a chair that failed after retry +
|
|
120
|
+
// fallback promotion, 'skipped' (:89) for one the cost ceiling skipped.
|
|
121
|
+
function degradedReason(run) {
|
|
122
|
+
// exit-1 path: the engine wrote a structured {code, message}.
|
|
123
|
+
if (run.error && run.error.code) { return `${run.error.code}: ${run.error.message}`; }
|
|
124
|
+
const stages = Array.isArray(run.stages) ? run.stages : [];
|
|
125
|
+
const failed = stages.find((s) => s && s.status === 'error');
|
|
126
|
+
if (failed) { return `${STAGE_LABELS[failed.name] || failed.name} stage failed`; }
|
|
127
|
+
const skipped = stages.find((s) => s && s.status === 'skipped');
|
|
128
|
+
if (skipped) { return `${STAGE_LABELS[skipped.name] || skipped.name} stage was skipped (cost ceiling)`; }
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function verdictPanel(run, verdict) {
|
|
133
|
+
const reason = degradedReason(run);
|
|
134
|
+
if (!verdict || verdict.parseError) {
|
|
135
|
+
return { present: false, overallVerdict: null, tierCounts: null, streetCred: [], decisions: [], reason };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
present: true,
|
|
139
|
+
overallVerdict: verdict.overallVerdict === undefined ? null : verdict.overallVerdict,
|
|
140
|
+
tierCounts: verdict.tierCounts || null,
|
|
141
|
+
streetCred: Array.isArray(verdict.streetCred) ? verdict.streetCred : [],
|
|
142
|
+
decisions: (Array.isArray(verdict.findings) ? verdict.findings : [])
|
|
143
|
+
.filter((f) => f && f.decision)
|
|
144
|
+
.map((f) => ({ id: f.id, decision: f.decision, applied: f.applied === true })),
|
|
145
|
+
reason,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} project
|
|
151
|
+
* @param {string} runId (with or without the council- prefix)
|
|
152
|
+
* @returns {object} RunDetail (see plan Shared contracts)
|
|
153
|
+
*/
|
|
154
|
+
function getRunDetail(project, runId) {
|
|
155
|
+
const ptr = readPointer(project, runId);
|
|
156
|
+
if (ptr.error) { return { runId: ptr.runId, error: ptr.error }; }
|
|
157
|
+
const runDir = ptr.runDir;
|
|
158
|
+
|
|
159
|
+
// Outer containment fence (third council-review pass): readPointer's shipped
|
|
160
|
+
// implementation (src/council/run-state.js:133-139) validates the pointer file's
|
|
161
|
+
// {runId, runDir} JSON only for truthiness, so a tampered or stale pointer can point
|
|
162
|
+
// runDir anywhere on disk. Mirrors src/workspace/artifact-guard.js's readRunArtifact
|
|
163
|
+
// outer fence and electron/ipc-workspace.js's workspace:open-report fence — same
|
|
164
|
+
// isRealpathContained helper (src/utils/path-fence.js), same check. Own
|
|
165
|
+
// distinguishable error string: getRunDetail's other error shapes ('run.json missing',
|
|
166
|
+
// the readPointer-sourced messages) are asserted by name in several suites and must
|
|
167
|
+
// not collide with this one. Checked BEFORE any read reaches the filesystem, unlike
|
|
168
|
+
// readRunArtifact (which reads run.json first) — getRunDetail has no allowlist-shaped
|
|
169
|
+
// reason to read anything from an escaping runDir at all.
|
|
170
|
+
let realProject, realRunDir;
|
|
171
|
+
try { realProject = fs.realpathSync(project); }
|
|
172
|
+
catch (err) { return { runId: ptr.runId, runDir, error: `project unreadable: ${err.message}` }; }
|
|
173
|
+
try { realRunDir = fs.realpathSync(runDir); }
|
|
174
|
+
catch (err) { return { runId: ptr.runId, runDir, error: `run dir unreadable: ${err.message}` }; }
|
|
175
|
+
if (!isRealpathContained(realProject, realRunDir)) {
|
|
176
|
+
return { runId: ptr.runId, runDir, error: 'run directory escapes project' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const run = readDoc(runDir, 'run.json');
|
|
180
|
+
if (!run) { return { runId: ptr.runId, runDir, error: 'run.json missing' }; }
|
|
181
|
+
const tally = readDoc(runDir, 'tally.json');
|
|
182
|
+
const verdict = readDoc(runDir, 'verdict.json');
|
|
183
|
+
|
|
184
|
+
let derived = null;
|
|
185
|
+
// Computed once, outside the `!run.parseError` guard's block so both the derived model
|
|
186
|
+
// (artifactCollisions) and the artifacts presence map below reuse the same call.
|
|
187
|
+
const artifactNames = run.parseError ? [] : artifactAllowlist(run);
|
|
188
|
+
if (!run.parseError) {
|
|
189
|
+
const labelMap = run.labelMap && typeof run.labelMap === 'object' ? run.labelMap : {};
|
|
190
|
+
const tallyOk = tally && !tally.parseError ? tally : null;
|
|
191
|
+
derived = {
|
|
192
|
+
schemaSupported: run.schemaVersion === 2,
|
|
193
|
+
names: buildNamePairs(labelMap),
|
|
194
|
+
stageRail: stageRail(run),
|
|
195
|
+
// ⚠️ DE-ROT (F07): 3-arg signature — tierOverride + post-override tier live in verdict.json.
|
|
196
|
+
matrix: tallyOk ? buildMatrixModel(tallyOk, labelMap, verdict && !verdict.parseError ? verdict : null) : null,
|
|
197
|
+
cost: costPanel(run, tallyOk),
|
|
198
|
+
verdictPanel: verdictPanel(run, verdict),
|
|
199
|
+
// ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): two distinct bench
|
|
200
|
+
// entries that sanitize to the same artifact name (artifact-guard.js's
|
|
201
|
+
// artifactAllowlist) is a run-integrity defect — this run directory cannot hold both
|
|
202
|
+
// models' review/judge files under distinct names, so drillIntoJudge's artifact lookup
|
|
203
|
+
// would otherwise silently misattribute prose. Surfaced here (rather than only inside
|
|
204
|
+
// the low-level allowlist helper) so the renderer can warn the user directly.
|
|
205
|
+
artifactCollisions: artifactNames.collisions || [],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const artifacts = {};
|
|
210
|
+
const names = artifactNames;
|
|
211
|
+
for (const name of [...names, 'report.html', 'run.json', 'tally.json', 'verdict.json']) {
|
|
212
|
+
try {
|
|
213
|
+
const st = fs.statSync(path.join(runDir, name));
|
|
214
|
+
artifacts[name] = { present: true, bytes: st.size };
|
|
215
|
+
} catch {
|
|
216
|
+
artifacts[name] = { present: false, bytes: 0 };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { runId: ptr.runId, runDir, run, tally, verdict, artifacts, derived };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { getRunDetail, costPanel, TERMINAL_STATUSES, STAGE_LABELS };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — run discovery (v4.4 spec §4.3 / §5.1).
|
|
3
|
+
*
|
|
4
|
+
* Walks the project sessions dir for v4.0 council pointer files
|
|
5
|
+
* (`council-<runId>.json` = {runId, runDir}) and builds run-list rows from a
|
|
6
|
+
* shallow, defensive read of run.json (+ verdict.json for the overallVerdict
|
|
7
|
+
* chip). Strictly read-only (§6.2). Unreadable pointers/runs come back as
|
|
8
|
+
* {runId, error} rows — this module never throws on bad input.
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { formatCost } = require('../utils/pricing');
|
|
15
|
+
// ⚠️ DE-ROT (F25): v4.0 module (not v4.3) — safe for Phase 1. Single source of truth
|
|
16
|
+
// for reading/validating council pointer files; do not re-parse them here.
|
|
17
|
+
const runState = require('../council/run-state');
|
|
18
|
+
// Same constant run-state.js itself imports (src/council/run-state.js:20), so a
|
|
19
|
+
// future rename can't silently desync this module's walk from its pointer reads.
|
|
20
|
+
const { SESSIONS_DIR } = require('../session-manager');
|
|
21
|
+
// ⚠️ THIRD COUNCIL-REVIEW PASS: readPointer (below) only validates {runId, runDir}
|
|
22
|
+
// for truthiness — a tampered/stale pointer can point runDir anywhere on disk. This
|
|
23
|
+
// is the shared realpath-containment primitive (also used by artifact-guard.js's
|
|
24
|
+
// readRunArtifact and run-detail.js's getRunDetail); it lives in its own leaf module
|
|
25
|
+
// specifically so this file can require it without creating a cycle — artifact-guard.js
|
|
26
|
+
// already requires THIS file for readPointer.
|
|
27
|
+
const { isRealpathContained } = require('../utils/path-fence');
|
|
28
|
+
|
|
29
|
+
// ⚠️ DE-ROT (F25): widened from /^council-([0-9a-f]{8})\.json$/ to match the shipped
|
|
30
|
+
// walker's pattern (src/council/run-state.js:148) so ids that are not 8 hex still list.
|
|
31
|
+
const POINTER_RE = /^council-([a-zA-Z0-9_-]{1,64})\.json$/;
|
|
32
|
+
|
|
33
|
+
// Same character class as POINTER_RE's capture group / run-state.js:148's enumeration
|
|
34
|
+
// pattern. readPointer() below is a DIRECT lookup keyed on caller-supplied runId (unlike
|
|
35
|
+
// the POINTER_RE walk above, which only ever sees filenames readdirSync already returned)
|
|
36
|
+
// — without this check a runId containing '/', '..', or backslashes reaches
|
|
37
|
+
// runState.readPointer's `council-${runId}.json` path.join unfiltered and can collapse
|
|
38
|
+
// out of the sessions dir entirely (Task 4 review finding: traversal via unvalidated runId).
|
|
39
|
+
const RUN_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
40
|
+
|
|
41
|
+
function sessionsDir(project) {
|
|
42
|
+
return path.join(project, '.claude', SESSIONS_DIR);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readJson(file) {
|
|
46
|
+
try { return { doc: JSON.parse(fs.readFileSync(file, 'utf-8')) }; }
|
|
47
|
+
catch (err) { return { error: err.message }; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Best-effort sort timestamp: run.json createdAt || first stage start || pointer mtime. */
|
|
51
|
+
function startedAtOf(run, pointerPath) {
|
|
52
|
+
if (run) {
|
|
53
|
+
if (run.createdAt) { return run.createdAt; }
|
|
54
|
+
const s0 = Array.isArray(run.stages) && run.stages[0];
|
|
55
|
+
if (s0 && s0.startedAt) { return s0.startedAt; }
|
|
56
|
+
}
|
|
57
|
+
try { return fs.statSync(pointerPath).mtime.toISOString(); } catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a council runId to its run dir via the pointer file.
|
|
62
|
+
* Accepts the id with or without the `council-` prefix (v4.0 readPointer parity).
|
|
63
|
+
* ⚠️ DE-ROT (F25): thin ADAPTER over the shipped src/council/run-state.js:134
|
|
64
|
+
* readPointer — that one already strips the prefix (:119) and validates
|
|
65
|
+
* {runId, runDir}, but returns null. This wrapper is the ONLY place that turns
|
|
66
|
+
* null into the {runId, error} row the workspace run list renders (§5.1).
|
|
67
|
+
* @returns {{runId: string, runDir: string} | {runId: string, error: string}}
|
|
68
|
+
*/
|
|
69
|
+
function readPointer(project, runId) {
|
|
70
|
+
const id = String(runId).replace(/^council-/, '');
|
|
71
|
+
if (!RUN_ID_RE.test(id)) { return { runId: id, error: 'invalid runId' }; }
|
|
72
|
+
const ptr = runState.readPointer(project, id);
|
|
73
|
+
if (!ptr) { return { runId: id, error: 'pointer missing, unreadable, or invalid' }; }
|
|
74
|
+
return { runId: id, runDir: ptr.runDir };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} project absolute project dir (AMICUS_PROJECT)
|
|
79
|
+
* @returns {Array<object>} RunRow[] sorted startedAt desc; error rows inline.
|
|
80
|
+
*/
|
|
81
|
+
function scanCouncilRuns(project) {
|
|
82
|
+
let names = [];
|
|
83
|
+
try { names = fs.readdirSync(sessionsDir(project)); } catch { return []; }
|
|
84
|
+
const rows = [];
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
const m = POINTER_RE.exec(name);
|
|
87
|
+
if (!m) { continue; }
|
|
88
|
+
const pointerPath = path.join(sessionsDir(project), name);
|
|
89
|
+
const ptr = readPointer(project, m[1]);
|
|
90
|
+
if (ptr.error) {
|
|
91
|
+
rows.push({ runId: m[1], runDir: null, error: ptr.error, pointerPath, startedAt: startedAtOf(null, pointerPath) });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Outer containment fence (third council-review pass): mirrors artifact-guard.js's
|
|
96
|
+
// readRunArtifact / run-detail.js's getRunDetail — same isRealpathContained helper,
|
|
97
|
+
// same check. This feeds the GUI run list (spec §5.1), so a fenced-out pointer MUST
|
|
98
|
+
// degrade to an error row exactly like the dangling-pointer/unreadable-run.json cases
|
|
99
|
+
// below rather than throw — one bad pointer must never blank the whole run list.
|
|
100
|
+
let realProject, realRunDir;
|
|
101
|
+
try {
|
|
102
|
+
realProject = fs.realpathSync(project);
|
|
103
|
+
realRunDir = fs.realpathSync(ptr.runDir);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
rows.push({
|
|
106
|
+
runId: m[1], runDir: ptr.runDir, error: `run dir unreadable: ${err.message}`,
|
|
107
|
+
pointerPath, startedAt: startedAtOf(null, pointerPath),
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!isRealpathContained(realProject, realRunDir)) {
|
|
112
|
+
rows.push({
|
|
113
|
+
runId: m[1], runDir: ptr.runDir, error: 'run directory escapes project',
|
|
114
|
+
pointerPath, startedAt: startedAtOf(null, pointerPath),
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const r = readJson(path.join(ptr.runDir, 'run.json'));
|
|
120
|
+
if (r.error) {
|
|
121
|
+
rows.push({ runId: m[1], runDir: ptr.runDir, error: `run.json: ${r.error}`, pointerPath, startedAt: startedAtOf(null, pointerPath) });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const run = r.doc;
|
|
125
|
+
const row = {
|
|
126
|
+
runId: run.runId || m[1],
|
|
127
|
+
runDir: ptr.runDir,
|
|
128
|
+
status: run.status || 'unknown',
|
|
129
|
+
startedAt: startedAtOf(run, pointerPath),
|
|
130
|
+
completedAt: run.completedAt || null,
|
|
131
|
+
bench: Array.isArray(run.bench) ? run.bench : [],
|
|
132
|
+
chair: run.chair || null,
|
|
133
|
+
costDisplay: formatCost(run.usage && run.usage.cost),
|
|
134
|
+
tierCounts: null,
|
|
135
|
+
overallVerdict: null,
|
|
136
|
+
};
|
|
137
|
+
const v = readJson(path.join(ptr.runDir, 'verdict.json'));
|
|
138
|
+
if (!v.error && v.doc) {
|
|
139
|
+
row.overallVerdict = v.doc.overallVerdict === undefined ? null : v.doc.overallVerdict;
|
|
140
|
+
row.tierCounts = v.doc.tierCounts || null;
|
|
141
|
+
}
|
|
142
|
+
rows.push(row);
|
|
143
|
+
}
|
|
144
|
+
rows.sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
|
|
145
|
+
return rows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { scanCouncilRuns, readPointer, POINTER_RE };
|