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.
Files changed (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. 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 };