amicus 4.4.0 → 4.5.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +162 -0
- package/README.md +17 -2
- package/bin/amicus.js +10 -0
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +226 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +395 -0
- package/docs/council.md +970 -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 +56 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +849 -0
- package/electron/fold.js +1 -1
- package/electron/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +88 -19
- package/electron/workspace-ui/workspace-render.js +17 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +13 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +52 -4
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-template.js +53 -0
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +68 -5
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +24 -1
- package/src/council/findings.js +199 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/presets-cli.js +23 -11
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +64 -11
- 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 +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +134 -110
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +45 -47
- package/src/council/tally.js +10 -0
- package/src/headless.js +180 -7
- package/src/mcp-council-run.js +108 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +82 -74
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +42 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +69 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/spend-query.js +17 -5
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/error-doc.js +5 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +66 -7
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- package/src/workspace/run-detail.js +13 -1
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
'
|
|
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
|
};
|
package/src/council/briefings.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.',
|
|
@@ -149,6 +166,12 @@ function buildFindingsRepairPrompt({ errors, review }) {
|
|
|
149
166
|
// The absent case is stated, never papered over with an empty block: a model
|
|
150
167
|
// asked to repair nothing must be told to report nothing rather than left to
|
|
151
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'.
|
|
152
175
|
const prior = text
|
|
153
176
|
? ['--- YOUR PREVIOUS REVIEW (verbatim — this is the text to correct) ---',
|
|
154
177
|
text,
|
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, a distinction downstream callers key off).
|
|
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): its own
|
|
160
|
+
// JSON.parse succeeds on `null`, and `Array.isArray(null.findings)` throws into
|
|
161
|
+
// 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,34 @@ 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
|
+
|
|
48
236
|
/**
|
|
49
237
|
* v4.0 §7: stamp the council v2 envelope onto a validateFindings result
|
|
50
238
|
* (additive — ok/findings/errors stay top-level; existing key-readers keep
|
|
@@ -57,4 +245,6 @@ function buildValidateDoc(result) {
|
|
|
57
245
|
return { schemaVersion: COUNCIL_SCHEMA_VERSION, type: 'council-validate', ...result };
|
|
58
246
|
}
|
|
59
247
|
|
|
60
|
-
module.exports = {
|
|
248
|
+
module.exports = {
|
|
249
|
+
validateFindings, buildValidateDoc, SEVERITIES, lastJsonBlock, countAttemptedFindings,
|
|
250
|
+
};
|
|
@@ -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) {
|
|
@@ -56,7 +56,8 @@ function runSave(name, modelsArg, useJson) {
|
|
|
56
56
|
|
|
57
57
|
function renderSave(doc) {
|
|
58
58
|
const notice = doc.overwritten ? ' (overwritten)' : '';
|
|
59
|
-
return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n
|
|
59
|
+
return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n` +
|
|
60
|
+
" for full run configuration — chair, options, templates — see 'amicus pack'\n";
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/**
|
|
@@ -102,6 +103,16 @@ function renderList(entries) {
|
|
|
102
103
|
* which fails outright below 2 usable members), `show` is diagnostic-only:
|
|
103
104
|
* it always reports the full resolved/dropped split, even for a council
|
|
104
105
|
* that currently has fewer than 2 usable members.
|
|
106
|
+
*
|
|
107
|
+
* v4.5 Wave 2 (post-HOLD chip, task-23-report.md Anomaly 1): the resolved/
|
|
108
|
+
* dropped split now REUSES `classifyCouncilMembers` — the exact alias +
|
|
109
|
+
* catalog-membership + local-provider-tri-state check `resolveCouncilMembers`
|
|
110
|
+
* applies on every real run — instead of a parallel check that only asked
|
|
111
|
+
* "does the alias map to SOME id?" and never consulted the catalog at all
|
|
112
|
+
* (so a member whose alias resolved to a catalog-absent id read as healthy
|
|
113
|
+
* here while every real run silently dropped it). `droppedMembers` carries
|
|
114
|
+
* each dropped member's reason, distinguishing an unresolvable alias from a
|
|
115
|
+
* catalog-absent id — the same distinction `resolveCouncilMembers` computes.
|
|
105
116
|
* @param {string|undefined} name
|
|
106
117
|
* @param {boolean} useJson
|
|
107
118
|
* @returns {number} exit code
|
|
@@ -111,7 +122,7 @@ function runShow(name, useJson) {
|
|
|
111
122
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council show needs a <name>',
|
|
112
123
|
hint: 'amicus council show <name> [--json]' });
|
|
113
124
|
}
|
|
114
|
-
const { getCouncilWithSource,
|
|
125
|
+
const { getCouncilWithSource, classifyCouncilMembers } = require('../utils/config');
|
|
115
126
|
const { readCache } = require('../utils/model-catalog');
|
|
116
127
|
const catalog = (readCache() || {}).models || [];
|
|
117
128
|
const { members, builtin } = getCouncilWithSource(name, catalog);
|
|
@@ -119,14 +130,8 @@ function runShow(name, useJson) {
|
|
|
119
130
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Unknown council '${name}'`,
|
|
120
131
|
hint: "'amicus council list' shows available councils, or 'amicus council save' to create one" });
|
|
121
132
|
}
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const dropped = [];
|
|
125
|
-
for (const member of members) {
|
|
126
|
-
const id = member.includes('/') ? member : aliases[member];
|
|
127
|
-
(id ? resolved : dropped).push(member);
|
|
128
|
-
}
|
|
129
|
-
const doc = { name, builtin, members, resolved, dropped };
|
|
133
|
+
const { models, dropped, droppedMembers } = classifyCouncilMembers(members, catalog);
|
|
134
|
+
const doc = { name, builtin, members, resolved: models, dropped, droppedMembers };
|
|
130
135
|
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderShow(doc));
|
|
131
136
|
return 0;
|
|
132
137
|
}
|
|
@@ -134,7 +139,14 @@ function runShow(name, useJson) {
|
|
|
134
139
|
function renderShow(doc) {
|
|
135
140
|
const tag = doc.builtin ? ' [built-in]' : '';
|
|
136
141
|
let out = `Council '${doc.name}'${tag}\n members: ${doc.members.join(', ')}\n resolved: ${doc.resolved.join(', ')}\n`;
|
|
137
|
-
if (doc.dropped.length) {
|
|
142
|
+
if (doc.dropped.length) {
|
|
143
|
+
// Per-member reason (v4.5 Wave 2) when available; falls back to the bare
|
|
144
|
+
// ref list so this never throws on a hand-built doc missing the new field.
|
|
145
|
+
const detail = (doc.droppedMembers && doc.droppedMembers.length)
|
|
146
|
+
? doc.droppedMembers.map(d => `${d.member} (${d.reason})`).join(', ')
|
|
147
|
+
: doc.dropped.join(', ');
|
|
148
|
+
out += ` dropped: ${detail}\n`;
|
|
149
|
+
}
|
|
138
150
|
return out;
|
|
139
151
|
}
|
|
140
152
|
|