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
@@ -17,6 +17,16 @@
17
17
  * stays intact inside the fence. It must NOT be applied to CLI --json stdout
18
18
  * (the byte-parseable programmatic channel) or amicus_read mode=metadata —
19
19
  * structured data a caller parses, where wrapping would break the contract.
20
+ *
21
+ * This module ALSO hosts the OUTBOUND family's tag neutralizer
22
+ * (`defangOutboundFenceTags`, PR #200 tails B2/C2; close tags only until PR
23
+ * #206 round 3 B3b took it to open tags too). It lives here rather than
24
+ * beside either outbound builder because there are two of them — the council
25
+ * briefing tail (src/council/briefings-stage2-task.js :: fenceBriefing) and the
26
+ * parent-conversation section (src/prompt-builder.js :: buildContextSection) —
27
+ * in two directories that do not import each other, and this is the one module
28
+ * in the tree whose subject is fences and which both can reach without either
29
+ * depending on the other.
20
30
  */
21
31
  'use strict';
22
32
 
@@ -36,4 +46,104 @@ ${body}
36
46
  </untrusted_sidecar_output>`;
37
47
  }
38
48
 
39
- module.exports = { fenceSidecarOutput };
49
+ /**
50
+ * Every OUTBOUND house fence's tag name. ONE list, deliberately: both surfaces
51
+ * run the neutralizer over their whole vocabulary, not just their own tag, so a
52
+ * fence added to the family is covered everywhere by editing this array — and a
53
+ * body that carries the SIBLING surface's tags (either end, since round 3)
54
+ * cannot smuggle them through the surface that does not happen to emit them.
55
+ * @see src/council/briefings-stage2-task.js :: BRIEFING_FENCE_CLOSE
56
+ * @see src/prompt-builder.js :: buildContextSection
57
+ */
58
+ const OUTBOUND_FENCE_TAGS = ['council_briefing', 'previous_conversation'];
59
+
60
+ /**
61
+ * `</council_briefing>` / `</previous_conversation>`, case-insensitively and
62
+ * tolerating whitespace ANYWHERE inside the angle brackets — including between
63
+ * the `<` and the `/` (round 3, C1: the round-2 pattern required those two to
64
+ * be adjacent, so `< /council_briefing>` rode through untouched, and a reading
65
+ * model honours that spelling exactly as readily as the tight one). The slash
66
+ * and its surrounding space are INSIDE the captured group, so the author's own
67
+ * spelling survives into the defanged form and nothing is hidden.
68
+ * ⚠️ Module-level and `g`-flagged: safe ONLY because its sole use is
69
+ * `String#replace`, which resets `lastIndex` around the call. A `.test()` on
70
+ * this object would carry `lastIndex` between calls and skip matches — build a
71
+ * fresh regex if one is ever needed. Same warning for the OPEN pattern below.
72
+ */
73
+ const OUTBOUND_FENCE_CLOSE_RE =
74
+ new RegExp(`<(\\s*\\/\\s*(?:${OUTBOUND_FENCE_TAGS.join('|')})\\s*)>`, 'gi');
75
+
76
+ /**
77
+ * The same families' OPEN tags, attributes and all (round 3, B3b).
78
+ *
79
+ * `\\b` after the name is what keeps this off tags that merely START with a
80
+ * house name — `<council_briefingx>` is somebody else's markup — and
81
+ * `[^<>]*` carries whatever attributes the author wrote through into the
82
+ * escaped form. It cannot match a CLOSE tag: `\\s*` does not consume the `/`.
83
+ */
84
+ const OUTBOUND_FENCE_OPEN_RE =
85
+ new RegExp(`<(\\s*(?:${OUTBOUND_FENCE_TAGS.join('|')})\\b[^<>]*)>`, 'gi');
86
+
87
+ /**
88
+ * Neutralize any outbound house fence tag inside a body about to be embedded in
89
+ * one (PR #200 tails B2/C2; round 3 B3b/C1 widened it from closes to both ends).
90
+ *
91
+ * Without this, untrusted text that contains the close tag ends the fence early
92
+ * in the reading model's eyes and everything after it reads as the engine
93
+ * speaking — the fence's entire purpose, undone by a string the author types.
94
+ * The replacement is an entity escape (`&lt;/council_briefing&gt;`), so the
95
+ * text stays legible as what the author wrote while no longer being a tag:
96
+ * nothing is deleted and nothing is silently swallowed.
97
+ *
98
+ * OPEN TAGS TOO, and round 2's reason for skipping them was wrong (B3b). That
99
+ * reason — "an open tag inside a fence cannot escape it" — holds for a STRICT
100
+ * parser and fails for the reader this fence is actually addressed to. A model
101
+ * that balances tags reads the attacker's `<council_briefing …>` and the
102
+ * engine's REAL `</council_briefing>` as one pair: the attacker's tag gets the
103
+ * close, and the engine's fence is left unterminated. Everything after the
104
+ * attacker's open reads as fenced material, and everything the ENGINE writes
105
+ * after the real close reads as still inside a fence. That is the same escape
106
+ * one tag along. MEASURED before widening (W12, re-verified round 3): nothing
107
+ * in the tree PARSES these tags — the only occurrences outside test assertions
108
+ * are the two producers' own literals — so escaping opens breaks no consumer.
109
+ *
110
+ * ⚠️ THE BOUNDARY IS SOFT, and this is a disclosure, not a caveat. An entity
111
+ * escape is a convention about how a READING MODEL should interpret bytes, not
112
+ * a parser guarantee: some models decode `&lt;/council_briefing&gt;` back to
113
+ * the tag while reading, and a model that does is not fenced by this. What this
114
+ * buys is defense in depth — it removes the LITERAL tag, so the escape stops
115
+ * being free and starts depending on a decoding step the attacker does not
116
+ * control. The load-bearing protection is still the preamble both fences carry
117
+ * (the enclosed text is reference material, not instructions); this hardens it,
118
+ * and does not replace it. Deleting the tag outright would be a stronger
119
+ * boundary and a worse product — the reader would silently lose text the author
120
+ * wrote, which is the failure the whole fence exists to avoid.
121
+ *
122
+ * Total over non-strings (returns its argument unchanged): callers hand it
123
+ * whatever they were given, and a missing body must never be a throw inside a
124
+ * prompt builder.
125
+ * @param {string} text the untrusted body about to be fenced
126
+ * @returns {string} the same bytes when it carries no house tag
127
+ */
128
+ function defangOutboundFenceTags(text) {
129
+ if (typeof text !== 'string') { return text; }
130
+ // Closes first: once a close is escaped its `<` is gone, so the OPEN pattern
131
+ // cannot see it. (It could not match one anyway — see the pattern's note —
132
+ // but the order makes that independent of the pattern staying that way.)
133
+ return text
134
+ .replace(OUTBOUND_FENCE_CLOSE_RE, '&lt;$1&gt;')
135
+ .replace(OUTBOUND_FENCE_OPEN_RE, '&lt;$1&gt;');
136
+ }
137
+
138
+ // FUNCTIONS FIRST, constant last — the PR 201 round-2 workaround for
139
+ // `scripts/generate-docs-helpers.js :: extractExports`, which renders EVERY
140
+ // export as `name()` and keeps only the first five. Ordering conceals a
141
+ // constant behind that cap; MEASURED here, it cannot, because this module
142
+ // exports three names and the cap never fires. So CLAUDE.md's Key Exports cell
143
+ // reads `OUTBOUND_FENCE_TAGS()` for what is an array — a known-defect instance
144
+ // of the generator bug, not a claim this module makes, and the second one below
145
+ // the workaround's floor after `utils/text-sanitize.js`. It goes away with the
146
+ // generator ruling filed in BACKLOG.md; padding the export list to five to hide
147
+ // it would not be a fix, and the list itself must stay exported —
148
+ // tests/utils/outbound-fence-defang.test.js reads it as the LIVE vocabulary.
149
+ module.exports = { fenceSidecarOutput, defangOutboundFenceTags, OUTBOUND_FENCE_TAGS };
@@ -5,10 +5,11 @@
5
5
  * (src/headless.js `formatFoldOutput`, exported from src/headless.js and
6
6
  * re-exported from src/index.js) — byte-for-byte the same **7-line** head:
7
7
  * marker, Model, Session, Client, CWD, Mode, `---`. ⚠️ v4.4.1 DOC-5: this said
8
- * "8-line" for two releases. Line 8 (`VERDICT:`) is council's OWN addition and
8
+ * "8-line" for two releases. Line 8 (the terminal line — `VERDICT:` on a review
9
+ * run, `ANSWER:` on a task one since v4.9 W8) is council's OWN addition and
9
10
  * has no counterpart in formatFoldOutput, whose 8th element is the summary
10
11
  * body. Only the first 7 lines are the shared contract; anyone changing the
11
- * shared format must sync those and leave `VERDICT:` alone.
12
+ * shared format must sync those and leave the terminal line alone.
12
13
  * src/headless.js is the SOURCE OF TRUTH; keep the head in sync with it.
13
14
  * (Line numbers deliberately omitted — the previous `:775-789`/`:797` citation
14
15
  * had drifted by ~500 lines.) The duplication is deliberate:
@@ -24,7 +25,7 @@
24
25
  * The chair body is UNTRUSTED model text: it passes through stripFoldMarkers
25
26
  * before embedding, so chair prose containing a marker can never truncate or
26
27
  * spoof the fold (the exact hazard the nonce closure exists for).
27
- * Degradation mirrors the engine's ladder: no chair → VERDICT: none + tally
28
+ * Degradation mirrors the engine's ladder: no chair → `<terminal>: none` + tally
28
29
  * summary; pre-tally → stage/status summary. Never blocked, always labeled.
29
30
  */
30
31
  'use strict';
@@ -63,14 +64,34 @@ function buildFoldText(o) {
63
64
  // amicus_verdict MCP path types overallVerdict as a bare z.string().nullable()
64
65
  // — mcp-tools.js :: amicus_verdict), so a multi-line or marker-bearing value
65
66
  // must never reach the head verbatim: an embedded '\n' would shift every line below
66
- // VERDICT: (a raw string containing '\n' becomes several elements once the
67
+ // the terminal line (a raw string containing '\n' becomes several elements once the
67
68
  // head array is '\n'-joined), and an embedded marker could spoof the fold.
68
- // Safe on the shipped engine path (parseChairVerdict returns a canonical
69
- // CHAIR_VERDICTS phrase) this is defense-in-depth, not a fix for a real
69
+ // Safe on the shipped engine path (`run-chair.js :: runChair` parses the chair's
70
+ // terminal line with `parse-stage2.js :: parseChairTerminal`, which returns a
71
+ // canonical phrase of the run's OWN scale — CHAIR_VERDICTS on a review run,
72
+ // CHAIR_ANSWERS on a task one) — this is defense-in-depth, not a fix for a real
70
73
  // producer.
74
+ // ⚠️ DE-ROT (v4.9 W8): this said "parseChairVerdict returns a canonical
75
+ // CHAIR_VERDICTS phrase", true only until W7 added the second scale — after
76
+ // which it named the wrong parser for every task run.
71
77
  const overall = verdict && verdict.overallVerdict
72
78
  ? stripFoldMarkers(String(verdict.overallVerdict)).replace(/[\r\n]+/g, ' ').trim()
73
79
  : null;
80
+ // v4.9 W8: the terminal line names the SCALE its phrase belongs to. The two
81
+ // scales are disjoint by pinned construction (tests/council/chair-scale-drift.test.js),
82
+ // so labelling a CHAIR_ANSWERS phrase `VERDICT:` asserts a scale it is not on.
83
+ // `intent` is emit-when-'task' (verdict.js :: buildVerdict, the W5 ruling), so
84
+ // absence — and an explicit 'review' — keep the review fold byte-identical.
85
+ // ⚠️ v4.9 fix round 2 (council B2): `run.intent` is the SECOND carrier, and it
86
+ // is what covers the DEGRADED fold — the one a user reaches for precisely when
87
+ // a task run went wrong. With no verdict.json (or a parse-failed one) there is
88
+ // no verdict.intent, and the fold headed `VERDICT: none` on a run that was
89
+ // never on that scale. `o.run` is the parsed run.json, which checkpoints
90
+ // `intent: 'task'` at start (src/council/run.js :: runCouncil):
91
+ // electron/ipc-workspace.js passes `run: detail.run` and run-detail.js ::
92
+ // getRunDetail reads it straight from `<runDir>/run.json`.
93
+ // PR #200 round-5 B3: parens on the whole disjunction — behaviour-identical.
94
+ const terminalLabel = ((verdict && verdict.intent === 'task') || run.intent === 'task') ? 'ANSWER' : 'VERDICT';
74
95
  const tierCounts = (verdict && verdict.tierCounts) || (tally && tally.tierCounts) || null;
75
96
  const cost = run.usage && run.usage.cost ? run.usage.cost : null;
76
97
 
@@ -82,7 +103,7 @@ function buildFoldText(o) {
82
103
  `CWD: ${o.project}`,
83
104
  'Mode: council',
84
105
  '---',
85
- `VERDICT: ${overall || 'none'}`,
106
+ `${terminalLabel}: ${overall || 'none'}`,
86
107
  ];
87
108
  if (tierCounts) {
88
109
  head.push(tierLine(tierCounts));
@@ -50,7 +50,8 @@ function seatOf(leg) {
50
50
  model: leg.model || null,
51
51
  modelInput: leg.modelInput || null,
52
52
  role: leg.role || null,
53
- // ⚠️ v4.8 R5: the leg's seat id, and the reason live-dead-seats.js:209's
53
+ // ⚠️ v4.8 R5: the leg's seat id, and the reason live-dead-seats.js ::
54
+ // deadSeats' `if (s.seat)` suppression arm's
54
55
  // `if (s.seat)` arm is reachable at all. Null on a unique-alias bench, so the
55
56
  // guard there stays load-bearing — see the comment at that line.
56
57
  seat: leg.seat || null,
@@ -15,7 +15,8 @@
15
15
  * unconditionally (src/council/tally.js :: tally) — it is never a real source for
16
16
  * either the override badge or the post-override tier. Only `buildVerdict`
17
17
  * materializes `{from,to,reason}` and rewrites `tier` to `tierOverride.to`
18
- * (src/council/verdict.js:122-126). So both fields are joined in from
18
+ * (src/council/verdict.js :: buildVerdict's `findings` map re-anchored BY SYMBOL at v4.9 W9,
19
+ * which shifted the old `:128-132`). So both fields are joined in from
19
20
  * verdict.findings[] by `id`; when verdict is absent/unparseable (caller
20
21
  * passes null/undefined, or a finding has no verdict-side counterpart) the
21
22
  * row falls back to tally's own (pre-override) tier and renders no badge.
@@ -183,7 +184,10 @@ function buildMatrixModel(tally, labelMap, verdict) {
183
184
  // ⚠️ DE-ROT (F29): v4.1 decorates tally.json findings in place with
184
185
  // `debate: {action, previousTier}` (src/council/debate.js :: decorateRecord; action ∈
185
186
  // defended|amended|withdrawn|no-response) and verdict.json carries it through
186
- // (src/council/verdict.js:43). Consumed by electron/workspace-ui/workspace-matrix.js's
187
+ // (src/council/verdict.js :: buildVerdict's `if (f.debate)` carry-through the old
188
+ // `:43` was ALREADY rotted onto summarizeSeatLoss, which has since left verdict.js
189
+ // altogether; re-anchored by symbol, not re-numbered). Consumed by
190
+ // electron/workspace-ui/workspace-matrix.js's
187
191
  // renderMatrix, which renders a `.debate-badge` in the tier cell (alongside the
188
192
  // thin/tierOverride badges) so a withdrawn/amended/defended/no-response finding never
189
193
  // renders as an ordinary live row. Absent on non-debate runs, hence `|| null`.
@@ -124,7 +124,7 @@ function costPanel(run, tally) {
124
124
 
125
125
  // ⚠️ PRE-FLIGHT (P3): F04's correction is implemented here rather than left as prose.
126
126
  // VERIFIED on shipped main (Task 0): `finalize(exitCode, error)` writes `error: error || null`
127
- // (run-finalize.js :: writeRunTerminal), and `return finalize(degraded.value ? 2 : 0)` (run.js:279) is the ONLY
127
+ // (run-finalize.js :: writeRunTerminal), and `return finalize(degraded.value ? 2 : 0)` (run.js:294) is the ONLY
128
128
  // exit-2 path — it passes NO error. Every error-bearing call is `finalize(1, …)`. So on a
129
129
  // `status:'partial'` run — precisely the run this panel exists to explain — `run.error` is
130
130
  // GUARANTEED null, and the old one-line formula rendered "undefined: undefined".
@@ -142,13 +142,32 @@ function degradedReason(run) {
142
142
  return null;
143
143
  }
144
144
 
145
+ // v4.9 W8 T-B: `intent` rides this payload so the renderer can label a task run's
146
+ // chip `ANSWER:` (electron/workspace-ui/workspace-matrix.js :: renderVerdict is a
147
+ // plain browser script and cannot require() src/, so a fork key it cannot read is
148
+ // a fork it cannot make). Unlike verdict.json — emit-when-'task' per the W5 ruling,
149
+ // because that artifact has a byte-identity contract — this is an in-memory IPC
150
+ // model whose literal is CLOSED and always materializes every key with a default,
151
+ // so `intent` is materialized both ways and defaults to 'review'. The renderer
152
+ // still treats an ABSENT key as review: pre-v4.9 payloads carry none.
153
+ // ⚠️ v4.9 fix round 2 (council B2): sourced from `run` as well as `verdict`.
154
+ // Reading `verdict.intent` ALONE meant the two panels that exist to explain a
155
+ // broken run could not label it: on the narrow leg where a task run exits before
156
+ // the verdict write — or leaves a truncated one — there is no verdict.intent to
157
+ // read, and the panel defaulted to review, so the Workspace chip said "no chair
158
+ // verdict" about a run that was never on that scale. `run.json` checkpoints
159
+ // `intent: 'task'` at start (run.js :: runCouncil) and `run` is already this
160
+ // function's first parameter, so the honest source costs nothing to reach.
145
161
  function verdictPanel(run, verdict) {
146
162
  const reason = degradedReason(run);
163
+ // PR #200 round-5 B3: parens on the whole disjunction — behaviour-identical.
164
+ const intent = ((verdict && verdict.intent === 'task') || (run && run.intent === 'task')) ? 'task' : 'review';
147
165
  if (!verdict || verdict.parseError) {
148
- return { present: false, overallVerdict: null, tierCounts: null, streetCred: [], decisions: [], reason };
166
+ return { present: false, overallVerdict: null, tierCounts: null, streetCred: [], decisions: [], reason, intent };
149
167
  }
150
168
  return {
151
169
  present: true,
170
+ intent,
152
171
  overallVerdict: verdict.overallVerdict === undefined ? null : verdict.overallVerdict,
153
172
  tierCounts: verdict.tierCounts || null,
154
173
  streetCred: Array.isArray(verdict.streetCred) ? verdict.streetCred : [],
@@ -233,11 +252,18 @@ function getRunDetail(project, runId) {
233
252
  // of a copy of the question makes that divergence unrepresentable.
234
253
  seatSpace: isSeatTable(run.seats),
235
254
  // ⚠️ Fix-wave 3 (council-3 C2): seats[] is PRESENT but unusable, so the run silently
236
- // lost per-seat behaviour — two seats on one model become indistinguishable in every
237
- // panel. isSeatTable fails WHOLE (one malformed entry drops the entire table), which
238
- // is the fail-safe direction but is exactly the correct-but-silent degrade the product
239
- // principle rejects. Emitted here, beside the predicate that decides it, rather than
240
- // re-derived renderer-side; workspace-app.js's renderBanners is its only consumer.
255
+ // lost per-seat ARTIFACT NAMING — two seats on one model become indistinguishable in
256
+ // the reviews/judges/re-vote panels, whose roster this flag sends back to `bench`
257
+ // (workspace-lazy.js :: roster). isSeatTable fails WHOLE (one malformed entry drops the
258
+ // entire table), which is the fail-safe direction but is exactly the correct-but-silent
259
+ // degrade the product principle rejects. Emitted here, beside the predicate that decides
260
+ // it, rather than re-derived renderer-side; workspace-banners.js is its only consumer.
261
+ // ⚠️ "in every panel" — what this comment said until v4.9 W9 — was already FALSE when
262
+ // written, and W9's PR5b-1 measured it: `costPanel` above maps `tally.runStats`, whose
263
+ // `seat` is stamped from the in-memory seat at assembly (run-stats-entry.js ::
264
+ // buildRunStatsEntry), so the seats panel keeps one row per seat no matter what
265
+ // run.seats looks like. Ruling V15 discloses that split in the banner rather than
266
+ // forcing the seats panel down to `seatSpace` for uniformity.
241
267
  // NOT the same as `!seatSpace`: a run with no seats[] at all is a legacy run, not a
242
268
  // broken one, and must not be bannered.
243
269
  seatTableRejected: Array.isArray(run.seats) && run.seats.length > 0 && !isSeatTable(run.seats),
@@ -75,18 +75,22 @@ function isSeatTable(seats) {
75
75
  * bound seat a `rebuttal-` an orphan may have written, re-arming RN-1 in the debate
76
76
  * namespace (MEASURED; the one regression this fix wave introduced). The question is:
77
77
  * does the note POSITIVELY PROVE the orphan did NOT write this kind? Exactly one such
78
- * proof exists. `orphanLegNote` has three call sites — run-stages.js:71 and :140 (Stage-1
79
- * and its retry) and run-stage2.js:110 (the -s2 judge wave) — and `data.waveId` separates
80
- * them EXACTLY, not heuristically: run-stage2.js:67/69/90 build it as `${runId}-s2` from
78
+ * proof exists. `orphanLegNote` has three call sites — two in run-stages.js :: runStage1
79
+ * (Stage-1's own bind and its retry pass; cited `:71 and :140` until the v4.9 W9 fix round
80
+ * moved a builder out of that function and both numbers rotted — anchored by SYMBOL now, per
81
+ * the anti-rot rule) and run-stage2.js :: bindStage2Seats (the -s2 judge wave) — and
82
+ * `data.waveId` separates them EXACTLY, not heuristically: run-stage2.js :: bindStage2Seats
83
+ * and the launch site in run-stage2.js :: runStage2 both build it as `${runId}-s2` from
81
84
  * the runId this run.json carries. A -s2 note says the leg BOUND in Stage 1, so its review
82
85
  * landed under a SEAT name: `review-<alias>.md` is provably not its. Nothing else is
83
86
  * provable, and the near misses are why:
84
87
  * - a Stage-1 note exonerates NOTHING, not even judge-: that orphan is re-admitted to
85
- * Stage 2 under a PLACEHOLDER seat (run-stage2.js:92-97) that `judgeSeatOf` filters out
86
- * (:105-107), so its judge leg takes the alias branch too — and emits no -s2 note,
88
+ * Stage 2 under a PLACEHOLDER seat that stage1-bind.js :: bindPaddedWave pads in and
89
+ * drops from `judgeSeatOf` (called from run-stage2.js :: bindStage2Seats since the
90
+ * v4.9 W2 split), so its judge leg takes the alias branch too — and emits no -s2 note,
87
91
  * because it BOUND. Measured: run-stages.test.js :: "M2: the placeholder never becomes…".
88
92
  * - rebuttal-/revote- are never exonerated: a debate leg whose raiser/judge key names no
89
- * seat takes materializeDebate's alias branch (run-debate.js :: runDebate;
93
+ * seat takes materializeDebate's alias branch (run-debate.js :: runDefenseWave;
90
94
  * run-debate-revote.js :: runRevoteWave, `seat = seatOf.get(leg) || null`). A rebuttal
91
95
  * leg's unbound raiser still has no note recording it. A revote leg's unbound judge
92
96
  * SOMETIMES gets one, since v4.8 T5.1 (channel `seat-unbound`, run-debate-revote.js's