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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.md +6 -3
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +59 -3
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +277 -0
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- package/src/workspace/run-scan.js +148 -0
package/src/council/findings.js
CHANGED
|
@@ -4,12 +4,124 @@
|
|
|
4
4
|
const SEVERITIES = ['blocker', 'major', 'minor', 'nit'];
|
|
5
5
|
const REQUIRED = ['claim', 'location', 'rationale'];
|
|
6
6
|
|
|
7
|
-
/**
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 = {
|
|
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
|
-
|
|
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) {
|
package/src/council/report.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
};
|