amicus 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -4,12 +4,124 @@
4
4
  const SEVERITIES = ['blocker', 'major', 'minor', 'nit'];
5
5
  const REQUIRED = ['claim', 'location', 'rationale'];
6
6
 
7
- /** Extract the LAST ```json fenced block's body, or null. */
7
+ /** The start of a ```json block. Openers are enumerated INDEPENDENTLY — see below. */
8
+ const OPENER = /```json\s*\n/g;
9
+
10
+ /**
11
+ * The two readings of "where does this block close?", primary first.
12
+ *
13
+ * ANCHORED_CLOSE is the primary and the definition of a block: the closing fence
14
+ * starts a line (CommonMark's rule; leading horizontal whitespace allowed). It is
15
+ * what makes a body that CONTAINS a fence extractable at all.
16
+ *
17
+ * The same-line reading — the first triple-backtick anywhere ends the body — is the
18
+ * pre-Task-10 one. As a way to FIND blocks it is broken (it truncates any body that
19
+ * quotes a fence), but as a way to read a body a sloppy emit mis-delimited it is
20
+ * right, and JSON.parse rather than the regex decides which reading was.
21
+ */
22
+ const ANCHORED_CLOSE = /^[ \t]*```/m;
23
+ const FENCE = '```';
24
+
25
+ /**
26
+ * How one opener's body can be read, primary first.
27
+ * @param {string} rest the text immediately following an opener
28
+ * @returns {Array<{body: string, anchored: boolean}>} 0–2 readings
29
+ */
30
+ function bodyReadings(rest) {
31
+ const out = [];
32
+ const anchored = ANCHORED_CLOSE.exec(rest);
33
+ if (anchored) { out.push({ body: rest.slice(0, anchored.index), anchored: true }); }
34
+ const inline = rest.indexOf(FENCE);
35
+ if (inline !== -1) { out.push({ body: rest.slice(0, inline), anchored: false }); }
36
+ return out;
37
+ }
38
+
39
+ /**
40
+ * Extract the last ```json fenced block that is VALID JSON, or null.
41
+ *
42
+ * ⚠️ OWNER RULING (v4.4.1). "Last block" means the last candidate that PARSES, not
43
+ * the last one that is merely syntactically delimited. One rule closes two traps
44
+ * that no single regex can close at once — both of them measured, not hypothetical:
45
+ *
46
+ * TRAP 1 — a fence INSIDE the body (pre-Task-10). The pattern was
47
+ * `/```json\s*\n([\s\S]*?)```/g`: the first triple-backtick ANYWHERE in the JSON
48
+ * ended the match, so a findings block whose `claim` quotes a fence — which any
49
+ * review of markdown inevitably writes — was truncated mid-string and failed
50
+ * JSON.parse. On the paid md-lite council preserved in output/md-lite-council/,
51
+ * THREE of four seats hit it: `opus` cut at "```/) for both open and" (5 findings
52
+ * lost), `glm` at "```js) is silently disca" (6 lost), `minimax` at a
53
+ * repeated-backtick example. The chair synthesized from what survived, unaware.
54
+ *
55
+ * TRAP 2 — a sloppy block BEFORE a good one (introduced by Task 10's `c5f4a9e`,
56
+ * measured and pinned by Task 11). With the close anchored to line start, a
57
+ * same-line-fenced block's body runs on to the next line-start fence — which is the
58
+ * NEXT block's OPENING fence. A well-formed block behind a sloppy one became
59
+ * unreachable; the old unanchored regex found it.
60
+ *
61
+ * The algorithm, in three moves.
62
+ *
63
+ * 1. OPENERS ARE ENUMERATED INDEPENDENTLY, not by a single scan that resumes after
64
+ * each match. That alone is what killed trap 2: the old scan's cursor jumped past
65
+ * the good block's opener because the sloppy block's run-on body had swallowed
66
+ * it, so the good block was never even a candidate. Every ```json opener now gets
67
+ * considered on its own terms, whatever the block before it did.
68
+ * 2. EACH OPENER IS READ BOTH WAYS — anchored close (primary) and same-line close
69
+ * (fallback) — so a sloppy but parseable emit stays reachable.
70
+ * 3. THE LAST OPENER WHOSE BODY PARSES WINS, preferring its anchored reading. A
71
+ * truncated reading can never beat a whole one, because a truncated body does not
72
+ * parse; JSON.parse is the arbiter, not the regex.
73
+ *
74
+ * The safety of consulting the sloppy reading at all is structural, not lucky: a raw
75
+ * newline inside a JSON string is invalid JSON, so no line of a WELL-FORMED body can
76
+ * begin with a fence, and no well-formed body can contain a `\n`-terminated ```json
77
+ * opener. The readings therefore agree on every well-formed block and can only
78
+ * disagree about a malformed one.
79
+ *
80
+ * ⚠️ WIDENED BY A SECOND OWNER RULING (v4.4.1, same release). The same-line reading
81
+ * may also DISCOVER a block, not merely recover one. The first cut of this function
82
+ * gated it — "recover, never discover" — so an opener whose ONLY close shared a line
83
+ * with body content returned null even when its JSON was perfect, buying a paid
84
+ * repair leg over a cosmetic closer. The owner widened it: every reading of every
85
+ * opener is a candidate, and JSON.parse arbitrates, full stop. Simulated before the
86
+ * ruling and re-pinned below: a body that QUOTES a fence still parses (trap 1 stays
87
+ * closed — per-opener arbitration keeps the anchored reading winning, because it is
88
+ * the one that parses), and a lone same-line block now parses instead of repairing.
89
+ *
90
+ * Only an opener that never closed AT ALL — no fence of either kind after it, i.e. a
91
+ * cut-off emit — yields no candidate. When no opener yields a candidate, the answer
92
+ * is null: nothing was ever closed, and the text goes to the repair wave.
93
+ *
94
+ * When candidates exist but NOTHING parses, the last opener's preferred (anchored if
95
+ * it has one) body is returned rather than null — deliberately. A malformed emit must
96
+ * not look like an absent one: callers key off that distinction (validateFindings
97
+ * reports NOT_PARSEABLE with a real body instead of NO_FENCED_BLOCK;
98
+ * countAttemptedFindings returns null vs 0, which repairCanHonorContract then reads).
99
+ *
100
+ * Every Stage-1/Stage-2 extractor funnels through here — validateFindings,
101
+ * countAttemptedFindings, and parse-stage2's parseJudgeOutput / parseDebateDefense
102
+ * / parseRevote — so judge, debate-defense and re-vote parsing carried both defects
103
+ * and are fixed by this one change.
104
+ *
105
+ * @param {string} text full model output (prose + fenced block)
106
+ * @returns {string|null} the winning block body, or null when nothing closed
107
+ */
8
108
  function lastJsonBlock(text) {
9
- const re = /```json\s*\n([\s\S]*?)```/g;
10
- let m, last = null;
11
- while ((m = re.exec(text)) !== null) { last = m[1]; }
12
- return last;
109
+ const src = String(text ?? '');
110
+ const perOpener = [];
111
+ OPENER.lastIndex = 0;
112
+ let m;
113
+ while ((m = OPENER.exec(src)) !== null) {
114
+ const readings = bodyReadings(src.slice(m.index + m[0].length));
115
+ if (readings.length > 0) { perOpener.push(readings); } // an opener that never closed is no candidate
116
+ }
117
+ if (perOpener.length === 0) { return null; } // nothing ever closed
118
+ for (let i = perOpener.length - 1; i >= 0; i--) {
119
+ for (const reading of perOpener[i]) {
120
+ try { JSON.parse(reading.body); return reading.body; }
121
+ catch { /* not this reading — keep walking backwards */ }
122
+ }
123
+ }
124
+ return perOpener[perOpener.length - 1][0].body; // malformed ≠ absent
13
125
  }
14
126
 
15
127
  /**
@@ -27,9 +139,57 @@ function validateFindings(jsonText) {
27
139
  try { parsed = JSON.parse(body); }
28
140
  catch (e) { return { ok: false, findings: [], errors: [{ code: 'NOT_PARSEABLE', detail: e.message }] }; }
29
141
 
30
- const findings = Array.isArray(parsed.findings) ? parsed.findings : [];
31
- if (findings.length === 0) {
32
- errors.push({ code: 'EMPTY_FINDINGS', detail: 'findings is missing or empty' });
142
+ // ⚠️ v4.4.1 FINAL-REVIEW C a body that parses to nothing usable.
143
+ // `JSON.parse('null')` SUCCEEDS: it returns null and throws nothing, so the catch
144
+ // above never sees it and every `parsed.<key>` below threw
145
+ // `TypeError: Cannot read properties of null`. Because run-stages.js:164 calls this
146
+ // from inside run.js's try/catch, ONE seat emitting a `null` body aborted an entire
147
+ // PAID council as exit 1 rather than degrading that seat — the fail-closed shape
148
+ // this release exists to remove.
149
+ //
150
+ // Same guard parse-stage2.js:129 and :167 already carried; this was an asymmetry
151
+ // among five consumers of one extractor, not a new rule. `!parsed` (their idiom)
152
+ // rather than `parsed === null`, so the other contentless bodies — 0, false, "" —
153
+ // land here too: each carries no object at all, each was already ok:false via
154
+ // EMPTY_FINDINGS, and NOT_PARSEABLE is the truer story to hand the repair prompt.
155
+ // A truthy scalar (123, "text") never crashed and is left on its existing path.
156
+ //
157
+ // NOT_PARSEABLE, not NO_FENCED_BLOCK: the distinction is load-bearing. The model
158
+ // emitted something broken, not nothing — and countAttemptedFindings must keep
159
+ // answering null (unverifiable) rather than 0 (a declared empty set), because
160
+ // repairCanHonorContract reads exactly that difference. It does: its own JSON.parse
161
+ // succeeds on `null`, and `Array.isArray(null.findings)` throws into its catch.
162
+ if (!parsed) {
163
+ return { ok: false, findings: [], errors: [{ code: 'NOT_PARSEABLE',
164
+ detail: `block body is ${JSON.stringify(parsed)}, not an object` }] };
165
+ }
166
+
167
+ // ⚠️ LC-10 (owner ruling, 2026-07-26). A review that read the material and found
168
+ // nothing is a VALID review — the anti-sycophancy clause shipped in every Stage-1
169
+ // briefing says so verbatim ("An empty severity category is a valid result"), and
170
+ // rejecting it structurally pressured models into inventing findings to satisfy
171
+ // the schema. costgate01's grok did exactly that, and the fabrication reached
172
+ // tally.json, the street-cred rankings, the chair synthesis and a human decision.
173
+ //
174
+ // The distinction that makes this safe: a BROKEN emit already has its own codes
175
+ // (NO_FENCED_BLOCK, NOT_PARSEABLE) and returns above this line. What reaches here
176
+ // is a cleanly-parsed object. A non-empty `overall` is what separates a deliberate
177
+ // "nothing found" from a model that emitted a hollow shell — the empty-overall case
178
+ // stays an error.
179
+ //
180
+ // The ruling blesses `findings: []` — an array that is PRESENT and empty. A missing
181
+ // or non-array `findings` key is not a declaration of zero and stays an error, which
182
+ // is the same line countAttemptedFindings already draws: an explicit `[]` counts as
183
+ // zero, an absent array returns null (unverifiable). Widening this to "no findings
184
+ // key at all is fine" would let a bare {"overall":"looks good"} — the exact hollow
185
+ // shell the `overall` guard exists to catch — pass as a clean review.
186
+ const declared = Array.isArray(parsed.findings);
187
+ const findings = declared ? parsed.findings : [];
188
+ const overall = typeof parsed.overall === 'string' ? parsed.overall.trim() : '';
189
+ if (!declared) {
190
+ errors.push({ code: 'EMPTY_FINDINGS', detail: 'findings is missing or not an array' });
191
+ } else if (findings.length === 0 && overall === '') {
192
+ errors.push({ code: 'EMPTY_FINDINGS', detail: 'findings is empty and overall is missing or blank' });
33
193
  }
34
194
  const seen = new Set();
35
195
  findings.forEach((f, i) => {
@@ -45,6 +205,70 @@ function validateFindings(jsonText) {
45
205
  return { ok: errors.length === 0, findings: errors.length === 0 ? findings : [], errors };
46
206
  }
47
207
 
208
+ /**
209
+ * How many findings a review's trailing block ATTEMPTED to declare, regardless of
210
+ * whether they validate.
211
+ *
212
+ * ⚠️ LC-11: the repair prompt's contract is "the same findings, fixed — do not add
213
+ * or remove findings". Cardinality is the checkable half of that contract, and it
214
+ * is the half that matters: a repair which changes the count has produced findings
215
+ * the ORIGINAL PROSE never narrates, and that prose is what the judges read in
216
+ * bundle-stage2.md.
217
+ *
218
+ * Deliberately NOT validateFindings: an invalid block (bad severity, missing
219
+ * field) still declares a cardinality, and that is exactly the case the repair
220
+ * wave exists for.
221
+ *
222
+ * @param {string} text full review text (prose + fenced block)
223
+ * @returns {number|null} null when there is no block or it does not parse — in
224
+ * which case there is nothing to compare and the caller must mark the result
225
+ * unverified rather than implying a check happened.
226
+ */
227
+ function countAttemptedFindings(text) {
228
+ const body = lastJsonBlock(text || '');
229
+ if (body === null) { return null; }
230
+ try {
231
+ const parsed = JSON.parse(body);
232
+ return Array.isArray(parsed.findings) ? parsed.findings.length : null;
233
+ } catch { return null; }
234
+ }
235
+
236
+ /**
237
+ * A canonical repair that HONORS the contract for a review which declared zero
238
+ * findings: the same (empty) set, with a real `overall`. Probe only — it is never
239
+ * sent to a model.
240
+ */
241
+ const EMPTY_SET_REPAIR_PROBE =
242
+ '```json\n{"overall":"I read the material and found nothing to report.","findings":[]}\n```';
243
+
244
+ /**
245
+ * Can a repair that honors the count contract pass validation at all, given the
246
+ * count the ORIGINAL declared?
247
+ *
248
+ * ⚠️ v4.4.1 review F2. The repair prompt's contract is "the same findings, fixed —
249
+ * do not add or remove findings", and run-stages.js refuses a repair that changed
250
+ * the count. When the original declared ZERO findings, the only contract-honoring
251
+ * repair is another empty set — so if the validator rejects an empty set, every
252
+ * outcome of that repair wave is predetermined: a compliant repair fails
253
+ * validation, a non-compliant one is refused on the count. Up to two PAID solo
254
+ * legs whose only reachable end state is 'unstructured'. Don't buy it.
255
+ *
256
+ * The answer is ASKED of the validator instead of hard-coded so the two rules can
257
+ * never drift. Task 3 (LC-10) makes a well-formed empty set valid; the day it
258
+ * lands this predicate starts returning true on its own, and the malformed empty
259
+ * original (blank or missing `overall`) enters the repair loop again — where a
260
+ * repair can now succeed by re-emitting zero findings with a real `overall`.
261
+ *
262
+ * @param {number|null} attemptedCount countAttemptedFindings(originalText)
263
+ * @returns {boolean} false ⇒ skip the repair loop; the spend cannot buy an outcome.
264
+ * null (nothing to compare) is always repairable — that is the wave's main
265
+ * legitimate use.
266
+ */
267
+ function repairCanHonorContract(attemptedCount) {
268
+ if (attemptedCount !== 0) { return true; }
269
+ return validateFindings(EMPTY_SET_REPAIR_PROBE).ok;
270
+ }
271
+
48
272
  /**
49
273
  * v4.0 §7: stamp the council v2 envelope onto a validateFindings result
50
274
  * (additive — ok/findings/errors stay top-level; existing key-readers keep
@@ -57,4 +281,7 @@ function buildValidateDoc(result) {
57
281
  return { schemaVersion: COUNCIL_SCHEMA_VERSION, type: 'council-validate', ...result };
58
282
  }
59
283
 
60
- module.exports = { validateFindings, buildValidateDoc, SEVERITIES, lastJsonBlock };
284
+ module.exports = {
285
+ validateFindings, buildValidateDoc, SEVERITIES, lastJsonBlock, countAttemptedFindings,
286
+ repairCanHonorContract,
287
+ };
@@ -35,7 +35,15 @@ function parseJudgeOutput(text, { labels, findingIds }) {
35
35
  const errors = [];
36
36
  const known = new Set(labels);
37
37
  const flat = [];
38
- if (!Array.isArray(parsed.ranking) || parsed.ranking.length === 0) {
38
+ // ⚠️ v4.4.1 FINAL-REVIEW C. `JSON.parse('null')` SUCCEEDS it returns null and
39
+ // throws nothing — so a body of literal `null` sailed past the catch above and
40
+ // `parsed.ranking` threw `TypeError: Cannot read properties of null`. parseDebateDefense
41
+ // (:129) and parseRevote (:167) below already carried this `!parsed` guard; the judge
42
+ // path and findings.js's validateFindings did not, which made it an asymmetry among
43
+ // five consumers of one extractor rather than a new rule. Guarded on BOTH derefs so
44
+ // a `null` body reports exactly what a keyless `{}` body already reported —
45
+ // BAD_RANKING + BAD_ADJUDICATIONS — and no new error code enters a repair prompt.
46
+ if (!parsed || !Array.isArray(parsed.ranking) || parsed.ranking.length === 0) {
39
47
  errors.push({ code: 'BAD_RANKING', detail: 'ranking must be a non-empty array of review labels' });
40
48
  } else {
41
49
  for (const slot of parsed.ranking) {
@@ -52,7 +60,7 @@ function parseJudgeOutput(text, { labels, findingIds }) {
52
60
  }
53
61
 
54
62
  const knownIds = new Set(findingIds);
55
- if (!Array.isArray(parsed.adjudications)) {
63
+ if (!parsed || !Array.isArray(parsed.adjudications)) { // see the `!parsed` note above
56
64
  errors.push({ code: 'BAD_ADJUDICATIONS', detail: 'adjudications must be an array' });
57
65
  } else {
58
66
  for (const a of parsed.adjudications) {
@@ -114,15 +114,26 @@ function renderMd(m) {
114
114
  for (const s of m.streetCred) { out.push(`| ${s.model} | ${fmtNum(s.peersOnly)} | ${fmtNum(s.withSelf)} |`); }
115
115
 
116
116
  out.push('\n## Findings by tier\n');
117
- for (const t of TIER_ORDER) {
118
- const group = m.findings.filter(f => f.tier === t);
119
- if (!group.length) { continue; }
120
- out.push(`### ${t}`);
121
- for (const f of group) {
122
- const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
123
- out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
117
+ // LC-10 fast-follow (review minor M3): m.findings can legitimately be EMPTY
118
+ // (every seat honestly reported nothing) TIER_ORDER's four groups are then
119
+ // all empty too, and the loop below emits nothing, leaving this heading with
120
+ // no content beneath it before '## Cost'. Same heading-over-nothing class
121
+ // Task 3 closed in the Stage-2 prompts (buildJudgeBundle/buildChairPacket),
122
+ // human-facing here rather than model-facing. State the clean bench instead
123
+ // of leaving the heading to dangle.
124
+ if (!m.findings.length) {
125
+ out.push('_No findings were raised on this bench — a clean review is a valid review._\n');
126
+ } else {
127
+ for (const t of TIER_ORDER) {
128
+ const group = m.findings.filter(f => f.tier === t);
129
+ if (!group.length) { continue; }
130
+ out.push(`### ${t}`);
131
+ for (const f of group) {
132
+ const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
133
+ out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) — a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
134
+ }
135
+ out.push('');
124
136
  }
125
- out.push('');
126
137
  }
127
138
 
128
139
  // Defensive: never emit the heading unless at least one grouping has
@@ -40,13 +40,26 @@ function worseConformance(a, b) {
40
40
  * leg doc yields durationMs/usage null (never invent a value). `model` (the
41
41
  * council alias) overrides leg.model (the resolved executable id) so ledger
42
42
  * rows join meta.models by exact string (ledger.js:20-24).
43
+ *
44
+ * ⚠️ LC-11 / review F1: `findingsUnverified` and `repairRefused` are the same
45
+ * class of fact as `conformance` and ride the same row. They are the two halves
46
+ * of the repair contract's outcome: `findingsUnverified` marks a 'repaired' seat
47
+ * whose contract could NOT be checked (the original block was absent or
48
+ * unparseable, so there was no finding count to compare), and `repairRefused`
49
+ * ({code, detail}) marks the stronger case — the contract WAS checked and broken,
50
+ * which is otherwise indistinguishable from a seat that never emitted JSON at
51
+ * all. Both are additive and present only when set, so a run without either is
52
+ * byte-for-byte unchanged.
43
53
  */
44
- function buildRunStatsEntry({ leg, model, role, wasChair, conformance }) {
54
+ function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsUnverified,
55
+ repairRefused }) {
45
56
  return {
46
57
  model: model !== undefined ? model : (leg ? leg.model : null),
47
58
  role,
48
59
  wasChair: !!wasChair,
49
60
  conformance: conformance || 'clean',
61
+ ...(findingsUnverified ? { findingsUnverified: true } : {}),
62
+ ...(repairRefused ? { repairRefused } : {}),
50
63
  status: leg ? leg.status : 'error',
51
64
  durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
52
65
  usage: (leg && leg.usage) || null,
@@ -139,6 +152,7 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
139
152
  const rankings = okJudges.map(j => ({ judge: j.judge, order: j.order }));
140
153
  const runStats = reviews.map(r => buildRunStatsEntry({
141
154
  leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
155
+ findingsUnverified: r.findingsUnverified, repairRefused: r.repairRefused,
142
156
  }));
143
157
  if (claudeReview) {
144
158
  meta.models.push(CLAUDE_SEAT); // last, mirroring its review-N+1 label
@@ -176,7 +190,34 @@ function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
176
190
  return verdict;
177
191
  }
178
192
 
193
+ /**
194
+ * Build the chair packet and persist it as `chair-packet.md`. Lifted verbatim
195
+ * out of run.js for the 300-line gate (v4.4.1 Task 0.5) — same composition,
196
+ * same debate addendum, same file write.
197
+ * @param {{runDir: string, reviews: Array, claudeReview: object|null,
198
+ * tallyInput: object, record: object, debateOutcomes: Array|null, date: string}} args
199
+ * `tallyInput`/`record` are the DEBATED ones when --debate ran, the
200
+ * provisional pair otherwise (run.js keeps that sequencing).
201
+ * @returns {string} the packet text (run.js hands it straight to runChair)
202
+ */
203
+ function buildChairPacketFile({ runDir, reviews, claudeReview, tallyInput, record, debateOutcomes, date }) {
204
+ const { buildChairPacket } = require('./briefings-stage2');
205
+ const { buildDebateAddendum } = require('./briefings-debate');
206
+ const packet = buildChairPacket({
207
+ // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
208
+ // no rankings/adjudications, so it appears ONLY as one more review block.
209
+ reviews: reviews.map(r => ({ model: r.model, text: r.text }))
210
+ .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
211
+ rankings: tallyInput.rankings,
212
+ adjudications: tallyInput.adjudications,
213
+ tierCounts: record.tierCounts, date,
214
+ }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
215
+ fs.writeFileSync(path.join(runDir, 'chair-packet.md'), packet, { mode: 0o600 });
216
+ return packet;
217
+ }
218
+
179
219
  module.exports = {
180
220
  buildRunStatsEntry, worseConformance, buildTallyInput, writeTallyFiles, writeVerdictFiles,
221
+ buildChairPacketFile,
181
222
  preflightClaudeReview, labelClaudeReview, claudeRunStatsRow, CLAUDE_SEAT,
182
223
  };