amicus 4.8.1 → 4.9.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 (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +36 -15
  10. package/electron/offer-session.js +51 -0
  11. package/electron/setup-ui.js +18 -8
  12. package/electron/workspace-ui/live-dead-seats.js +163 -91
  13. package/electron/workspace-ui/workspace-banners.js +30 -7
  14. package/electron/workspace-ui/workspace-matrix.js +23 -3
  15. package/electron/workspace-ui/workspace-seats.js +95 -79
  16. package/package.json +1 -1
  17. package/schemas/council-run.schema.json +2 -2
  18. package/schemas/council-tally.schema.json +17 -1
  19. package/schemas/council-verdict.schema.json +12 -4
  20. package/schemas/run.schema.json +6 -1
  21. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  22. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  23. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  24. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  25. package/skills/second-opinion/SKILL.md +151 -36
  26. package/src/cli-council-run-bench.js +98 -6
  27. package/src/cli-handlers-council-run.js +18 -6
  28. package/src/cli-handlers-council.js +57 -7
  29. package/src/cli-handlers-doctor.js +1 -1
  30. package/src/cli.js +3 -1
  31. package/src/council/anonymize.js +2 -1
  32. package/src/council/briefings-chair-task.js +161 -0
  33. package/src/council/briefings-chair.js +33 -8
  34. package/src/council/briefings-debate.js +79 -13
  35. package/src/council/briefings-stage2-task.js +236 -0
  36. package/src/council/briefings-stage2.js +103 -26
  37. package/src/council/briefings-task.js +167 -0
  38. package/src/council/briefings.js +41 -4
  39. package/src/council/chair-fallback.js +95 -0
  40. package/src/council/debate.js +38 -21
  41. package/src/council/findings.js +3 -2
  42. package/src/council/ledger.js +2 -2
  43. package/src/council/parse-stage2.js +63 -15
  44. package/src/council/report-cost.js +61 -0
  45. package/src/council/report-html.js +26 -4
  46. package/src/council/report-md.js +30 -2
  47. package/src/council/report.js +40 -37
  48. package/src/council/run-assemble.js +21 -6
  49. package/src/council/run-chair.js +44 -95
  50. package/src/council/run-debate-revote.js +81 -49
  51. package/src/council/run-debate.js +51 -34
  52. package/src/council/run-finish.js +5 -3
  53. package/src/council/run-retry-keys.js +4 -4
  54. package/src/council/run-retry-launch.js +4 -4
  55. package/src/council/run-retry-notes.js +72 -15
  56. package/src/council/run-stage1-launch.js +4 -4
  57. package/src/council/run-stage1-rows.js +9 -6
  58. package/src/council/run-stage2.js +81 -47
  59. package/src/council/run-stages.js +9 -21
  60. package/src/council/run-stats-entry.js +46 -1
  61. package/src/council/run.js +28 -13
  62. package/src/council/seats.js +2 -2
  63. package/src/council/stage1-bind.js +3 -2
  64. package/src/council/verdict-seat-loss.js +124 -0
  65. package/src/council/verdict.js +108 -99
  66. package/src/headless.js +256 -49
  67. package/src/mcp-council-bench.js +64 -3
  68. package/src/mcp-council-run.js +10 -3
  69. package/src/mcp-server.js +52 -12
  70. package/src/mcp-tools.js +41 -5
  71. package/src/observe/council-legs.js +2 -2
  72. package/src/opencode-client.js +19 -1
  73. package/src/pack/pack-forward.js +15 -12
  74. package/src/pack/pack-resolve.js +1 -1
  75. package/src/prompt-builder.js +17 -1
  76. package/src/sidecar/fanout-leg.js +26 -0
  77. package/src/sidecar/fanout.js +1 -1
  78. package/src/sidecar/list-council.js +178 -0
  79. package/src/sidecar/list-limit.js +3 -1
  80. package/src/sidecar/list-search.js +2 -1
  81. package/src/sidecar/models.js +8 -1
  82. package/src/sidecar/read.js +34 -10
  83. package/src/template/render.js +16 -7
  84. package/src/utils/alias-shadow-writer.js +220 -0
  85. package/src/utils/alias-shadow.js +294 -0
  86. package/src/utils/config.js +1 -1
  87. package/src/utils/curated-models.js +11 -3
  88. package/src/utils/degrade.js +12 -5
  89. package/src/utils/doctor-alias-check.js +2 -5
  90. package/src/utils/engine-log-parse.js +289 -0
  91. package/src/utils/engine-log-tail.js +114 -0
  92. package/src/utils/engine-log.js +250 -0
  93. package/src/utils/engine-skew-records.js +146 -0
  94. package/src/utils/engine-skew.js +300 -0
  95. package/src/utils/gateway-router.js +10 -2
  96. package/src/utils/model-catalog.js +1 -1
  97. package/src/utils/result-schema.js +10 -0
  98. package/src/utils/text-sanitize.js +81 -0
  99. package/src/utils/ttft.js +57 -0
  100. package/src/utils/untrusted-fence.js +111 -1
  101. package/src/workspace/fold-format.js +28 -7
  102. package/src/workspace/live-normalize.js +2 -1
  103. package/src/workspace/matrix-model.js +6 -2
  104. package/src/workspace/run-detail.js +33 -7
  105. package/src/workspace/seat-space.js +10 -6
@@ -0,0 +1,124 @@
1
+ // src/council/verdict-seat-loss.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/verdict-seat-loss
6
+ * The verdict's seat-loss surface: `summarizeSeatLoss` (v4.5.2) and
7
+ * `deriveSeatLoss` (v4.6 Plan 2, spec D3) — lifted VERBATIM out of verdict.js
8
+ * for the 300-line gate (v4.9 PR #200 fix round 3), on the W4 chair-fallback
9
+ * precedent. The extraction itself was a pure MOVE: same bodies, same
10
+ * docblocks, same exports, and verdict.js re-exports both so no caller changes.
11
+ * (v4.9 W9 has since edited `deriveSeatLoss`'s body here — the identity pins
12
+ * below cover the MOVE, not a freeze.) Their behaviour stays pinned where it
13
+ * already was (tests/council/verdict-seat-loss.test.js,
14
+ * tests/council/verdict-degrades.test.js), both of which still import through
15
+ * verdict.js — if the move changed anything, those go red.
16
+ *
17
+ * Pure: no IO, no verdict-document knowledge. Nothing in this file requires
18
+ * verdict.js, so the dependency runs one way only.
19
+ */
20
+
21
+ /**
22
+ * Describe which requested seats actually reviewed, for the verdict's own face.
23
+ *
24
+ * ⚠️ ADDED v4.5.2 from a field report. The critic is a SOLO wave with one leg,
25
+ * so losing it loses 100% of the adversarial role — and unlike a dead bench wave
26
+ * (which trips the quorum gate and fails the run loudly) a dead critic is
27
+ * survivable, so the run continues to a full verdict, tally and chair synthesis
28
+ * that never saw the critic's findings. Run `dfb6a692` did exactly that and the
29
+ * only record was `deadWaves` in run.json, a file nobody opens when the verdict
30
+ * reads clean. A user who typed `--critic` asked for adversarial review; a
31
+ * verdict produced without it must say so where the verdict is read.
32
+ *
33
+ * Returns null when no critic was requested — there is nothing to report, and an
34
+ * always-present block would train readers to ignore it.
35
+ *
36
+ * @param {{runId: string, critic: ?string,
37
+ * deadWaves: Array<{waveId: string, models: string[], reason: string}>}} o
38
+ * @returns {?{criticRequested: string, criticSeated: boolean, reason: ?string,
39
+ * deadBenchSeats: string[]}}
40
+ */
41
+ function summarizeSeatLoss({ runId, critic, deadWaves = [] } = {}) {
42
+ if (!critic) { return null; }
43
+ // Match on EITHER carrier. The `-c1` suffix is the convention run-stages.js
44
+ // uses, but a wave that names the critic model is the critic wave whatever it
45
+ // is called — and relying on the id alone would silently under-report if that
46
+ // convention ever changes.
47
+ const isCriticWave = w =>
48
+ w.waveId === `${runId}-c1` || (w.models || []).includes(critic);
49
+ const dead = deadWaves.find(isCriticWave) || null;
50
+ return {
51
+ criticRequested: critic,
52
+ criticSeated: !dead,
53
+ reason: dead ? dead.reason : null,
54
+ deadBenchSeats: deadWaves.filter(w => !isCriticWave(w))
55
+ .flatMap(w => w.models || []),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * seatLoss, derived from the sink's records (v4.6 Plan 2, spec D3 — closes #84).
61
+ *
62
+ * WHY A DERIVATION: two fields reporting lost seats can disagree; deriving one
63
+ * from the other makes contradiction inexpressible. `summarizeSeatLoss` stays
64
+ * exactly as v4.5.2 shipped it (its tests pass unedited — that is the proof the
65
+ * shape survived); this function rebuilds its wave input from `dead-wave`
66
+ * records and then adds the losses waves can never show: `dead-leg` records —
67
+ * a solo critic wave that STARTED but whose one leg died is invisible to
68
+ * deadWaves (#84's second half) — plus v4.9 W9's gated `seat-unbound` family.
69
+ *
70
+ * Reads ONLY record.data (Task 1's machine surface) — never the prose fields.
71
+ * @param {{runId: string, critic: ?string, degrades: Array<object>}} o
72
+ * @returns {?object} the summarizeSeatLoss shape, or null when no critic was requested
73
+ */
74
+ function deriveSeatLoss({ runId, critic, degrades = [] } = {}) {
75
+ if (!critic) { return null; }
76
+ // v4.9 W9 (SI-02). ⚠️ THIRD spelling of one admission rule; its renderer twins are
77
+ // `live-dead-seats.js :: isSeatLoss` and `workspace-seats.js :: retriedSeats`, which cannot
78
+ // require src/ — all three move together, enforced only by workspace-seats.test.js's drift
79
+ // pin. `seat-unbound` is GATED: orphan-leg, re-vote and Stage-2 judge notes share it, are NOT
80
+ // seat losses, and carry no retry-family field. (R-W9a is CLOSED at the producer in the W9 fix
81
+ // round: `run-retry-notes.js :: skippedWaveNote` emits the `firstFailure` fact its record
82
+ // already carried, so this unchanged gate admits that real loss and still excludes the three.)
83
+ // ⚠️ The kind test admits a kind-LESS record as a degrade — W9 fix round, council C4. The
84
+ // POSITIVE-only spelling rested on an ASSERTED caller inventory (everything here comes from
85
+ // `makeDegrade`, which stamps the default), which is convention, not structure: one new
86
+ // caller, or one hand-written record, and a real seat loss vanishes silently. `report.js`
87
+ // learned it as mutant LEGACYDROP; the two renderers now spell it exactly as this line does.
88
+ // What all four agree on is that an ABSENT kind is a loss — their kind LISTS still differ,
89
+ // deliberately. `heal`/`info` name themselves here and are still excluded.
90
+ const gatedUnbound = d => d.channel === 'seat-unbound'
91
+ && (d.data.retryWaveId || d.data.firstFailure) && (d.data.seatId || d.data.seat);
92
+ const real = degrades.filter(d => (d.kind === undefined || d.kind === 'degrade') && d.data
93
+ && (d.channel === 'dead-leg' || d.channel === 'dead-wave' || gatedUnbound(d)));
94
+ const waves = real.filter(d => d.channel === 'dead-wave')
95
+ .map(d => ({ waveId: d.data.waveId, models: d.data.models || [], reason: d.data.reason }));
96
+ const base = summarizeSeatLoss({ runId, critic, deadWaves: waves });
97
+ const legs = real.filter(d => d.channel !== 'dead-wave');
98
+ // ⚠️ STAYS ALIAS-KEYED — decided and measured in v4.9 W9 (R4), not mirrored from the
99
+ // renderers' seat-key fix. `seats.js :: preflightSeats` REFUSES a critic alias occupying
100
+ // more than one bench seat, zero-spend, before any leg launches, so on every run this can see
101
+ // `data.seat === critic` names exactly one seat and alias equality IS seat equality; its only
102
+ // production caller, `run-verdict-files.js :: writeVerdictFiles`, is fed in-process records
103
+ // from that run's own sink. The renderers differ: they read run.json/verdict.json off DISK,
104
+ // any version or hand edit, where that refusal is not in force.
105
+ const criticLeg = legs.find(l => l.data.seat === critic) || null;
106
+ return {
107
+ ...base,
108
+ criticSeated: base.criticSeated && !criticLeg,
109
+ // SL-2 handoff: a reconciliation note (run-retry-notes.js's
110
+ // missingLegStillDeadNote) carries data.status: null when the retry
111
+ // produced no leg for the seat at all — there is no status to name, so
112
+ // the old `ended '${status}'` template rendered the literal string
113
+ // "ended 'null'". A status-carrying record keeps the original text.
114
+ reason: base.reason || (criticLeg
115
+ ? (criticLeg.data.reason || (criticLeg.data.status
116
+ ? `the critic leg ended '${criticLeg.data.status}' with no usable output`
117
+ : 'the critic leg produced no usable output'))
118
+ : null),
119
+ deadBenchSeats: [...base.deadBenchSeats,
120
+ ...legs.filter(l => l.data.seat !== critic).map(l => l.data.seat)],
121
+ };
122
+ }
123
+
124
+ module.exports = { summarizeSeatLoss, deriveSeatLoss };
@@ -2,100 +2,28 @@
2
2
  'use strict';
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { parseChairVerdict } = require('./parse-stage2');
5
+ const { parseChairTerminal, CHAIR_VERDICTS, CHAIR_ANSWERS } = require('./parse-stage2');
6
+ // v4.9 PR #200 fix round 3: the seat-loss pair lives in its own leaf module (the
7
+ // 300-line gate), re-exported below so every existing caller and pin is unchanged.
8
+ const { summarizeSeatLoss, deriveSeatLoss } = require('./verdict-seat-loss');
6
9
 
7
10
  // v4.0 §7: council family v2 — verdict docs carry {schemaVersion, type} and a
8
- // nullable overallVerdict (the chair's Ship-it line; populated by the headless
9
- // engine in Plan B via opts.overallVerdict, null in every Stage-4 manual path).
11
+ // nullable overallVerdict (the chair's terminal line `VERDICT:` on a review
12
+ // run, `ANSWER:` on a task one; populated by the headless engine in Plan B via
13
+ // opts.overallVerdict, null in every Stage-4 manual path).
10
14
  const VERDICT_SCHEMA_VERSION = 2;
11
15
 
12
- /**
13
- * Describe which requested seats actually reviewed, for the verdict's own face.
14
- *
15
- * ⚠️ ADDED v4.5.2 from a field report. The critic is a SOLO wave with one leg,
16
- * so losing it loses 100% of the adversarial role — and unlike a dead bench wave
17
- * (which trips the quorum gate and fails the run loudly) a dead critic is
18
- * survivable, so the run continues to a full verdict, tally and chair synthesis
19
- * that never saw the critic's findings. Run `dfb6a692` did exactly that and the
20
- * only record was `deadWaves` in run.json, a file nobody opens when the verdict
21
- * reads clean. A user who typed `--critic` asked for adversarial review; a
22
- * verdict produced without it must say so where the verdict is read.
23
- *
24
- * Returns null when no critic was requested — there is nothing to report, and an
25
- * always-present block would train readers to ignore it.
26
- *
27
- * @param {{runId: string, critic: ?string,
28
- * deadWaves: Array<{waveId: string, models: string[], reason: string}>}} o
29
- * @returns {?{criticRequested: string, criticSeated: boolean, reason: ?string,
30
- * deadBenchSeats: string[]}}
31
- */
32
- function summarizeSeatLoss({ runId, critic, deadWaves = [] } = {}) {
33
- if (!critic) { return null; }
34
- // Match on EITHER carrier. The `-c1` suffix is the convention run-stages.js
35
- // uses, but a wave that names the critic model is the critic wave whatever it
36
- // is called — and relying on the id alone would silently under-report if that
37
- // convention ever changes.
38
- const isCriticWave = w =>
39
- w.waveId === `${runId}-c1` || (w.models || []).includes(critic);
40
- const dead = deadWaves.find(isCriticWave) || null;
41
- return {
42
- criticRequested: critic,
43
- criticSeated: !dead,
44
- reason: dead ? dead.reason : null,
45
- deadBenchSeats: deadWaves.filter(w => !isCriticWave(w))
46
- .flatMap(w => w.models || []),
47
- };
48
- }
49
-
50
- /**
51
- * seatLoss, derived from the sink's records (v4.6 Plan 2, spec D3 — closes #84).
52
- *
53
- * WHY A DERIVATION: two fields reporting lost seats can disagree; deriving one
54
- * from the other makes contradiction inexpressible. `summarizeSeatLoss` stays
55
- * exactly as v4.5.2 shipped it (its tests pass unedited — that is the proof the
56
- * shape survived); this function rebuilds its wave input from `dead-wave`
57
- * records and then adds the losses waves can never show: `dead-leg` records —
58
- * a solo critic wave that STARTED but whose one leg died is invisible to
59
- * deadWaves, which is #84's second half.
60
- *
61
- * Reads ONLY record.data (Task 1's machine surface) — never the prose fields.
62
- * @param {{runId: string, critic: ?string, degrades: Array<object>}} o
63
- * @returns {?object} the summarizeSeatLoss shape, or null when no critic was requested
64
- */
65
- function deriveSeatLoss({ runId, critic, degrades = [] } = {}) {
66
- if (!critic) { return null; }
67
- const real = degrades.filter(d => d.kind !== 'heal' && d.data);
68
- const waves = real.filter(d => d.channel === 'dead-wave')
69
- .map(d => ({ waveId: d.data.waveId, models: d.data.models || [], reason: d.data.reason }));
70
- const base = summarizeSeatLoss({ runId, critic, deadWaves: waves });
71
- const legs = real.filter(d => d.channel === 'dead-leg');
72
- const criticLeg = legs.find(l => l.data.seat === critic) || null;
73
- return {
74
- ...base,
75
- criticSeated: base.criticSeated && !criticLeg,
76
- // SL-2 handoff: a reconciliation note (run-retry-notes.js's
77
- // missingLegStillDeadNote) carries data.status: null when the retry
78
- // produced no leg for the seat at all — there is no status to name, so
79
- // the old `ended '${status}'` template rendered the literal string
80
- // "ended 'null'". A status-carrying record keeps the original text.
81
- reason: base.reason || (criticLeg
82
- ? (criticLeg.data.reason || (criticLeg.data.status
83
- ? `the critic leg ended '${criticLeg.data.status}' with no usable output`
84
- : 'the critic leg produced no usable output'))
85
- : null),
86
- deadBenchSeats: [...base.deadBenchSeats,
87
- ...legs.filter(l => l.data.seat !== critic).map(l => l.data.seat)],
88
- };
89
- }
90
-
91
16
  /**
92
17
  * Merge a tally record with Claude's Stage-4 decisions into the verdict record.
93
18
  * @param {object} record tally() output
94
19
  * @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
95
- * @param {{overallVerdict?: (string|null), seatLoss?: object, degrades?: Array<object>}} [opts]
96
- * `overallVerdict` is the engine hook (Plan B): the parsed chair `VERDICT:`
97
- * line; omitted/undefined null. `seatLoss` (v4.5.2) and `degrades` (v4.6
98
- * Plan 2) are additive and OPTIONAL each lands on the verdict only when
20
+ * @param {{overallVerdict?: (string|null), intent?: (string|null),
21
+ * seatLoss?: object, degrades?: Array<object>}} [opts]
22
+ * `overallVerdict` is the engine hook (Plan B): the parsed chair terminal line
23
+ * (`VERDICT:` on a review run, `ANSWER:` on a task run v4.9 W5/W7);
24
+ * omitted/undefined → null. `intent` is the Stage-5 rebuild's carrier (v4.9 fix
25
+ * round 2 — see the emit-when-'task' line below). `seatLoss` (v4.5.2) and
26
+ * `degrades` (v4.6 Plan 2) are additive and OPTIONAL — each lands only when
99
27
  * truthy/non-empty, absent otherwise (never fabricated).
100
28
  */
101
29
  function buildVerdict(record, decisions = [], opts = {}) {
@@ -116,6 +44,22 @@ function buildVerdict(record, decisions = [], opts = {}) {
116
44
  // matching the `seatLoss` sibling below; PR5 codes against that name.
117
45
  ...(record.meta.seats ? { seats: record.meta.seats } : {}),
118
46
  claudeInCouncil: record.meta.claudeInCouncil,
47
+ // v4.9 W5.3: task-mode marker — emit-when-'task' (the W4/W5 plan's §7.5
48
+ // byte-identity ruling): a review record never materializes the key, so a
49
+ // review verdict.json is unchanged byte for byte, and an explicit
50
+ // meta.intent 'review' (hand-assembled input) is NOT forwarded either.
51
+ // ⚠️ v4.9 fix round 2 (council C1): `opts.intent` is a SECOND carrier, not a
52
+ // redundant one. MEASURED: the run dir's tally.json copies `meta` verbatim
53
+ // (tally.js), so the canonical Stage-5 rebuild already came through the first
54
+ // guard — but a hand-assembled or MCP-supplied record has none (mcp-tools.js
55
+ // :: amicus_verdict types `record` as `z.record(z.any())`), and on THAT leg
56
+ // the rebuild dropped the key, regressing the fold line and Workspace chip to
57
+ // review scale. Passed through `opts` rather than assigned after the call, so
58
+ // the key keeps its SLOT here and a rebuilt document's key order still
59
+ // matches the engine's (pinned, cli-council-verdict-chair-carry.test.js).
60
+ // PR #200 round-5 B3: parens on the whole disjunction — behaviour-identical.
61
+ ...(((record.meta && record.meta.intent === 'task') || opts.intent === 'task')
62
+ ? { intent: 'task' } : {}),
119
63
  overallVerdict: opts.overallVerdict === undefined ? null : opts.overallVerdict,
120
64
  findings: record.findings.map(f => {
121
65
  const d = byId.get(f.id) || {};
@@ -148,9 +92,9 @@ function buildVerdict(record, decisions = [], opts = {}) {
148
92
  // row's flat {model, seat} shape — model is the alias, seat is the seat
149
93
  // id, so on a unique-alias bench they are byte-equal and nothing is
150
94
  // emitted (same semantics as `seat.id !== seat.alias` one layer up,
151
- // run-stats-entry.js:64). NOT a plain pass-through like `raiserSeat`
152
- // above (:141) — that field's upstream producer already holds a real
153
- // {id, alias} seat OBJECT at its own decision point (run.js:202:
95
+ // run-stats-entry.js :: buildRunStatsEntry). NOT a plain pass-through like `raiserSeat`
96
+ // in the findings literal above — that field's upstream producer already holds a real
97
+ // {id, alias} seat OBJECT at its own decision point (run.js:212:
154
98
  // `r.seat && r.seat.id !== r.seat.alias`), so passing its verdict
155
99
  // through here is safe. The street-cred producer never has such an object
156
100
  // at this point, only a flat row, so a pass-through here would leak `seat`
@@ -200,26 +144,62 @@ function buildVerdict(record, decisions = [], opts = {}) {
200
144
  * verdict belongs to the run, not to wherever the caller writes the result):
201
145
  * 1. `<runDir>/verdict.json` `overallVerdict` — the value the engine already
202
146
  * parsed. Guarded by `runId`: a stale or foreign verdict.json sitting in
203
- * the folder must never inject another run's chair line.
204
- * 2. `<runDir>/chair-output.md`, re-parsed with the engine's own
205
- * `parseChairVerdict`, so there is no second parser to drift. This also
206
- * recovers runs whose verdict.json was already nulled by the defect.
147
+ * the folder must never inject another run's chair line. Guarded by SCALE
148
+ * too (v4.9 fix round 3, council A1/B1 — see below).
149
+ * 2. `<runDir>/chair-output.md`, re-parsed with the engine's OWN parser, so
150
+ * there is no second parser to drift. This also recovers runs whose
151
+ * verdict.json was already nulled by the defect.
152
+ *
153
+ * ⚠️ ONE scale, chosen by the run's INTENT (v4.9 fix round 2, council B1/C2 —
154
+ * CORRECTING W7's MEDIUM F2, recorded because it was wrong instructively). W7 was
155
+ * right that a VERDICT-only fallback recovered null for every task run; its fix
156
+ * tried BOTH parsers unconditionally, justified thus — the two scales are
157
+ * DISJOINT by pinned construction (tests/council/chair-scale-drift.test.js), so
158
+ * neither can read the other's phrase and the order of the two calls cannot
159
+ * change an outcome. The premise is true; the conclusion does not follow.
160
+ * Disjointness is a property of the PHRASE SETS, not of the DOCUMENT: chair prose
161
+ * that quotes, contrasts or merely mentions the other scale carries BOTH keyword
162
+ * lines, and then order decides everything — measured red in both directions, now
163
+ * pinned in both (cli-council-verdict-chair-carry.test.js, SCALEFREEFALLBACK).
164
+ *
165
+ * So `intent` dispatches through `parseChairTerminal` — one parser, never both.
166
+ * W7 rejected that call believing a Stage-5 rebuild has no intent to pass; it has
167
+ * THREE, and the caller reads all of them (cli-handlers-council.js :: runVerdict):
168
+ * the record's `meta.intent`, `run.json`'s `intent` from the run folder this
169
+ * function already anchors on, and — since fix round 3, council C3 — the prior
170
+ * verdict.json's own `intent` (readPriorVerdictIntent below), which is the last
171
+ * carrier standing when the other two are absent. Absent — and anything but 'task' — is review,
172
+ * restoring pre-W7 review behaviour exactly. Never invents: an absent, skipped or
173
+ * unstructured chair yields null.
207
174
  *
208
- * Never invents: an absent, skipped, or unstructured chair yields null.
175
+ * ⚠️ AND THE CARRY OBEYS THE SAME SCALE (v4.9 fix round 3, council A1+B1 one
176
+ * mechanism, one fix). Round 2 dispatched branch 2 and left branch 1 with no
177
+ * scale check at all, so the whole guard was inert whenever a prior verdict.json
178
+ * existed: `overallVerdict: 'Ship it'` was carried onto a task rebuild verbatim
179
+ * and every downstream surface then labelled a CHAIR_VERDICTS phrase `ANSWER:`
180
+ * — the defect round 2 measured on the prose leg, one branch earlier. So the
181
+ * carried phrase must be a member of the scale `intent` selects; off-scale is
182
+ * treated as NO CARRY and falls through to branch 2, which is intent-dispatched
183
+ * and therefore already right. This also subsumes the old string/non-empty
184
+ * check: every member of both scales is a non-empty string, and `includes`
185
+ * refuses a number, null or object without a separate typeof.
209
186
  * @param {string} runDir
210
187
  * @param {string} [runId] record.meta.runId — the run being rebuilt
211
- * @returns {string|null} a canonical chair verdict phrase, or null
188
+ * @param {?string} [intent] 'task' selects the ANSWER scale; anything else review
189
+ * @returns {string|null} a canonical chair verdict OR answer phrase, or null
212
190
  */
213
- function readOverallVerdict(runDir, runId) {
191
+ function readOverallVerdict(runDir, runId, intent) {
192
+ const scale = intent === 'task' ? CHAIR_ANSWERS : CHAIR_VERDICTS;
214
193
  try {
215
194
  const prior = JSON.parse(fs.readFileSync(path.join(runDir, 'verdict.json'), 'utf-8'));
216
- if (typeof prior.overallVerdict === 'string' && prior.overallVerdict
195
+ if (scale.includes(prior.overallVerdict)
217
196
  && (!runId || prior.runId === runId)) {
218
197
  return prior.overallVerdict;
219
198
  }
220
199
  } catch { /* no prior verdict.json, or unreadable — try the chair prose */ }
221
200
  try {
222
- return parseChairVerdict(fs.readFileSync(path.join(runDir, 'chair-output.md'), 'utf-8'));
201
+ const text = fs.readFileSync(path.join(runDir, 'chair-output.md'), 'utf-8');
202
+ return parseChairTerminal(text, intent);
223
203
  } catch { /* no chair-output.md — the chair genuinely produced nothing */ }
224
204
  return null;
225
205
  }
@@ -247,6 +227,35 @@ function readPriorVerdictSurfaces(runDir, runId) {
247
227
  return { seatLoss: null, degrades: null };
248
228
  }
249
229
 
230
+ /**
231
+ * The run's intent as the prior verdict.json records it (v4.9 fix round 3,
232
+ * council C3) — the THIRD carrier for a Stage-5 rebuild, after the record's
233
+ * `meta.intent` and run.json's checkpoint.
234
+ *
235
+ * A SEPARATE function rather than a fourth key on readPriorVerdictSurfaces
236
+ * directly above: intent has to be resolved BEFORE readOverallVerdict runs
237
+ * (it selects that call's scale), while the loss surfaces are recovered after
238
+ * the parse; and #87's pins assert that function's return shape exactly
239
+ * (verdict-degrades.test.js), so widening it would edit a pin to fit a change
240
+ * rather than the other way round.
241
+ *
242
+ * Same contract as both siblings: the run folder's own verdict.json, the same
243
+ * `!runId || prior.runId === runId` guard — waived only when the RECORD names
244
+ * no run, never when the DOCUMENT does not — and absence yields null.
245
+ * Emit-when-'task' at the source (buildVerdict), so ONLY 'task' is reported;
246
+ * anything else, including a hand-written `intent: 'review'`, is no vote.
247
+ * @param {string} runDir
248
+ * @param {string} [runId]
249
+ * @returns {'task'|null}
250
+ */
251
+ function readPriorVerdictIntent(runDir, runId) {
252
+ try {
253
+ const prior = JSON.parse(fs.readFileSync(path.join(runDir, 'verdict.json'), 'utf-8'));
254
+ if ((!runId || prior.runId === runId) && prior.intent === 'task') { return 'task'; }
255
+ } catch { /* no prior verdict.json, or unreadable — no vote */ }
256
+ return null;
257
+ }
258
+
250
259
  /** Atomic write: tmp + rename (matches the repo's wave.json convention). */
251
260
  function writeVerdictAtomic(filePath, verdict) {
252
261
  const tmp = `${filePath}.tmp-${process.pid}`;
@@ -256,5 +265,5 @@ function writeVerdictAtomic(filePath, verdict) {
256
265
 
257
266
  module.exports = {
258
267
  buildVerdict, summarizeSeatLoss, deriveSeatLoss, readOverallVerdict, readPriorVerdictSurfaces,
259
- writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
268
+ readPriorVerdictIntent, writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
260
269
  };