amicus 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +4 -3
  4. package/electron/ipc-workspace.js +283 -0
  5. package/electron/main.js +27 -0
  6. package/electron/preload-workspace.js +40 -0
  7. package/electron/workspace-shell.js +85 -0
  8. package/electron/workspace-ui/index.html +111 -0
  9. package/electron/workspace-ui/live-model.js +101 -0
  10. package/electron/workspace-ui/md-lite.js +119 -0
  11. package/electron/workspace-ui/workspace-app.js +240 -0
  12. package/electron/workspace-ui/workspace-matrix.js +212 -0
  13. package/electron/workspace-ui/workspace-panels.js +226 -0
  14. package/electron/workspace-ui/workspace-render.js +271 -0
  15. package/electron/workspace-ui/workspace-verbs.js +247 -0
  16. package/electron/workspace-ui/workspace.css +172 -0
  17. package/package.json +1 -1
  18. package/schemas/council-run-live.schema.json +25 -1
  19. package/schemas/council-run.schema.json +14 -0
  20. package/schemas/progress.schema.json +14 -1
  21. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  22. package/src/cli-handlers-council-run.js +25 -3
  23. package/src/cli-handlers-spend.js +32 -5
  24. package/src/cli-handlers-watch.js +37 -10
  25. package/src/council/briefings.js +35 -2
  26. package/src/council/run-budget.js +224 -0
  27. package/src/council/run-launch.js +44 -6
  28. package/src/council/run-stages.js +17 -3
  29. package/src/council/run.js +12 -11
  30. package/src/headless.js +347 -14
  31. package/src/mcp-council-awareness.js +53 -3
  32. package/src/observe/council-legs.js +183 -0
  33. package/src/observe/live-doc.js +21 -3
  34. package/src/observe/watch-render.js +19 -0
  35. package/src/opencode-client.js +15 -3
  36. package/src/sidecar/child-sessions.js +198 -0
  37. package/src/sidecar/conversation-mirror.js +111 -37
  38. package/src/sidecar/fanout-budget.js +71 -0
  39. package/src/sidecar/fanout-leg.js +23 -1
  40. package/src/sidecar/fanout.js +4 -11
  41. package/src/sidecar/tool-part.js +196 -0
  42. package/src/sidecar/workspace-window.js +62 -0
  43. package/src/spend-query.js +21 -6
  44. package/src/utils/env-num.js +42 -0
  45. package/src/utils/path-fence.js +82 -0
  46. package/src/utils/pricing.js +98 -9
  47. package/src/workspace/artifact-guard.js +187 -0
  48. package/src/workspace/blind-mode.js +32 -0
  49. package/src/workspace/fold-format.js +95 -0
  50. package/src/workspace/live-normalize.js +156 -0
  51. package/src/workspace/matrix-model.js +94 -0
  52. package/src/workspace/run-detail.js +223 -0
  53. 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 };