amicus 4.7.0 → 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 (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +948 -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 +3 -3
  8. package/docs/council.md +181 -34
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/testing.md +1 -1
  12. package/docs/troubleshooting.md +6 -4
  13. package/docs/usage.md +15 -11
  14. package/electron/setup-ui-aliases.js +2 -2
  15. package/electron/workspace-ui/index.html +9 -0
  16. package/electron/workspace-ui/live-dead-seats.js +228 -0
  17. package/electron/workspace-ui/live-model.js +10 -236
  18. package/electron/workspace-ui/live-seats.js +126 -0
  19. package/electron/workspace-ui/workspace-app.js +6 -41
  20. package/electron/workspace-ui/workspace-banners.js +95 -0
  21. package/electron/workspace-ui/workspace-lazy.js +55 -12
  22. package/electron/workspace-ui/workspace-matrix.js +2 -2
  23. package/electron/workspace-ui/workspace-panels.js +42 -10
  24. package/electron/workspace-ui/workspace-render.js +2 -2
  25. package/electron/workspace-ui/workspace-seats.js +101 -17
  26. package/package.json +6 -3
  27. package/schemas/council-run-live.schema.json +1 -0
  28. package/schemas/council-run.schema.json +19 -0
  29. package/schemas/council-tally.schema.json +34 -2
  30. package/schemas/council-verdict.schema.json +15 -0
  31. package/scripts/postinstall.js +6 -3
  32. package/scripts/setup-hooks.js +49 -3
  33. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  34. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  35. package/skills/second-opinion/SKILL.md +25 -11
  36. package/skills/sidecar/SKILL.md +3 -1
  37. package/src/cli-handlers-doctor.js +7 -0
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/peer-split.js +196 -0
  47. package/src/council/report-html.js +12 -5
  48. package/src/council/report-md.js +146 -0
  49. package/src/council/report.js +188 -112
  50. package/src/council/run-assemble.js +100 -79
  51. package/src/council/run-chair.js +17 -1
  52. package/src/council/run-debate-revote.js +268 -0
  53. package/src/council/run-debate.js +92 -102
  54. package/src/council/run-finish.js +70 -0
  55. package/src/council/run-launch.js +56 -18
  56. package/src/council/run-retry-group.js +266 -0
  57. package/src/council/run-retry-keys.js +74 -0
  58. package/src/council/run-retry-launch.js +55 -0
  59. package/src/council/run-retry-notes.js +65 -13
  60. package/src/council/run-retry.js +179 -162
  61. package/src/council/run-stage1-launch.js +27 -10
  62. package/src/council/run-stage1-rows.js +220 -0
  63. package/src/council/run-stage1-superseded.js +156 -0
  64. package/src/council/run-stage2.js +65 -5
  65. package/src/council/run-stages.js +72 -69
  66. package/src/council/run-state.js +1 -1
  67. package/src/council/run-stats-entry.js +71 -0
  68. package/src/council/run-verdict-files.js +52 -0
  69. package/src/council/run.js +42 -52
  70. package/src/council/seats.js +262 -0
  71. package/src/council/stage1-bind.js +142 -0
  72. package/src/council/street-cred.js +258 -0
  73. package/src/council/tally.js +100 -49
  74. package/src/council/verdict.js +49 -1
  75. package/src/headless.js +83 -6
  76. package/src/mcp-tools.js +71 -1
  77. package/src/observe/council-legs.js +7 -1
  78. package/src/observe/live-doc.js +3 -3
  79. package/src/sidecar/continue.js +8 -23
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-retry.js +15 -3
  82. package/src/sidecar/fanout-wave-io.js +26 -1
  83. package/src/sidecar/fanout.js +4 -10
  84. package/src/sidecar/leg-ids.js +19 -0
  85. package/src/sidecar/models-probe.js +7 -4
  86. package/src/sidecar/reopen-spend.js +32 -0
  87. package/src/sidecar/resume.js +1 -1
  88. package/src/sidecar/setup.js +13 -2
  89. package/src/utils/config.js +109 -11
  90. package/src/utils/curated-models.js +17 -2
  91. package/src/utils/degrade.js +5 -0
  92. package/src/utils/doctor-engine-check.js +32 -0
  93. package/src/utils/engine-install-scan.js +98 -15
  94. package/src/utils/engine-repair.js +96 -2
  95. package/src/utils/no-output-backstop.js +1 -1
  96. package/src/utils/remediation-hints.js +42 -0
  97. package/src/utils/session-index-prune.js +297 -0
  98. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  99. package/src/workspace/artifact-guard.js +8 -114
  100. package/src/workspace/artifact-names.js +222 -0
  101. package/src/workspace/fold-format.js +9 -6
  102. package/src/workspace/live-normalize.js +6 -2
  103. package/src/workspace/matrix-model.js +141 -19
  104. package/src/workspace/run-detail.js +27 -1
  105. 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
@@ -154,6 +154,58 @@ function withTimeout(promise, ms, label) {
154
154
  ]);
155
155
  }
156
156
 
157
+ /**
158
+ * Task 6 (#129, #133): build the NO_OUTPUT_BACKSTOP reason string. Report
159
+ * ONLY what the mechanism observed — a deadline passed with no substantive
160
+ * activity (output/reasoning/tool calls) — never a cause. At the pre-send
161
+ * firing site (runHeadless, ~:506-518) the backstop can win the race against
162
+ * sendPromptAsync before the send ever resolves, so "the endpoint accepted
163
+ * the request" is not even something that site observed. The previous text
164
+ * asserted "likely a listed-but-not-serving model or a dead endpoint" — a
165
+ * canned guess with no evidence gate — which sent 30 minutes of #133's
166
+ * debugging at model ids and API keys while the real cause (an opencode
167
+ * engine version skew) sat in ~/.local/share/opencode/log/opencode.log the
168
+ * whole time.
169
+ *
170
+ * `fromEnv` distinguishes two ways `ms` was decided, NOT whether
171
+ * AMICUS_NO_OUTPUT_BACKSTOP_MS is relevant — it is relevant on both branches:
172
+ * - fromEnv=true: `ms` IS the live env-resolved value (or its documented
173
+ * default) — the message says so directly, "(0 disables)" included,
174
+ * because raising the env var changes exactly this window.
175
+ * - fromEnv=false: `ms` arrived as a direct, caller-set numeric option.
176
+ * Task 6 review (Important finding): this is NOT synonymous with "the
177
+ * env var doesn't apply" — `run-retry.js :: retryStage1Losses`'s
178
+ * `escalatedBackstopMs` computes a Stage-1 retry's window as
179
+ * `2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs :
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
+ * non-tunable 30s (PROBE_WINDOW_MS; docs/usage.md:406 promises it's "not
185
+ * tunable") is truly independent of the env var. Because a real
186
+ * `fromEnv` flag distinguishing those two cases would have to ride the
187
+ * same value through src/sidecar/fanout.js, which is line-locked at
188
+ * EXACTLY 300/300 this release, the caller-set branch instead names the
189
+ * var as something this window *overrides* rather than either claiming
190
+ * it governs (false on the probe) or omitting it (false/unhelpful on the
191
+ * retry) — true on both, and still points a user at the remedy.
192
+ *
193
+ * Kept module-scope and pure (not a closure over runHeadless locals) so it
194
+ * can be asserted on directly in tests without driving the poll loop; the
195
+ * `noOutputBackstopReason` closure inside runHeadless just forwards to this
196
+ * with the per-run `noOutputBackstopMs`/`backstopFromEnv` values, so the two
197
+ * firing sites there stay identical to what's tested here.
198
+ * @param {{ms: number, fromEnv: boolean}} args
199
+ * @returns {string}
200
+ */
201
+ function formatNoOutputBackstopReason({ ms, fromEnv }) {
202
+ return 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
203
+ + `${Math.round(ms / 1000)}s — `
204
+ + (fromEnv
205
+ ? 'the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)'
206
+ : 'a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default');
207
+ }
208
+
157
209
  /**
158
210
  * Wait for the OpenCode server to be ready using SDK health check
159
211
  */
@@ -473,16 +525,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
473
525
  // deadline `nowMs >= deadline` can never satisfy — the backstop would
474
526
  // silently never fire. Finite zero (the documented explicit-disable
475
527
  // value) still takes the direct branch: Number.isFinite(0) === true.
476
- const noOutputBackstopMs = Number.isFinite(options.noOutputBackstopMs)
477
- ? options.noOutputBackstopMs : resolveNoOutputBackstopMs(options._env);
528
+ // Capture the source-of-truth predicate ONCE and reuse it (do not call
529
+ // Number.isFinite a second time below) — `backstopFromEnv` is just its
530
+ // complement: options.noOutputBackstopMs is a direct, caller-set value
531
+ // exactly when Number.isFinite is true, so the window came from
532
+ // resolveNoOutputBackstopMs's env-resolution seam exactly when it's false.
533
+ // Reused both to pick noOutputBackstopMs and to decide whether the reason
534
+ // string below may name the env var, so the two can never drift apart.
535
+ const backstopFromEnv = !Number.isFinite(options.noOutputBackstopMs);
536
+ const noOutputBackstopMs = backstopFromEnv
537
+ ? resolveNoOutputBackstopMs(options._env) : options.noOutputBackstopMs;
478
538
  const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: Date.now() });
479
539
  let backstopFired = false;
480
540
  // Single source for the reason string so the pre-send firing site below and
481
541
  // the per-poll firing site further down (still ticking the SAME instance)
482
- // can never drift apart.
483
- const noOutputBackstopReason = () => 'NO_OUTPUT_BACKSTOP: model produced no '
484
- + `output, reasoning, or tool calls in ${Math.round(noOutputBackstopMs / 1000)}s `
485
- + '— likely a listed-but-not-serving model or a dead endpoint';
542
+ // can never drift apart. Forwards to the module-scope, pure
543
+ // formatNoOutputBackstopReason (below/exported) so tests can assert on the
544
+ // string shape directly without driving the whole poll loop.
545
+ const noOutputBackstopReason = () => formatNoOutputBackstopReason({
546
+ ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
547
+ });
486
548
 
487
549
  // Send prompt asynchronously (returns immediately, we poll for results) —
488
550
  // bounded by the backstop: an endpoint that accepts but never answers must
@@ -1244,6 +1306,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1244
1306
  ...settleResult,
1245
1307
  ...subtreeFlags,
1246
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,
1247
1312
  error: sessionError
1248
1313
  };
1249
1314
  }
@@ -1259,6 +1324,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1259
1324
  ...settleResult,
1260
1325
  ...subtreeFlags,
1261
1326
  ...subtreeResult,
1327
+ // #133 P1: see the comment on the sibling return above — guaranteed set.
1328
+ opencodeSessionId: sessionId,
1262
1329
  exitCode: 0
1263
1330
  };
1264
1331
 
@@ -1334,6 +1401,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1334
1401
  aborted: false,
1335
1402
  taskId,
1336
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,
1337
1413
  error: error.message
1338
1414
  };
1339
1415
  }
@@ -1413,6 +1489,7 @@ module.exports = {
1413
1489
  extractSummary,
1414
1490
  findTrailingFoldMarker,
1415
1491
  formatFoldOutput,
1492
+ formatNoOutputBackstopReason,
1416
1493
  DEFAULT_TIMEOUT,
1417
1494
  FOLD_MARKER,
1418
1495
  COMPLETE_MARKER,
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.
@@ -88,7 +88,7 @@ Build on the previous sidecar's findings. The user wants to continue or extend t
88
88
 
89
89
  /** Create session metadata for continuation */
90
90
  function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
91
- const { model, briefing, headless, agent, gateway, resolutionVersion } = options;
91
+ const { model, briefing, headless, agent, gateway, resolutionVersion, tag } = options;
92
92
 
93
93
  const sessionDir = SessionPaths.sessionDir(project, taskId);
94
94
  fs.mkdirSync(sessionDir, { recursive: true });
@@ -102,7 +102,11 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
102
102
  agent: agent || (headless ? 'build' : 'chat'),
103
103
  status: 'running',
104
104
  createdAt: new Date().toISOString(),
105
- continuesFrom: oldTaskId
105
+ continuesFrom: oldTaskId,
106
+ // v4.7.1 Task 7 (D13): absent-not-null, same idiom as start-metadata.js:50
107
+ // — a continuation inherits the parent's tag so a continue chain never
108
+ // scatters into `(unattributed)`.
109
+ ...(tag ? { tag } : {}),
106
110
  };
107
111
  // #61 Task 5.2 (best-effort provenance): only present when THIS continue
108
112
  // call freshly routed an explicit --model through the gateway router — the
@@ -116,26 +120,6 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
116
120
  return sessionDir;
117
121
  }
118
122
 
119
- /**
120
- * Resolve a reopened session's usage, write it onto metadata, and append one
121
- * attributed ledger row. Mirrors start.js's finalize (the only sites that
122
- * dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
123
- * @returns {{usage: object|null}}
124
- */
125
- function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
126
- const { resolveUsage } = require('../utils/pricing');
127
- const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
128
- if (usage) {
129
- metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
130
- try {
131
- const { appendSpend } = require('../utils/spend-ledger');
132
- const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
133
- appendSpend({ taskId, model, mode, usage, op, status, project, gateway }, ctx);
134
- } catch { /* best-effort */ }
135
- }
136
- return { usage };
137
- }
138
-
139
123
  /**
140
124
  * Continue from a previous sidecar session - Spec Reference: §4.4, §8.5
141
125
  * @returns {Promise<number>} process exit code
@@ -196,6 +180,7 @@ async function continueSidecar(options) {
196
180
 
197
181
  const sessionDir = createContinueSessionMetadata(newTaskId, project, {
198
182
  model, briefing, headless, agent: effectiveAgent, gateway, resolutionVersion,
183
+ tag: oldMetadata.tag, // v4.7.1 Task 7: inherit the parent's tag (absent if the parent had none).
199
184
  }, oldTaskId);
200
185
 
201
186
  // Lock the NEW continuation session dir too — not just the previous one — so a
@@ -270,6 +255,7 @@ async function continueSidecar(options) {
270
255
  // ledger row (status: statusFromResult, matching start.js — not terminal.status).
271
256
  {
272
257
  const { statusFromResult } = require('../utils/result-schema');
258
+ const { finalizeSpendForReopen } = require('./reopen-spend');
273
259
  const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
274
260
  const { usage } = finalizeSpendForReopen({
275
261
  taskId: newTaskId, model, mode: headless ? 'headless' : 'interactive',
@@ -292,6 +278,5 @@ module.exports = {
292
278
  loadPreviousSession,
293
279
  buildContinuationContext,
294
280
  createContinueSessionMetadata,
295
- finalizeSpendForReopen,
296
281
  continueSidecar
297
282
  };
@@ -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.