amicus 4.7.1 → 4.8.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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +882 -1
  3. package/README.md +3 -2
  4. package/docs/CITATIONS.md +114 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +2 -2
  8. package/docs/council.md +179 -32
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/troubleshooting.md +3 -3
  12. package/docs/usage.md +2 -2
  13. package/electron/setup-ui-aliases.js +2 -2
  14. package/electron/workspace-ui/index.html +9 -0
  15. package/electron/workspace-ui/live-dead-seats.js +228 -0
  16. package/electron/workspace-ui/live-model.js +10 -236
  17. package/electron/workspace-ui/live-seats.js +126 -0
  18. package/electron/workspace-ui/workspace-app.js +6 -41
  19. package/electron/workspace-ui/workspace-banners.js +95 -0
  20. package/electron/workspace-ui/workspace-lazy.js +55 -12
  21. package/electron/workspace-ui/workspace-matrix.js +2 -2
  22. package/electron/workspace-ui/workspace-panels.js +42 -10
  23. package/electron/workspace-ui/workspace-render.js +2 -2
  24. package/electron/workspace-ui/workspace-seats.js +101 -17
  25. package/package.json +3 -1
  26. package/schemas/council-run-live.schema.json +1 -0
  27. package/schemas/council-run.schema.json +19 -0
  28. package/schemas/council-tally.schema.json +34 -2
  29. package/schemas/council-verdict.schema.json +15 -0
  30. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  31. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  32. package/skills/second-opinion/SKILL.md +25 -11
  33. package/src/cli-handlers-doctor.js +7 -0
  34. package/src/council/anonymize.js +80 -11
  35. package/src/council/briefings-chair.js +272 -0
  36. package/src/council/briefings-stage2.js +12 -140
  37. package/src/council/debate.js +120 -15
  38. package/src/council/ledger-join.js +284 -0
  39. package/src/council/ledger-stats.js +100 -0
  40. package/src/council/ledger.js +177 -88
  41. package/src/council/peer-split.js +196 -0
  42. package/src/council/report-html.js +12 -5
  43. package/src/council/report-md.js +146 -0
  44. package/src/council/report.js +188 -112
  45. package/src/council/run-assemble.js +100 -79
  46. package/src/council/run-chair.js +17 -1
  47. package/src/council/run-debate-revote.js +268 -0
  48. package/src/council/run-debate.js +92 -102
  49. package/src/council/run-finish.js +70 -0
  50. package/src/council/run-launch.js +45 -18
  51. package/src/council/run-retry-group.js +266 -0
  52. package/src/council/run-retry-keys.js +74 -0
  53. package/src/council/run-retry-launch.js +55 -0
  54. package/src/council/run-retry-notes.js +65 -13
  55. package/src/council/run-retry.js +166 -161
  56. package/src/council/run-stage1-launch.js +27 -10
  57. package/src/council/run-stage1-rows.js +220 -0
  58. package/src/council/run-stage1-superseded.js +156 -0
  59. package/src/council/run-stage2.js +65 -5
  60. package/src/council/run-stages.js +72 -69
  61. package/src/council/run-state.js +1 -1
  62. package/src/council/run-stats-entry.js +71 -0
  63. package/src/council/run-verdict-files.js +52 -0
  64. package/src/council/run.js +42 -52
  65. package/src/council/seats.js +262 -0
  66. package/src/council/stage1-bind.js +142 -0
  67. package/src/council/street-cred.js +258 -0
  68. package/src/council/tally.js +100 -49
  69. package/src/council/verdict.js +49 -1
  70. package/src/headless.js +20 -6
  71. package/src/mcp-tools.js +71 -1
  72. package/src/observe/council-legs.js +7 -1
  73. package/src/observe/live-doc.js +3 -3
  74. package/src/sidecar/fanout-leg.js +22 -1
  75. package/src/sidecar/fanout-wave-io.js +26 -1
  76. package/src/sidecar/fanout.js +4 -10
  77. package/src/sidecar/leg-ids.js +19 -0
  78. package/src/sidecar/models-probe.js +7 -4
  79. package/src/sidecar/reopen-spend.js +1 -1
  80. package/src/sidecar/setup.js +13 -2
  81. package/src/utils/config.js +109 -11
  82. package/src/utils/curated-models.js +17 -2
  83. package/src/utils/degrade.js +5 -0
  84. package/src/utils/no-output-backstop.js +1 -1
  85. package/src/utils/remediation-hints.js +13 -0
  86. package/src/utils/session-index-prune.js +297 -0
  87. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  88. package/src/workspace/artifact-guard.js +8 -114
  89. package/src/workspace/artifact-names.js +222 -0
  90. package/src/workspace/fold-format.js +9 -6
  91. package/src/workspace/live-normalize.js +6 -2
  92. package/src/workspace/matrix-model.js +141 -19
  93. package/src/workspace/run-detail.js +27 -1
  94. package/src/workspace/seat-space.js +143 -0
@@ -1,5 +1,11 @@
1
1
  // src/council/tally.js
2
2
  'use strict';
3
+ const { peersOf, unattributedPeerDrops } = require('./peer-split');
4
+ // The street-cred half (rankPositions / credSeats / computeStreetCred) lives in
5
+ // ./street-cred (v4.8 T3.3 size-gate split; this file stood at 301 of 300 with
6
+ // the seat-keying in it — release Constraint 6 is EXTRACT, never shave).
7
+ // computeStreetCred is re-exported below, so no existing import path moved.
8
+ const { computeStreetCred } = require('./street-cred');
3
9
 
4
10
  /**
5
11
  * Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
@@ -26,49 +32,9 @@ function assignTier(a, d) {
26
32
  return { tier, confidence };
27
33
  }
28
34
 
29
- function mean(arr) { return arr.reduce((s, x) => s + x, 0) / arr.length; }
30
-
31
- /** Map each model to its (possibly fractional) rank position in one judge's order. */
32
- function rankPositions(order) {
33
- const pos = new Map();
34
- let p = 1;
35
- for (const slot of order) {
36
- const group = Array.isArray(slot) ? slot : [slot];
37
- const meanPos = p + (group.length - 1) / 2;
38
- for (const m of group) { pos.set(m, meanPos); }
39
- p += group.length;
40
- }
41
- return pos;
42
- }
43
-
44
- /**
45
- * Both-numbers street-cred. Lower mean rank = better.
46
- * @param {Array<{judge:string, order:Array<string|string[]>}>} rankings
47
- * @param {string[]} models all reviewed models (incl. claude when in-council)
48
- */
49
- function computeStreetCred(rankings, models) {
50
- const judgePos = rankings.map(r => ({ judge: r.judge, pos: rankPositions(r.order) }));
51
- return models.map(m => {
52
- const all = [], peers = [], perJudgeRank = {};
53
- for (const { judge, pos } of judgePos) {
54
- if (!pos.has(m)) { continue; } // absent from this judge's ranking → skip
55
- const rank = pos.get(m);
56
- perJudgeRank[judge] = rank;
57
- all.push(rank);
58
- if (judge !== m) { peers.push(rank); }
59
- }
60
- return {
61
- model: m,
62
- withSelf: all.length ? mean(all) : null,
63
- peersOnly: peers.length ? mean(peers) : null,
64
- perJudgeRank,
65
- };
66
- });
67
- }
68
-
69
35
  // v4.0 §7: council family v2 — every council doc carries {schemaVersion, type}.
70
36
  const COUNCIL_SCHEMA_VERSION = 2;
71
- const VERDICTS = { agree: 'a', dispute: 'd', neutral: 'n' };
37
+ const VERDICTS = { __proto__: null, agree: 'a', dispute: 'd', neutral: 'n' };
72
38
 
73
39
  function countTiers(findings) {
74
40
  const counts = { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 };
@@ -86,31 +52,111 @@ function tally(input) {
86
52
  const byFinding = new Map();
87
53
  for (const adj of adjudications) {
88
54
  if (!byFinding.has(adj.findingId)) { byFinding.set(adj.findingId, []); }
89
- byFinding.get(adj.findingId).push({ judge: adj.judge, verdict: adj.verdict });
55
+ byFinding.get(adj.findingId).push({ judge: adj.judge, verdict: adj.verdict, ...(adj.seat ? { seat: adj.seat } : {}) });
90
56
  }
91
57
  const outFindings = findings.map(f => {
92
58
  const votes = byFinding.get(f.id) || [];
93
- // Only exclude the raiser's own vote when a raiser is known; the raiser is
94
- // populated by the orchestrator (not the reviewer JSON), so an unset raiser
95
- // must not silently drop a real peer vote (L8).
96
- const peers = f.raiser ? votes.filter(v => v.judge !== f.raiser) : votes;
59
+ const peers = peersOf(f, votes);
60
+ // v4.8 T-B2: how many votes `peersOf` excluded without being able to
61
+ // attribute them the one-sided alias fallback, plus (T-B4) the falsy
62
+ // judges of a falsy raiser. ⚠️ NOT the votes the SEAT ids attributed: when
63
+ // both sides carry a seat id the engine knows whose vote it is, so that
64
+ // exclusion is announced by nothing. Same function and same emit rule
65
+ // (> 0 only) as debate.js :: debateTargets, so this document and the
66
+ // defense brief can never announce different numbers.
67
+ // ⚠️ On the SEATED shapes — tally.test.js T1 and T2, the one-sided twin
68
+ // pair — `basis` deliberately does NOT move: counting the ambiguous vote
69
+ // reproduces the naive filter's outcome, measured Confirmed on both, which
70
+ // re-arms #137. Announced, not counted. ⚠️ T-B4 is the deliberate exception
71
+ // and the scope of that sentence narrowed with it: on a finding whose
72
+ // raiser is FALSY, `basis` DOES move, because there the ambiguous vote is
73
+ // the raiser's own inflating its own basis rather than a twin's signal
74
+ // going uncounted (council C1 — see peer-split.js :: peersOf).
75
+ const drops = unattributedPeerDrops(f, votes);
97
76
  const basis = { a: 0, d: 0, n: 0 };
98
77
  // Skip unknown verdict strings so a stray value can't corrupt the basis via
99
- // basis[undefined] = NaN (L9).
78
+ // basis[undefined] = NaN (L9). Guaranteed by VERDICTS's `__proto__: null`,
79
+ // not `!== undefined` alone — measured, all four Object.prototype keys
80
+ // (toString/__proto__/constructor/valueOf) read undefined on it. VERDICTS
81
+ // is module-local, absent from module.exports, so this isn't observable
82
+ // outside this file.
100
83
  for (const v of peers) {
101
84
  const key = VERDICTS[v.verdict];
102
85
  if (key !== undefined) { basis[key] += 1; }
103
86
  }
104
87
  const { tier, confidence } = assignTier(basis.a, basis.d);
88
+ // v4.8 PR4c §3.3 (R8): `peers` has already excluded the raiser BY SEAT, so a
89
+ // surviving peer whose ALIAS equals the raiser's is a different seat of the
90
+ // same model — corroboration that is not independent. Emitted only when TRUE
91
+ // (an unconditional `false` would change every document's shape).
92
+ // ⚠️ The leading `f.raiser &&` is LOAD-BEARING, not decoration — and the
93
+ // interesting part is that this sentence was FALSE for one commit inside
94
+ // v4.8 T-B4, so it is written with its measurement rather than its
95
+ // adjective. It is load-bearing because `peersOf` can hand back a
96
+ // seat-carrying vote whose `judge` is falsy, and then `v.judge === f.raiser`
97
+ // reads `undefined === undefined` on the CLI path (cli-handlers-council.js
98
+ // is a raw JSON.parse with no schema) and `'' === ''` on the MCP path
99
+ // (mcp-tools.js's z.string() accepts the empty string) — so without the
100
+ // guard this stamp fires on documents that name no models at all.
101
+ // MEASURED at each step over the 768-shape cross-product of (f.raiser,
102
+ // f.raiserSeat, v.judge, v.seat, verdict): deleting the guard flipped 8
103
+ // shapes at 64b835b8, ZERO after T-B4 round 1 — which had made `peersOf`
104
+ // drop every falsy-judge vote of a falsy raiser, briefly disarming the very
105
+ // pins that guarded this — and 4 after round 2, whose P0 rule counts the
106
+ // ones the SEAT ids prove are real peers. The 4 are the seat-DIFFER shapes
107
+ // of the T7b and T7d families, i.e. exactly those two tests. Re-run this
108
+ // after any edit to peer-split.js :: peersOf; do not infer it.
109
+ // ⚠️ Alias-only in BOTH directions, and the CHANGELOG says so: it misses
110
+ // `gpt-5,openai/gpt-5` (one model, two aliases — votes carry no
111
+ // resolvedModel) and it fires falsely on a SPLIT alias, whose two seats
112
+ // resolved to different executables. PR4b's ledger treats
113
+ // (alias, resolvedModel) as identity; this stamp is same-ALIAS only.
114
+ // SI-23 (R10): `location` is declared on the MCP schema now
115
+ // (mcp-tools.js :: getTools) so it survives zod validation, but
116
+ // surviving validation and reaching THIS document are two different
117
+ // properties — the round-trip pin below only holds if this map also
118
+ // forwards it, emit-when-present, the same convention as `raiserSeat`
119
+ // two lines up. Scoped to `location` alone AT R10: `claim` was already
120
+ // reaching this function on both the CLI and MCP paths (declared on the
121
+ // MCP schema since before R10) and was ALREADY not forwarded here — R10
122
+ // named the gap and left it for a separate PR rather than widening its
123
+ // own mandate to close it. SI-23 fix round 1 (paid council on PR #183,
124
+ // findings A1/B1, two independent raisers) ruled that indefensible one
125
+ // line from the fix it sits beside: `claim` is forwarded below too,
126
+ // same round.
127
+ // A2 (nit) / C1 (major, contested a0/d1/n2, thin): the truthiness checks
128
+ // below drop an empty string that zod would accept. Deliberate, not a
129
+ // bug — `raiserSeat` above uses the identical pattern, and diverging
130
+ // `location`/`claim` from their own immediate sibling is worse than the
131
+ // edge case, so "" is treated as absent on all three fields.
105
132
  return { id: f.id, raiser: f.raiser, severity: f.severity, tier, basis, confidence,
106
- tierOverride: null, adjudications: votes };
133
+ tierOverride: null, adjudications: votes, ...(f.raiserSeat ? { raiserSeat: f.raiserSeat } : {}),
134
+ ...(f.location ? { location: f.location } : {}),
135
+ ...(f.claim ? { claim: f.claim } : {}),
136
+ ...(f.raiser
137
+ && peers.some(v => v.seat && f.raiserSeat && VERDICTS[v.verdict] === 'a' && v.judge === f.raiser)
138
+ ? { sameModelCorroboration: true } : {}),
139
+ ...(drops > 0 ? { unattributedPeerDrops: drops } : {}) };
107
140
  });
108
141
  return {
109
142
  schemaVersion: COUNCIL_SCHEMA_VERSION,
110
143
  type: 'council-tally',
111
144
  meta,
112
145
  judged: Array.isArray(rankings) && rankings.length >= 2,
113
- streetCred: computeStreetCred(rankings || [], meta.models),
146
+ // v4.8 T3.3: `meta.seats` joins BY VALUE inside computeStreetCred (never
147
+ // positionally against meta.models — run-assemble.js :: buildTallyInput
148
+ // forbids that). The ENGINE emits it only when the bench repeats an alias,
149
+ // so a unique-alias run leaves the rows alias-driven and byte-identical.
150
+ // ⚠️ DO NOT READ THAT AS "hand-assembled input is always alias-driven" —
151
+ // this sentence said so until fix round 1 and it was false, in the way that
152
+ // hides a defect rather than merely misinforming. `meta` is copied verbatim
153
+ // from user JSON on both hand-assembled appendRun paths, and
154
+ // mcp-tools.js :: amicus_council_tally DECLARES `meta.seats`, so such a
155
+ // record produces SEAT-driven street-cred rows here while its `runStats`
156
+ // rows — declared `z.array(z.record(z.any()))`, so never asked for a seat —
157
+ // carry none. That asymmetry is a live quadrant, not a hypothetical; it is
158
+ // what ledger-join.js :: credFor's second lookup exists for.
159
+ streetCred: computeStreetCred(rankings || [], meta.models, meta.seats),
114
160
  findings: outFindings,
115
161
  runStats: (runStats || []).map(r => ({
116
162
  model: r.model, role: r.role, wasChair: !!r.wasChair, conformance: r.conformance || 'clean',
@@ -128,6 +174,11 @@ function tally(input) {
128
174
  ...(r.repairRefused ? { repairRefused: r.repairRefused } : {}),
129
175
  ...(r.waveId ? { waveId: r.waveId } : {}),
130
176
  ...(r.resolvedModel ? { resolvedModel: r.resolvedModel } : {}),
177
+ // v4.8 PR4c §3.1: `seat` rides the same slot. It is emitted upstream only
178
+ // when the bench repeats that alias (run-assemble.js's buildRunStatsEntry),
179
+ // so a unique-alias run is byte-for-byte unchanged here and in verdict.json,
180
+ // which copies this array verbatim (verdict.js :: buildVerdict).
181
+ ...(r.seat ? { seat: r.seat } : {}),
131
182
  status: r.status || 'unknown',
132
183
  durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
133
184
  usage: r.usage || null,
@@ -108,6 +108,13 @@ function buildVerdict(record, decisions = [], opts = {}) {
108
108
  date: record.meta.date,
109
109
  chair: record.meta.chair,
110
110
  council: record.meta.models,
111
+ // v4.8 PR4c §3.2: this projection RENAMES meta.models to `council`, so
112
+ // nothing from meta reaches verdict.json unless it is named here — which is
113
+ // why the seat table needs its own line rather than riding tally's verbatim
114
+ // meta copy. Emitted only when the record carries one (a twin bench), so a
115
+ // unique-alias verdict is byte-for-byte unchanged. The key is `seats`,
116
+ // matching the `seatLoss` sibling below; PR5 codes against that name.
117
+ ...(record.meta.seats ? { seats: record.meta.seats } : {}),
111
118
  claudeInCouncil: record.meta.claudeInCouncil,
112
119
  overallVerdict: opts.overallVerdict === undefined ? null : opts.overallVerdict,
113
120
  findings: record.findings.map(f => {
@@ -121,11 +128,52 @@ function buildVerdict(record, decisions = [], opts = {}) {
121
128
  adjudications: f.adjudications,
122
129
  decision: d.decision || null,
123
130
  applied: d.applied === true,
131
+ // v4.8 PR4c §3.4: this literal is CLOSED — it names every key and copies
132
+ // nothing else off `f` — so the two fields tally() stamps need their own
133
+ // lines or verdict.json names seats it cannot resolve (§1.2). Appended as
134
+ // a pure TAIL, leaving the shipped eleven-key order untouched.
135
+ // ⚠️ NOT `|| null`, even though `duplicateOf` and `decision` above are:
136
+ // `JSON.stringify({raiserSeat: null})` still WRITES `"raiserSeat":`, so
137
+ // that idiom changes the shape of every unique-alias verdict.json and
138
+ // fails seat-parity-ondisk's needles. `applied` is the sibling to copy —
139
+ // it computes a value rather than defaulting one. Emit-when-set matches
140
+ // both producers (tally.js) and keeps a non-twin verdict byte-identical.
141
+ ...(f.raiserSeat ? { raiserSeat: f.raiserSeat } : {}),
142
+ ...(f.sameModelCorroboration ? { sameModelCorroboration: true } : {}),
124
143
  };
125
144
  if (f.debate) { out.debate = f.debate; } // v4.1: additive debate decoration carry-through (spec §5.6)
126
145
  return out;
127
146
  }),
128
- streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly })),
147
+ // v4.8 fix round 1 (review finding): emit-when-DIFFERENT, adapted to this
148
+ // row's flat {model, seat} shape — model is the alias, seat is the seat
149
+ // id, so on a unique-alias bench they are byte-equal and nothing is
150
+ // 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:
154
+ // `r.seat && r.seat.id !== r.seat.alias`), so passing its verdict
155
+ // through here is safe. The street-cred producer never has such an object
156
+ // at this point, only a flat row, so a pass-through here would leak `seat`
157
+ // onto every unique-alias verdict.json the moment that producer's own
158
+ // guard slipped — silently, since nothing else guards this closed literal.
159
+ // This check is deliberate defense in depth: buildVerdict is also reachable
160
+ // on externally-supplied records that never touched computeStreetCred
161
+ // in-process at all — the MCP `record` param of mcp-tools.js ::
162
+ // amicus_verdict is `z.record(z.any())`, fully permissive — so this
163
+ // literal's own byte-identity cannot be contingent on that producer alone;
164
+ // this file's own tests exercise that exact shape (hand-built rec objects,
165
+ // never calling tally()). tally.json keeps its own pin regardless — a
166
+ // genuine producer bug still reds at seat-parity-ondisk.test.js — this
167
+ // check exists so verdict.json is never the ONE document such a bug (or an
168
+ // externally-supplied record) masks.
169
+ // ⚠️ NO LONGER INERT. This comment said "computeStreetCred emits no `seat`
170
+ // at all yet" until v4.8 T3.3 shipped that producer — street-cred.js ::
171
+ // computeStreetCred, one row per SEAT with the id emitted when it differs
172
+ // from the alias. Both guards now fire on the same real documents, and the
173
+ // pin that proves this literal carries the field through lives at
174
+ // seat-parity-ondisk.test.js on a real runCouncil twin bench.
175
+ streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly,
176
+ ...(s.seat && s.seat !== s.model ? { seat: s.seat } : {}) })),
129
177
  runStats: record.runStats,
130
178
  tierCounts: record.tierCounts,
131
179
  // Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
package/src/headless.js CHANGED
@@ -174,13 +174,13 @@ function withTimeout(promise, ms, label) {
174
174
  * because raising the env var changes exactly this window.
175
175
  * - fromEnv=false: `ms` arrived as a direct, caller-set numeric option.
176
176
  * Task 6 review (Important finding): this is NOT synonymous with "the
177
- * env var doesn't apply" — src/council/run-retry.js:154 computes a
178
- * Stage-1 retry's escalated window as
177
+ * env var doesn't apply" — `run-retry.js :: retryStage1Losses`'s
178
+ * `escalatedBackstopMs` computes a Stage-1 retry's window as
179
179
  * `2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs :
180
- * resolveNoOutputBackstopMs())` and forwards that as a direct
181
- * `noOutputBackstopMs` (line 186) — so a 240s retry-fired backstop is
182
- * "caller-set" by this predicate while still being *derived from* the
183
- * env default doubled. Only src/sidecar/models-probe.js:79's hardcoded,
180
+ * resolveNoOutputBackstopMs())` and forwards it on `common` as a direct
181
+ * `noOutputBackstopMs` — so a 600s retry-fired backstop (the 300s env
182
+ * default, doubled) is "caller-set" by this predicate while still being
183
+ * *derived from* the env default. Only src/sidecar/models-probe.js:79's hardcoded,
184
184
  * non-tunable 30s (PROBE_WINDOW_MS; docs/usage.md:406 promises it's "not
185
185
  * tunable") is truly independent of the env var. Because a real
186
186
  * `fromEnv` flag distinguishing those two cases would have to ride the
@@ -1306,6 +1306,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1306
1306
  ...settleResult,
1307
1307
  ...subtreeFlags,
1308
1308
  ...subtreeResult,
1309
+ // #133 P1: sessionId was assigned at :413/:417, well before this
1310
+ // return — guaranteed set here, same as `taskId` above.
1311
+ opencodeSessionId: sessionId,
1309
1312
  error: sessionError
1310
1313
  };
1311
1314
  }
@@ -1321,6 +1324,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1321
1324
  ...settleResult,
1322
1325
  ...subtreeFlags,
1323
1326
  ...subtreeResult,
1327
+ // #133 P1: see the comment on the sibling return above — guaranteed set.
1328
+ opencodeSessionId: sessionId,
1324
1329
  exitCode: 0
1325
1330
  };
1326
1331
 
@@ -1396,6 +1401,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1396
1401
  aborted: false,
1397
1402
  taskId,
1398
1403
  usage: emptyUsageTotals(),
1404
+ // #133 P1: measured, not assumed — `sessionId` (:365) is in scope
1405
+ // through this whole catch (it is already read at the `if (sessionId)`
1406
+ // abort-on-error above) but, unlike the two returns in the try body,
1407
+ // is NOT guaranteed assigned here: an exception thrown before session
1408
+ // creation (e.g. during the pre-session server-readiness wait) reaches
1409
+ // this same catch with sessionId still unset. `|| null` makes both
1410
+ // outcomes explicit and already schema-shaped (run.schema.json:23
1411
+ // wants string|null, never undefined).
1412
+ opencodeSessionId: sessionId || null,
1399
1413
  error: error.message
1400
1414
  };
1401
1415
  }
package/src/mcp-tools.js CHANGED
@@ -390,19 +390,89 @@ function getTools() {
390
390
  'returns the tally record immediately. No subprocess, no polling. Claude ' +
391
391
  'assembles the input and may override margin tiers afterward.',
392
392
  inputSchema: {
393
+ // v4.8 PR4c R4c-5: the three seat keys are declared here or zod SILENTLY
394
+ // STRIPS them, and `amicus_council_tally` stays permanently on the #137
395
+ // behaviour while `amicus council tally` (a raw JSON.parse with no schema)
396
+ // gets the fix. Deliberately PERMISSIVE — validate the envelope, let
397
+ // tally() be the single arbiter of shape on both paths, matching
398
+ // amicus_verdict's own `record: z.record(z.any())`. `z.array(z.record())`
399
+ // would reject a string seat table the CLI accepts, and a bare
400
+ // `.optional()` would reject the `|| null` a hand-assembling caller
401
+ // writes, which serializes byte-identically to omitting the key.
402
+ // ⚠️ ALL THREE keys carry `.nullable()`, not two of three (council C1).
403
+ // Bare `.optional()` on `seats` failed the WHOLE call with `seats:
404
+ // Expected array, received null` where `amicus council tally` — the raw
405
+ // JSON.parse at cli-handlers-council.js:24 — accepts it and every
406
+ // seat-space reader treats it as absent (`Array.isArray(null)` is
407
+ // false). That is the same silent fork in the same schema, one key
408
+ // later. `amicus_verdict` below already spells its array/record
409
+ // envelope keys this way (`seatLoss` :461, `degrades` :465).
393
410
  meta: z.object({
394
411
  runId: z.string(), runType: z.string().optional(), date: z.string().optional(),
395
412
  models: z.array(z.string()).min(1), chair: z.string().optional(),
396
- claudeInCouncil: z.boolean().optional(),
413
+ claudeInCouncil: z.boolean().optional(), seats: z.array(z.any()).nullable().optional(),
397
414
  }).describe('Run metadata; meta.models lists every reviewed model.'),
398
415
  findings: z.array(z.object({
399
416
  id: z.string(), raiser: z.string(), severity: z.string(), claim: z.string().optional(),
417
+ raiserSeat: z.string().nullable().optional(),
418
+ // SI-23 (R10): `location` was silently stripped by this closed
419
+ // z.object too — the same #137-shaped fork as the seat keys above,
420
+ // one key later. R10 (as relayed) also named `evidence`/`file`/`line`
421
+ // as siblings to fix here; MEASURED against findings.js :: REQUIRED
422
+ // (Stage-1's required-field list), briefings.js ::
423
+ // FINDINGS_JSON_SHAPE (the JSON contract every reviewer is briefed
424
+ // on), and every `f.<key>` read under src/ — none of those three
425
+ // exist anywhere in this codebase's finding shape. Only `location`
426
+ // does: anonymize.js :: toGlobalFindings forwards it alongside
427
+ // `claim` into the exact "run-global findings" shape this tool
428
+ // already documents below, and briefings-debate.js ::
429
+ // findingBlockDefense / debate.js :: debateTargets read it on the
430
+ // engine's native debate stage — unreachable from this MCP tool
431
+ // today (no MCP debate step exists), so this is a document-shape /
432
+ // round-trip fix here, not an unlocked renderer. `rationale` is also
433
+ // Stage-1-required but toGlobalFindings does not forward it past
434
+ // Stage-1 either; adding it here would invent a shape the engine's
435
+ // own path never produces, so it stays out.
436
+ // Plain `.optional()`, matching `claim` immediately above — a
437
+ // same-shape string sibling, not a seat field, so the `.nullable()`
438
+ // seat idiom explained above `meta` doesn't apply here.
439
+ // tally.js :: tally's `outFindings` map was ALSO dropping
440
+ // `claim`/`location` from every finding regardless of what survived
441
+ // validation, on the CLI and MCP paths alike — declaring the field
442
+ // here alone would validate but never reach the document, so that
443
+ // map forwards both, the same emit-when-present way it already
444
+ // forwards `raiserSeat`. R10 shipped only `location`'s forward and
445
+ // left `claim`'s for later (`claim` was already declared above,
446
+ // pre-R10); SI-23 fix round 1 (PR #183 council, A1/B1) closed that
447
+ // gap in the same map, same convention.
448
+ location: z.string().optional(),
400
449
  })).describe('Run-global findings (ids already A1/B2/C3-prefixed by Claude).'),
401
450
  adjudications: z.array(z.object({
402
451
  judge: z.string(), findingId: z.string(), verdict: z.enum(['agree', 'dispute', 'neutral']),
452
+ seat: z.string().nullable().optional(),
403
453
  })).describe('One row per (judge × finding).'),
454
+ // v4.8 T3.2: `seat` added here to match `adjudications.seat` immediately
455
+ // above — the channel run-assemble.js's buildTallyInput now puts on
456
+ // rankings[] (the judge's own seat, emit-when-DIFFERENT) is otherwise
457
+ // unreachable from a hand-assembled MCP call: zod strips unknown keys
458
+ // by default. NOT R10's territory — R10 covers SI-23 (findings[]
459
+ // losing evidence/file/line) as its own document-shape PR; this is a
460
+ // different field on a different array, and the adjudications.seat
461
+ // precedent immediately above is this repo's own pattern of adding a
462
+ // seat field in the PR that introduces it, not deferring to a later
463
+ // schema PR.
464
+ // v4.8 T3.3 adds `orderSeats` for the same reason and by the same rule.
465
+ // It is the seat-valued PARALLEL of `order` (street-cred.js ::
466
+ // rankPositions keys on it), so without the declaration an MCP caller
467
+ // that sends it gets the alias collapse SI-20 exists to fix, silently.
468
+ // `nullable()` inside the arrays is load-bearing, not defensive:
469
+ // anonymize.js :: rankingToOrder emits `null` for every slot with no
470
+ // distinguishing seat, so a mixed bench legitimately sends nulls.
404
471
  rankings: z.array(z.object({
405
472
  judge: z.string(), order: z.array(z.union([z.string(), z.array(z.string())])),
473
+ seat: z.string().nullable().optional(),
474
+ orderSeats: z.array(z.union([z.string().nullable(),
475
+ z.array(z.string().nullable())])).nullable().optional(),
406
476
  })).describe("Each judge's preference order over the reviews (ties = nested array)."),
407
477
  runStats: z.array(z.record(z.any())).optional().describe('Optional per-model run stats (status/duration/usage).'),
408
478
  project: z.string().optional().describe('Optional project directory path.'),
@@ -22,7 +22,7 @@
22
22
  * em-dash) and blind mode's labelOf(alias) lookup never matches (real model
23
23
  * id leaks). The alias IS on disk per-leg, though: every council leg goes
24
24
  * through src/sidecar/fanout-leg.js's runSingleAttempt, which calls
25
- * `writeLegPatch(legDir, { parentWave, modelInput })` synchronously,
25
+ * `writeLegPatch(legDir, { parentWave, modelInput, seat })` synchronously,
26
26
  * immediately after leg creation (fanout-leg.js:101) — well before any
27
27
  * status poll could reasonably observe it missing. So this module reads
28
28
  * `modelInput` straight off the leg's own metadata.json, no run.json join
@@ -127,6 +127,12 @@ function buildLegRow(project, legId, runCtx) {
127
127
  const row = {
128
128
  taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
129
129
  modelInput, role,
130
+ // v4.8 R5: the leg's seat id (`alias#N`), written at launch by
131
+ // fanout-leg.js :: runSingleAttempt. Null on a unique-alias bench, where
132
+ // buildSeats sets id === alias and the shared emit-when-DIFFERENT predicate
133
+ // therefore stamps nothing — the same rule run-stats-entry.js ::
134
+ // buildRunStatsEntry applies on the terminal path.
135
+ seat: meta.seat || null,
130
136
  };
131
137
  let stalledMs = null;
132
138
  let p = null;
@@ -26,9 +26,9 @@ const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
26
26
  // a timed-out single session never exited (watch-render.js:138 polls until
27
27
  // TERMINAL.has(doc.status), and amicus_status stamps metadata.status straight onto
28
28
  // the doc — mcp-server.js:687); a timed-out leg skipped the "prefer metadata.usage
29
- // over the stale progress.json snapshot" branch in council-legs.js:162 and reported
30
- // an under-counted cost; and markLive kept stamping view:'live' on a finished
31
- // single-session doc.
29
+ // over the stale progress.json snapshot" branch in council-legs.js :: buildLegRow
30
+ // and reported an under-counted cost; and markLive kept stamping view:'live' on a
31
+ // finished single-session doc.
32
32
  // NOTE this is deliberately NOT the same list as src/utils/result-schema.js:13 TERMINAL_STATUSES
33
33
  // (the leg set, no 'partial'). Two mirrors of THIS list exist — src/workspace/run-detail.js:26 and
34
34
  // electron/workspace-ui/live-model.js:14 — byte-identical, held by drift pins. Edit all three.
@@ -98,7 +98,7 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
98
98
  });
99
99
  waveDir = getSessionDir(project, waveId);
100
100
  emitLegStarted(waveDir, waveId, legId, leg.model, leg.modelInput, follow);
101
- writeLegPatch(legDir, { parentWave: waveId, modelInput: leg.modelInput });
101
+ writeLegPatch(legDir, { parentWave: waveId, modelInput: leg.modelInput, seat: leg.seat });
102
102
  saveInitialContext(legDir, systemPrompt, userMessage);
103
103
 
104
104
  // Per-leg watchdog: a BACKSTOP strictly behind runHeadless's own deadline
@@ -158,6 +158,27 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
158
158
  reason: result.error || undefined,
159
159
  completedAt: new Date().toISOString(),
160
160
  usage: usage || undefined,
161
+ // #133 P1: `|| undefined`, matching status/reason/usage above — NOT the
162
+ // `toolSettleAborted` carve-out below, because a session id has no
163
+ // meaningful-falsy value the way `false` does there. A clean/failed
164
+ // attempt (no session ever created) therefore carries neither key on
165
+ // disk, rather than writing an explicit `null` that could clobber a
166
+ // PRIOR attempt's real session id: fallback substitution reruns
167
+ // runSingleAttempt against the SAME legId/legDir (fanout-leg-fallback.js
168
+ // "SAME leg dir under the SAME legId"), and writeLegPatch's read-merge-
169
+ // write only drops keys that are `undefined`, not ones explicitly set to
170
+ // `null`. This does not disagree with result-schema.js:72's
171
+ // `metadata.opencodeSessionId || null` — that coercion runs downstream,
172
+ // on the per-request OUTPUT run document, where run.schema.json requires
173
+ // the field always present as string|null. The two layers do different
174
+ // jobs (omit-if-absent on disk here vs. always-present in the emitted
175
+ // doc there) and compose without conflict.
176
+ //
177
+ // Raised again in the PR #185 council round (findings A3/D2) as a
178
+ // possible conflation of "no session" with "empty session id"; kept
179
+ // as-is per the reasoning above — this is `legPatch`'s existing
180
+ // convention (matching status/reason/usage), not a gap opened by #133 P1.
181
+ opencodeSessionId: result.opencodeSessionId || undefined,
161
182
  // v4.4 B4 part 1: the leg completed with tool calls still live, so its
162
183
  // OpenCode session may have kept working (and billing) afterwards. Travels
163
184
  // with the leg so it is readable long after the run's stderr is gone.
@@ -75,13 +75,38 @@ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedA
75
75
  /**
76
76
  * v4.3 §7.2 (moved here v4.7 PR3 Task 1): stamp council attribution onto every
77
77
  * leg — fanout-leg's appendSpend reads it; no-op for every non-council caller.
78
- * v4.7 F8 (Task 7) adds tag stamping in the same pass.
78
+ * v4.7 F8 (Task 7) adds tag stamping in the same pass. v4.8 R5 (T4.2) adds
79
+ * seat stamping in the same pass.
79
80
  */
80
81
  function stampLegAttribution(legs, options) {
81
82
  if (options.councilRunId || options.councilName) {
82
83
  legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
83
84
  }
84
85
  if (options.tag) { legs.forEach(l => { l.tag = options.tag; }); }
86
+ // v4.8 R5: seat identity for the LIVE path. `options.seats` is the launching
87
+ // wave's roster, index-parallel with `options.models` by construction
88
+ // (run-stage1-launch.js builds `roster` and `models` from the same filter, and
89
+ // fanout-validate.js:66-87 pushes exactly one leg per model on BOTH its ok and
90
+ // its unroutable branch, so a leg that never routed still holds its slot).
91
+ //
92
+ // ⚠️ emit-when-DIFFERENT, against the seat's OWN alias — the shared predicate
93
+ // stated at run-stats-entry.js :: buildRunStatsEntry, which the three sites in
94
+ // run-assemble.js also spell. buildSeats mints `alias#N` ONLY when an alias
95
+ // repeats (seats.js:67), so `id !== alias` IS "the bench repeats this alias",
96
+ // and on a unique bench `id` IS the alias — a bare `if (s.id)` would stamp an
97
+ // alias-valued seat onto every leg of every run: the same wrong output shape
98
+ // run-assemble.js:165-169 records having already fixed once there (a
99
+ // wrong-comparator bug, `!== j.judge` rather than `!== alias`, with a
100
+ // narrower trigger — only a leg/seat alias drift, not every leg).
101
+ // Comparing against the seat's own alias and never against `model` also makes
102
+ // this immune to the two cases where `model` is NOT the alias (a leg reporting
103
+ // no modelInput; a padded --council member).
104
+ if (Array.isArray(options.seats)) {
105
+ legs.forEach((l, i) => {
106
+ const s = options.seats[i];
107
+ if (s && s.id !== s.alias) { l.seat = s.id; }
108
+ });
109
+ }
85
110
  }
86
111
 
87
112
  module.exports = { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution };
@@ -20,16 +20,10 @@ const { ERROR_CODES } = require('../utils/error-doc');
20
20
  // Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
21
21
  // fanout tests import it from here.
22
22
  const { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution } = require('./fanout-wave-io');
23
-
24
- /**
25
- * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
26
- * @param {string} waveId
27
- * @param {number} count
28
- * @returns {string[]}
29
- */
30
- function deriveLegIds(waveId, count) {
31
- return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
32
- }
23
+ // Leg-id derivation lives in ./leg-ids (v4.8 PR0 size-gate split).
24
+ // deriveLegIds is re-exported below — mcp-server.js, fanout-retry.js and
25
+ // the fanout tests import it from here.
26
+ const { deriveLegIds } = require('./leg-ids');
33
27
 
34
28
  /**
35
29
  * Run a fan-out wave. Spec §4.3.
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+ // deriveLegIds — the <waveId>-<i+1> leg-id convention. Moved verbatim from
3
+ // fanout.js:24-32 (v4.8 PR0 size-gate split, zero behavior; fanout.js was
4
+ // 300/300). The shape is load-bearing: council stage-1 composes
5
+ // `${runId}-s1` waves onto it, and ~10 suites plus a replay fixture
6
+ // hard-code the composite. Pinned by tests/sidecar/fanout.test.js:84-93
7
+ // through fanout.js's re-export.
8
+
9
+ /**
10
+ * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
11
+ * @param {string} waveId
12
+ * @param {number} count
13
+ * @returns {string[]}
14
+ */
15
+ function deriveLegIds(waveId, count) {
16
+ return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
17
+ }
18
+
19
+ module.exports = { deriveLegIds };
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  /** Probe backstop override (spec D5) — a fixed constant, NOT env-configurable;
20
- * the env knob (AMICUS_NO_OUTPUT_BACKSTOP_MS) stays the ordinary 120s leg default. */
20
+ * the env knob (AMICUS_NO_OUTPUT_BACKSTOP_MS) stays the ordinary 300s leg default. */
21
21
  const PROBE_WINDOW_MS = 30000;
22
22
 
23
23
  /** Fixed tiny prompt — a probe leg only needs to prove the model answers at all. */
@@ -28,9 +28,12 @@ const PROBE_PROMPT = 'Reply with exactly: OK';
28
28
  * schema.js) per the plan's Global Constraints classification contract.
29
29
  * Precedence matters: 'complete' wins outright; otherwise a NO_OUTPUT_
30
30
  * BACKSTOP error (PR2's silent-leg detector, armed here at PROBE_WINDOW_MS
31
- * instead of its 120s default) is the one specific error shape that means
32
- * "the model accepted the request and never produced a token" rather than an
33
- * ordinary routing/auth/timeout failure.
31
+ * instead of its 300s default) is the one specific error shape that means
32
+ * NOTHING arrived in the window no output, no reasoning, no tool call —
33
+ * rather than an ordinary routing/auth/timeout failure. ⚠️ It does NOT prove
34
+ * the model "accepted the request": a stalled gateway or a dropped connection
35
+ * fires the same backstop. `accepted-but-silent` names the classification, not
36
+ * a fact about the endpoint (council B1 on PR #182).
34
37
  * @param {{status?:string, error?:string|null}} leg
35
38
  * @returns {'served'|'accepted-but-silent'|'error'}
36
39
  */
@@ -10,7 +10,7 @@
10
10
  /**
11
11
  * Resolve a reopened session's usage, write it onto metadata, and append one
12
12
  * attributed ledger row. Mirrors start.js's finalize (the only sites that
13
- * dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
13
+ * dropped usage - BACKLOG.md :: "continue/resume never compute per-run usage" — ⚠️ cited `BACKLOG.md:280` until T-A8 re-opened it 2026-08-17: `:280` is a docs/usage.md line, the entry is `:289`). Best-effort ledger append; never throws.
14
14
  * @returns {{usage: object|null}}
15
15
  */
16
16
  function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
@@ -51,7 +51,11 @@ function createDefaultConfig(defaultModel) {
51
51
  const cfg = {
52
52
  ...existing,
53
53
  default: existing.default || defaultModel,
54
- aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
54
+ // Same restatement as the readline gate below (fix round 3, G-2): a spread
55
+ // into `{}` re-materialises Object.prototype. `saveConfig` rebuilds this
56
+ // into its own literal anyway, so this one is defense in depth rather than
57
+ // a measured hole — recorded as such rather than claimed as a fix.
58
+ aliases: { __proto__: null, ...getDefaultAliases(), ...(existing.aliases || {}) },
55
59
  };
56
60
  saveConfig(cfg);
57
61
  logger.info('Default config ensured', {
@@ -128,7 +132,14 @@ function resolveChoice(input, picks, catalog) {
128
132
  return { modelId: input };
129
133
  }
130
134
  const cfg = loadConfig();
131
- const aliases = { ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
135
+ // ⚠️ `__proto__: null` v4.8 SI-22.4 fix round 3 (council G-2). `input` here
136
+ // is FREE-FORM readline text, and the gate below is `aliases[input] !==
137
+ // undefined`, so on a plain literal `toString` / `valueOf` / `constructor` /
138
+ // `hasOwnProperty` all measured TRUE and returned `{alias: input, noUpgrade:
139
+ // true}` — setup accepted them as existing aliases. Spreading a
140
+ // null-prototype object into a bare `{}` produces a PLAIN object again, so the
141
+ // curated-models fix does not reach this literal; the seed has to be restated.
142
+ const aliases = { __proto__: null, ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
132
143
  if (aliases[input] !== undefined) {
133
144
  return { alias: input, noUpgrade: true };
134
145
  }