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
@@ -29,8 +29,21 @@ function resolveWatchTarget(id, project) {
29
29
  const clean = String(id).replace(/^council-/, '');
30
30
 
31
31
  const { readPointer } = require('./council/run-state');
32
+ const { containsOnDisk } = require('./utils/path-fence');
32
33
  const ptr = readPointer(project, clean);
33
- if (ptr) { return { kind: 'council', id: clean, runDir: ptr.runDir }; }
34
+ // readPointer validates `council-<id>.json`'s {runId, runDir} only for
35
+ // truthiness (run-state.js:133-139), so a tampered or stale pointer can point
36
+ // runDir anywhere on disk — and this resolver's runDir is what
37
+ // observe/watch-render.js opens events.jsonl from. Same realpath-containment
38
+ // fence the v4.4 workspace reads use (src/utils/path-fence.js); a real
39
+ // runDir is always nested inside project (src/mcp-council-run.js:109 enforces
40
+ // it at creation time), so nothing legitimate is refused. An escaping pointer
41
+ // falls through to the session lookup and then 'unknown' — the same outcome a
42
+ // missing pointer already produces, so handleWatch's BAD_SESSION contract is
43
+ // unchanged.
44
+ if (ptr && containsOnDisk(project, ptr.runDir)) {
45
+ return { kind: 'council', id: clean, runDir: ptr.runDir };
46
+ }
34
47
 
35
48
  // getSessionDir THROWS on a path-traversal id ('..' / separators) — this
36
49
  // resolver is exported and docblocked "pure over disk", so it must be
@@ -50,11 +63,44 @@ function resolveWatchTarget(id, project) {
50
63
  }
51
64
 
52
65
  /**
53
- * `amicus watch <id> [--json|--plain] [--interval <sec>] [--project <p>] [--ui]`
66
+ * `amicus watch [--ui] <id> [--json|--plain] [--interval <sec>] [--project <p>]`
54
67
  * @param {object} args parsed CLI args
55
68
  * @returns {Promise<number>} exit code (render loop: Task 12)
56
69
  */
57
70
  async function handleWatch(args) {
71
+ // v4.4: `amicus watch [--ui] [runId]` — GUI watch is the same verb (spec
72
+ // §4.4), not a separate command. Handled at the very top, above id
73
+ // resolution: unlike the machine-readable path, `--ui` alone (no runId)
74
+ // is valid and opens the Council Workspace run-list landing, so this
75
+ // branch must run BEFORE the "id is required" gate below.
76
+ if (args.ui) {
77
+ if (args.json) {
78
+ process.stderr.write('Error: --ui is interactive-only (no --json). Use amicus watch <id> --json for machine output.\n');
79
+ return 1;
80
+ }
81
+ // --project wins over --cwd (same precedence as the non-UI path below) —
82
+ // DE-ROT F46: `--project` is the documented first-priority option for
83
+ // `watch`; resolving from --cwd only would silently point the workspace
84
+ // at the wrong directory when a run was launched elsewhere.
85
+ const project = args.project || args.cwd || process.cwd();
86
+ const runId = args._[1] ? String(args._[1]) : '';
87
+ // ⚠️ v4.4.1 DOC-3: validate HERE, not by moving the branch. The branch's
88
+ // POSITION above the `id is required` gate is load-bearing (bare `--ui`
89
+ // opens the run-list landing and must keep working), so the runId check is
90
+ // pushed inside it and made conditional on a runId actually being supplied.
91
+ // Without this, `amicus watch <typo> --ui` skipped validateTaskId entirely
92
+ // and surfaced whatever getRunDetail produced — a vaguer error than the
93
+ // identical typo gets on the terminal path.
94
+ if (runId) {
95
+ const uiCheck = validateTaskId(runId);
96
+ if (!uiCheck.valid) { process.stderr.write(`${uiCheck.error}\n`); return 1; }
97
+ }
98
+ const { launchWorkspaceWindow } = require('./sidecar/workspace-window');
99
+ const res = await launchWorkspaceWindow({ project, runId });
100
+ if (res.error) { process.stderr.write(`${res.error}\n`); }
101
+ return res.code;
102
+ }
103
+
58
104
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
59
105
  const id = args._[1];
60
106
  if (!id || id === true) {
@@ -64,14 +110,6 @@ async function handleWatch(args) {
64
110
  const check = validateTaskId(String(id));
65
111
  if (!check.valid) { process.stderr.write(`${check.error}\n`); return 1; }
66
112
 
67
- // --ui is registered as the v4.4 Council Workspace seam only; this rev
68
- // (v4.3) registers the flag + this fail-fast, the GUI itself is out of
69
- // scope. --ui alone is accepted and falls through to the loop.
70
- if (args.ui && args.json) {
71
- process.stderr.write('Error: --ui is interactive-only and cannot be combined with --json\n');
72
- return 1;
73
- }
74
-
75
113
  const project = args.project || args.cwd || process.cwd();
76
114
  const target = resolveWatchTarget(String(id), project);
77
115
  if (target.kind === 'unknown') {
package/src/cli.js CHANGED
@@ -598,12 +598,14 @@ Options for 'spend':
598
598
  `,
599
599
  watch: `
600
600
  Options for 'watch':
601
- <id> A fan-out wave id, council run id, or session id
601
+ <id> A fan-out wave id, council run id, or session id.
602
+ Optional with --ui: bare 'amicus watch --ui' opens the
603
+ project's Council Workspace run list
602
604
  --project <path> Project the run was launched in (default cwd)
603
605
  --interval <sec> Refresh interval (default 2, floor 0.5)
604
606
  --plain Milestone log lines instead of the refresh table
605
607
  --json NDJSON: tailed events + composed doc on change + final doc
606
- --ui Open the Council Workspace window (v4.4; interactive-only)
608
+ --ui Open the Council Workspace window (interactive-only)
607
609
  `,
608
610
  setup: `
609
611
  Options for 'setup':
@@ -110,20 +110,40 @@ function buildRevoteBundle({ findings, date }) {
110
110
  return parts.join('\n\n');
111
111
  }
112
112
 
113
- function repair(kind, contract, errors) {
113
+ /**
114
+ * ⚠️ LC-12: same omission as LC-6 one stage earlier — a repair solo is a FRESH
115
+ * session and cannot see the response it is repairing unless we hand it over.
116
+ * `prior` is embedded verbatim and uncapped; the absent case is stated rather
117
+ * than papered over with an empty block, so a model with nothing to correct is
118
+ * told to say so instead of left to invent a position.
119
+ * @param {string} kind 'defense' | 're-vote'
120
+ * @param {string} contract the trailing-JSON contract for that kind
121
+ * @param {Array<{code:string,detail:string}>} errors
122
+ * @param {string} [prior] the text that ACTUALLY failed validation
123
+ */
124
+ function repair(kind, contract, errors, prior) {
114
125
  const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
126
+ const text = typeof prior === 'string' ? prior.trim() : '';
127
+ const block = text
128
+ ? [`--- YOUR PREVIOUS ${kind.toUpperCase()} (verbatim — this is the text to correct) ---`,
129
+ text,
130
+ `--- END OF YOUR PREVIOUS ${kind.toUpperCase()} ---`].join('\n')
131
+ : `Your previous ${kind} response was empty — there is no prior text to correct. `
132
+ + 'Do not invent a position to satisfy the schema: say so in your output.';
115
133
  return [
116
- 'Do NOT use any tools or read any files; everything is in this message; begin ' +
117
- 'immediately with the JSON block.',
118
- `Your previous ${kind} response's trailing JSON failed validation with these errors:`,
134
+ 'Do NOT use any tools or read any files; everything is in this message; begin '
135
+ + 'immediately with the JSON block.',
136
+ block,
137
+ `That ${kind} response's trailing JSON failed validation with these errors:`,
119
138
  lines,
120
- 'Re-emit ONLY the corrected JSON block as a single fenced ```json block:',
139
+ 'Re-emit ONLY the corrected JSON block (the same position, fixed do not change '
140
+ + 'your votes), as a single fenced ```json block:',
121
141
  contract,
122
142
  ].join('\n\n');
123
143
  }
124
144
 
125
- function buildDefenseRepairPrompt({ errors }) { return repair('defense', DEFENSE_CONTRACT, errors); }
126
- function buildRevoteRepairPrompt({ errors }) { return repair('re-vote', REVOTE_CONTRACT, errors); }
145
+ function buildDefenseRepairPrompt({ errors, defense }) { return repair('defense', DEFENSE_CONTRACT, errors, defense); }
146
+ function buildRevoteRepairPrompt({ errors, revote }) { return repair('re-vote', REVOTE_CONTRACT, errors, revote); }
127
147
 
128
148
  /**
129
149
  * Chair-packet "Debate round outcomes" section (spec §5.3c). De-anonymized —
@@ -22,7 +22,21 @@ const CHAIR_VERDICT_VALUES = ['Ship it', 'Fix these first', 'Fundamental rethink
22
22
  /** Shared date line (spec §4.3) — prepended to every model-facing briefing. */
23
23
  function dateLine(date) { return `Today's date is ${date}.`; }
24
24
 
25
- /** Stage-2 headless output contract (spec §5, embedded in the judge bundle). */
25
+ /**
26
+ * Stage-2 headless output contract (spec §5, embedded in the judge bundle).
27
+ *
28
+ * ⚠️ LC-10 fast-follow (review minor M1): on a clean bench, JUDGE_TASK_B_NO_FINDINGS
29
+ * tells the judge to emit `"adjudications": []` — but this contract immediately
30
+ * follows it, and its worked example still shows `{"id":"A1"...},{"id":"B2"...}`.
31
+ * "One entry per listed finding id" is satisfied by zero entries, so it is not a
32
+ * contradiction, but the example is the last remaining incentive to invent an id:
33
+ * inventing one fails `UNKNOWN_FINDING_ID` (parse-stage2.js) and buys up to two
34
+ * paid repair solos per judge. The trailing bullet below states the zero case
35
+ * explicitly so the empty findings index has its own answer, not just an example
36
+ * that happens to allow it. It is unconditional (shown on every bench, including
37
+ * the ordinary and repair paths) because it is inert when findings exist and the
38
+ * repair prompt has no findings-state argument to swap on.
39
+ */
26
40
  const JUDGE_OUTPUT_CONTRACT = [
27
41
  'End your response with a trailing fenced ```json block — no text after it — in',
28
42
  'exactly this shape:',
@@ -41,15 +55,49 @@ const JUDGE_OUTPUT_CONTRACT = [
41
55
  ' Ties: use a nested array for tied labels, e.g. [["Review A", "Review B"], "Review C"].',
42
56
  '- "adjudications": one entry per listed finding id; "verdict" is one of:',
43
57
  ' agree | dispute | neutral. An "I missed this — it\'s valid" counts as agree.',
58
+ '- If the FINDINGS INDEX below is empty, "adjudications" must be exactly `[]` — do',
59
+ ' not invent a finding id merely to have something to adjudicate.',
44
60
  ].join('\n');
45
61
 
62
+ /** Task B when there is something to adjudicate (the ordinary case). */
63
+ const JUDGE_TASK_B =
64
+ 'Task B — Adjudicate: for EVERY finding id listed below, state agree, dispute, or ' +
65
+ 'neutral with your reasoning in prose.';
66
+
67
+ /**
68
+ * Task B when the whole bench came back clean (LC-10).
69
+ *
70
+ * ⚠️ Once a well-formed empty findings set is a VALID review, a run in which every
71
+ * seat found nothing reaches Stage 2 with an EMPTY findings index — and the ordinary
72
+ * Task B then orders the judge to adjudicate "EVERY finding id listed below" when
73
+ * none are listed, under a bare heading with nothing beneath it. That is not merely
74
+ * untidy: `parse-stage2` validates every adjudication id against the run-global set,
75
+ * so a judge that answers the dangling instruction by inventing an id fails with
76
+ * UNKNOWN_FINDING_ID and buys up to two PAID repair solos per judge. Stating the
77
+ * empty case and naming the exact output (`"adjudications": []`) is what keeps the
78
+ * clean bench cheap.
79
+ */
80
+ const JUDGE_TASK_B_NO_FINDINGS =
81
+ 'Task B — Adjudicate: there is nothing to adjudicate on this bundle. No review ' +
82
+ 'raised a finding, so the findings index below is empty. Emit "adjudications": [] ' +
83
+ 'and do not invent finding ids to fill it — a review that read the material and ' +
84
+ 'found nothing is a valid review. Task A still applies in full.';
85
+
86
+ /** The findings index body when the bench raised nothing — never a bare heading. */
87
+ const NO_FINDINGS_INDEX = '(none — no review in this bundle raised a finding)';
88
+
46
89
  /**
47
90
  * The single shared anonymized judge bundle.
48
91
  * @param {{reviews: Array<{label: string, text: string}>,
49
92
  * findings: Array<{id: string, severity: string, claim: string}>}} args
93
+ * `findings` may legitimately be EMPTY (LC-10): every seat reported a clean read.
94
+ * The bundle then states so explicitly instead of emitting a heading over nothing.
50
95
  */
51
96
  function buildJudgeBundle({ reviews, findings, date }) {
52
- const findingLines = findings.map(f => `${f.id} [${f.severity}] ${f.claim}`).join('\n');
97
+ const raised = Array.isArray(findings) ? findings : [];
98
+ const findingLines = raised.length
99
+ ? raised.map(f => `${f.id} [${f.severity}] ${f.claim}`).join('\n')
100
+ : NO_FINDINGS_INDEX;
53
101
  const reviewBlocks = reviews
54
102
  .map(r => `--- ${r.label} ---\n${r.text}`)
55
103
  .join('\n\n');
@@ -58,8 +106,7 @@ function buildJudgeBundle({ reviews, findings, date }) {
58
106
  parts.push(
59
107
  'You are judging the anonymized peer reviews below. Do two things:',
60
108
  'Task A — Rank: order the reviews from most to least accurate and insightful.',
61
- 'Task B Adjudicate: for EVERY finding id listed below, state agree, dispute, or ' +
62
- 'neutral with your reasoning in prose.',
109
+ raised.length ? JUDGE_TASK_B : JUDGE_TASK_B_NO_FINDINGS,
63
110
  JUDGE_OUTPUT_CONTRACT,
64
111
  '--- FINDINGS INDEX (run-global ids) ---',
65
112
  findingLines,
@@ -68,15 +115,38 @@ function buildJudgeBundle({ reviews, findings, date }) {
68
115
  return parts.join('\n\n');
69
116
  }
70
117
 
71
- /** Bounded judge-repair re-prompt (solo; ≤ 2 per judge — spec §5). */
72
- function buildJudgeRepairPrompt({ errors }) {
118
+ /**
119
+ * Bounded judge-repair re-prompt (solo; 2 per judge — spec §5).
120
+ *
121
+ * ⚠️ LC-12: a repair solo is a FRESH session with no memory of the judging turn.
122
+ * Shipping only `errors` asked the model to correct something it had never seen —
123
+ * the identical defect LC-6 fixed one stage earlier, where it cost three of five
124
+ * paid councils a seat. Stage 2 is worse: a refused judge has no `conformance`
125
+ * column, so the tally silently shows fewer votes and basis counts can flip a tier.
126
+ *
127
+ * `judgement` is embedded verbatim and uncapped — a silent truncation would
128
+ * recreate the defect in a subtler form (repairing a judgement the model can only
129
+ * half see) — and the absent case is STATED rather than papered over with an
130
+ * empty block.
131
+ * @param {{errors?: Array<{code:string,detail:string}>, judgement?: string}} args
132
+ */
133
+ function buildJudgeRepairPrompt({ errors, judgement }) {
73
134
  const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
135
+ const text = typeof judgement === 'string' ? judgement.trim() : '';
136
+ const prior = text
137
+ ? ['--- YOUR PREVIOUS JUDGEMENT (verbatim — this is the text to correct) ---',
138
+ text,
139
+ '--- END OF YOUR PREVIOUS JUDGEMENT ---'].join('\n')
140
+ : 'Your previous response was empty — there is no prior judgement to correct. '
141
+ + 'Do not invent rankings or adjudications to satisfy the schema: say so in your output.';
74
142
  return [
75
- 'Do NOT use any tools or read any files; everything is in this message; begin ' +
76
- 'immediately with the JSON block.',
77
- 'Your previous judging response\'s trailing JSON failed validation with these errors:',
143
+ 'Do NOT use any tools or read any files; everything is in this message; begin '
144
+ + 'immediately with the JSON block.',
145
+ prior,
146
+ 'That judging response\'s trailing JSON failed validation with these errors:',
78
147
  lines,
79
- 'Re-emit ONLY the corrected JSON block as a single fenced ```json block:',
148
+ 'Re-emit ONLY the corrected JSON block (the same rankings and adjudications, fixed — '
149
+ + 'do not change your votes), as a single fenced ```json block:',
80
150
  JUDGE_OUTPUT_CONTRACT,
81
151
  ].join('\n\n');
82
152
  }
@@ -101,49 +171,107 @@ const VERDICT_SCALE_ADDENDUM = [
101
171
  ' VERDICT line itself — that line carries the phrase and nothing else.',
102
172
  ].join('\n');
103
173
 
174
+ /** The chair's opening instruction when there is something to weigh (the ordinary case). */
175
+ const CHAIR_TASK =
176
+ 'You are the council chair. Write the synthesized verdict across the reviews, ' +
177
+ 'rankings, and adjudications below. Weigh each reviewer\'s findings by their ' +
178
+ 'peer-validated standing (rank position and adjudication pattern), distinguish ' +
179
+ 'findings the bench broadly endorsed from contested or singleton claims, and ' +
180
+ 'arrive at an overall assessment of the material.';
181
+
182
+ /**
183
+ * The chair's opening instruction when the bench raised nothing at all (LC-10
184
+ * fast-follow, review minor M2).
185
+ *
186
+ * Task 3 fixed this by keeping CHAIR_TASK unchanged and appending a correcting note
187
+ * — asymmetric with buildJudgeBundle, which swaps JUDGE_TASK_B for
188
+ * JUDGE_TASK_B_NO_FINDINGS outright. The un-swapped CHAIR_TASK asks the chair to
189
+ * "distinguish findings the bench broadly endorsed from contested or singleton
190
+ * claims" over an empty tier table and two bare section headings — an instruction
191
+ * whose only satisfiable reading is to invent material — and the chair reads that
192
+ * unfollowable directive FIRST, the correction second. This constant replaces
193
+ * CHAIR_TASK entirely on a clean bench instead: it never asks for a distinction
194
+ * that cannot exist, and the clean-bench framing (still stated, not just implied)
195
+ * is now the only instruction the chair sees, not an amendment to a bad one.
196
+ */
197
+ const CHAIR_TASK_NO_FINDINGS =
198
+ 'You are the council chair. Write the synthesized verdict across the reviews and ' +
199
+ 'rankings below. Weigh each reviewer\'s standing by rank position, and arrive at ' +
200
+ 'an overall assessment of the material. NOTE: this bench raised NO findings. ' +
201
+ 'Every reviewer read the material and reported nothing to fix, which is a valid ' +
202
+ 'outcome — not a failed run. Synthesize on that basis: say what the reviews ' +
203
+ 'actually establish and where the bench\'s agreement is thin, and do not ' +
204
+ 'manufacture concerns to fill the sections below.';
205
+
206
+ /** A section body, or an explicit reason it is empty — never a heading over nothing. */
207
+ function orNone(text, none) {
208
+ return (typeof text === 'string' && text.trim()) ? text : none;
209
+ }
210
+
104
211
  /**
105
212
  * De-anonymized chair packet (spec §5/§6: the chair sees identities).
106
213
  * @param {{reviews: Array<{model: string, text: string}>,
107
214
  * rankings: Array<{judge: string, order: Array<string|string[]>}>,
108
215
  * adjudications: Array<{findingId: string, judge: string, verdict: string}>,
109
216
  * tierCounts: object}} args
217
+ * `rankings` and `adjudications` may both be empty — an all-clean bench (LC-10)
218
+ * has nothing to adjudicate, and a Stage 2 whose judges all died has nothing to
219
+ * rank. Each empty section says WHICH of those it is rather than rendering blank.
110
220
  */
111
221
  function buildChairPacket({ reviews, rankings, adjudications, tierCounts, date }) {
112
222
  const reviewBlocks = reviews.map(r => `--- Review by ${r.model} ---\n${r.text}`).join('\n\n');
113
- const rankingLines = rankings
223
+ const rankingLines = (rankings || [])
114
224
  .map(r => `${r.judge}: ${JSON.stringify(r.order)}`)
115
225
  .join('\n');
116
- const adjLines = adjudications
226
+ const adjLines = (adjudications || [])
117
227
  .map(a => `${a.findingId} — ${a.judge}: ${a.verdict}`)
118
228
  .join('\n');
229
+ // Every finding lands in exactly one tier (tally.js countTiers), so the tier
230
+ // counts sum to the record's finding count — which is how an all-clean bench is
231
+ // told apart from a bench whose judges simply never voted.
232
+ const raisedCount = Object.values(tierCounts || {})
233
+ .reduce((s, n) => s + (typeof n === 'number' ? n : 0), 0);
119
234
  const tiers = JSON.stringify(tierCounts);
120
235
  const parts = [CHAIR_NO_TOOLS_PREAMBLE];
121
236
  if (date) { parts.push(dateLine(date)); }
237
+ parts.push(raisedCount === 0 ? CHAIR_TASK_NO_FINDINGS : CHAIR_TASK);
122
238
  parts.push(
123
- 'You are the council chair. Write the synthesized verdict across the reviews, ' +
124
- 'rankings, and adjudications below. Weigh each reviewer\'s findings by their ' +
125
- 'peer-validated standing (rank position and adjudication pattern), distinguish ' +
126
- 'findings the bench broadly endorsed from contested or singleton claims, and ' +
127
- 'arrive at an overall assessment of the material.',
128
239
  `Deterministic tier counts (peers-only cascade): ${tiers}`,
129
240
  '--- STAGE-1 REVIEWS (de-anonymized) ---',
130
241
  reviewBlocks,
131
242
  '--- PEER RANKINGS (judge: order, best first) ---',
132
- rankingLines,
243
+ orNone(rankingLines, '(none — no judge produced a usable ranking)'),
133
244
  '--- PER-FINDING ADJUDICATIONS ---',
134
- adjLines,
245
+ orNone(adjLines, raisedCount === 0
246
+ ? '(none — the bench raised no findings, so there was nothing to adjudicate)'
247
+ : '(none — no judge produced a usable adjudication)'),
135
248
  VERDICT_SCALE_ADDENDUM,
136
249
  );
137
250
  return parts.join('\n\n');
138
251
  }
139
252
 
140
- /** One-shot chair repair: the VERDICT line was missing (spec §5 chair contract). */
141
- function buildChairRepairPrompt() {
253
+ /**
254
+ * One-shot chair repair: the VERDICT line was missing (spec §5 chair contract).
255
+ *
256
+ * ⚠️ LC-12: this builder took NO arguments at all, so the repair solo — a fresh
257
+ * session — was asked for a verdict on a synthesis it could not see. The chair's
258
+ * synthesis WAS received; it is the verdict line that is missing. Handing back the
259
+ * synthesis lets the chair pick the verdict its own prose supports instead of
260
+ * re-deriving one from nothing.
261
+ * @param {{synthesis?: string}} [args]
262
+ */
263
+ function buildChairRepairPrompt({ synthesis } = {}) {
264
+ const text = typeof synthesis === 'string' ? synthesis.trim() : '';
265
+ const prior = text
266
+ ? ['--- YOUR SYNTHESIS (verbatim — verdict on THIS) ---', text,
267
+ '--- END OF YOUR SYNTHESIS ---'].join('\n')
268
+ : null;
142
269
  return [
143
- 'Do NOT use any tools or read any files; everything is in this message; begin ' +
144
- 'immediately with the VERDICT line.',
145
- 'Your synthesis was received, but the final parseable line was missing. Emit ONLY ' +
146
- 'one line, exactly one of:',
270
+ 'Do NOT use any tools or read any files; everything is in this message; begin '
271
+ + 'immediately with the VERDICT line.',
272
+ ...(prior ? [prior] : []),
273
+ 'Your synthesis was received, but the final parseable line was missing. Emit ONLY '
274
+ + 'one line, exactly one of:',
147
275
  'VERDICT: Ship it',
148
276
  'VERDICT: Fix these first',
149
277
  'VERDICT: Fundamental rethink',
@@ -153,5 +281,7 @@ function buildChairRepairPrompt() {
153
281
  module.exports = {
154
282
  JUDGE_NO_TOOLS_PREAMBLE, CHAIR_NO_TOOLS_PREAMBLE, CHAIR_VERDICT_VALUES,
155
283
  JUDGE_OUTPUT_CONTRACT, VERDICT_SCALE_ADDENDUM, dateLine,
284
+ JUDGE_TASK_B, JUDGE_TASK_B_NO_FINDINGS, NO_FINDINGS_INDEX,
285
+ CHAIR_TASK, CHAIR_TASK_NO_FINDINGS,
156
286
  buildJudgeBundle, buildJudgeRepairPrompt, buildChairPacket, buildChairRepairPrompt,
157
287
  };
@@ -31,7 +31,20 @@ const FINDINGS_TWO_PART_FRAMING = [
31
31
  '2. A trailing fenced ```json block immediately after the prose — no text after it:',
32
32
  ].join('\n');
33
33
 
34
- /** JSON shape + field rules — shared by the full Stage-1 contract AND the repair prompt. */
34
+ /**
35
+ * JSON shape + field rules — shared by the full Stage-1 contract AND the repair prompt.
36
+ *
37
+ * ⚠️ LC-10 (owner ruling, 2026-07-26). The `"overall"` and `"findings"` rules below
38
+ * are what finally make this prompt and `validateFindings` say the same thing. The
39
+ * briefing has always shipped the anti-sycophancy clause's "An empty severity category
40
+ * is a valid result" while the validator rejected exactly that answer (EMPTY_FINDINGS),
41
+ * so the only way for a model to satisfy the schema was to produce a finding —
42
+ * structural pressure to fabricate, which costgate01's grok obeyed. The validator now
43
+ * accepts a well-formed empty set, and these two lines state the same rule to the model:
44
+ * `findings` may be `[]`, `overall` may never be blank. Stating `overall` explicitly
45
+ * matters more than it looks — it is the field that separates "I read it and found
46
+ * nothing" from a hollow shell, and it was never in the field rules at all.
47
+ */
35
48
  const FINDINGS_JSON_SHAPE = [
36
49
  '```json',
37
50
  '{',
@@ -43,6 +56,10 @@ const FINDINGS_JSON_SHAPE = [
43
56
  '}',
44
57
  '```',
45
58
  '',
59
+ '- "overall" — a non-empty string. Always required, including when you found nothing.',
60
+ '- "findings" — the array of findings, always present. If you genuinely found nothing,',
61
+ ' emit [] and say so in "overall": a review that read the material and found nothing',
62
+ ' is a valid review. Never invent a finding to fill the array.',
46
63
  '- "id" — sequential integer within this review, starting at 1.',
47
64
  '- "severity" — one of: blocker | major | minor | nit.',
48
65
  '- "claim", "location", "rationale" — non-empty strings.',
@@ -121,13 +138,52 @@ function buildLensBriefing({ lens, briefing, date }) {
121
138
  * repair loop). References ONLY the json-shape fragment — never the
122
139
  * "prose review THEN json" framing — so a headless model isn't handed license
123
140
  * to write a whole new prose review on a tight repair turn.
141
+ *
142
+ * ⚠️ LC-6. This used to carry the validation ERRORS without the REVIEW they
143
+ * were errors about, and a repair solo is a FRESH session with no memory of
144
+ * the review turn. Three of five paid councils burned a seat on it:
145
+ * - wsgate02 `qwen` — refused twice: "I don't have a previous review to correct"
146
+ * - wsgate04 `glm` — refused twice: "the previous review's content was
147
+ * excluded by the caller… I will not fabricate findings"
148
+ * - costgate01 `grok` — COMPLIED, by inventing a self-referential finding
149
+ * about its own empty output, which then entered
150
+ * tally.json, the street-cred table and the chair
151
+ * synthesis as C1 and reached a human's decision.
152
+ * Honest models lose the seat (a 4-model bench silently adjudicating on 3,
153
+ * still paying for the fourth); compliant ones poison the record.
154
+ *
155
+ * `review` is the text that ACTUALLY failed — the original review on the first
156
+ * attempt, the previous repair's output on the second — so the errors and the
157
+ * artifact they describe are always the same thing. It is embedded verbatim and
158
+ * uncapped: the largest real case was 35 KB, and a silent truncation would
159
+ * recreate this defect in a subtler form (repairing a review the model can only
160
+ * half see).
161
+ * @param {{errors?: Array<{code: string, detail: string}>, review?: string}} args
124
162
  */
125
- function buildFindingsRepairPrompt({ errors }) {
163
+ function buildFindingsRepairPrompt({ errors, review }) {
126
164
  const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
165
+ const text = typeof review === 'string' ? review.trim() : '';
166
+ // The absent case is stated, never papered over with an empty block: a model
167
+ // asked to repair nothing must be told to report nothing rather than left to
168
+ // guess, which is precisely what produced grok's invented finding.
169
+ //
170
+ // ⚠️ LC-10: this branch's instruction — "emit an empty findings array and say so
171
+ // in overall" — described an answer the validator then REJECTED, so a model that
172
+ // complied burned its second repair attempt and still landed 'unstructured'. It is
173
+ // now the correct answer: an empty set with a real `overall` validates, and the
174
+ // seat ends 'repaired' + findingsUnverified rather than 'unstructured'.
175
+ const prior = text
176
+ ? ['--- YOUR PREVIOUS REVIEW (verbatim — this is the text to correct) ---',
177
+ text,
178
+ '--- END OF YOUR PREVIOUS REVIEW ---'].join('\n')
179
+ : 'Your previous response was empty — there is no prior review text to correct. ' +
180
+ 'Do not invent findings to satisfy the schema: emit an empty "findings" array ' +
181
+ 'and say so in "overall".';
127
182
  return [
128
183
  'Do NOT use any tools or read any files; everything is in this message; begin ' +
129
184
  'immediately with the JSON block.',
130
- 'Your previous review\'s trailing findings JSON failed validation with these errors:',
185
+ prior,
186
+ 'That review\'s trailing findings JSON failed validation with these errors:',
131
187
  lines,
132
188
  'Re-emit ONLY the corrected findings JSON block (the same findings, fixed — do not ' +
133
189
  'add or remove findings), as a single fenced ```json block:',