amicus 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/CHANGELOG.md +53 -0
  4. package/LICENSE +22 -1
  5. package/README.md +12 -2
  6. package/bin/amicus.js +13 -161
  7. package/package.json +6 -4
  8. package/scripts/postinstall.js +16 -7
  9. package/skills/second-opinion/COUNCIL-DESIGN.md +34 -34
  10. package/skills/second-opinion/MODEL-NOTES.md +23 -17
  11. package/skills/second-opinion/SKILL.md +77 -45
  12. package/src/cli-handlers-council.js +59 -0
  13. package/src/cli-handlers-doctor.js +173 -0
  14. package/src/cli-handlers-run.js +196 -0
  15. package/src/cli-handlers.js +1 -1
  16. package/src/cli.js +11 -2
  17. package/src/council/findings.js +48 -0
  18. package/src/council/ledger.js +82 -0
  19. package/src/council/tally.js +108 -0
  20. package/src/council/verdict.js +48 -0
  21. package/src/headless.js +43 -149
  22. package/src/mcp-server.js +6 -0
  23. package/src/sidecar/budget.js +83 -0
  24. package/src/sidecar/conversation-mirror.js +128 -0
  25. package/src/sidecar/fanout-leg.js +4 -1
  26. package/src/sidecar/fanout.js +34 -7
  27. package/src/sidecar/interactive-mirror.js +66 -0
  28. package/src/sidecar/interactive.js +35 -21
  29. package/src/sidecar/models.js +10 -9
  30. package/src/sidecar/session-finalize.js +26 -0
  31. package/src/sidecar/session-utils.js +5 -5
  32. package/src/sidecar/setup.js +2 -2
  33. package/src/sidecar/start.js +19 -6
  34. package/src/utils/activity-poller.js +47 -0
  35. package/src/utils/alias-resolver.js +1 -1
  36. package/src/utils/config.js +4 -4
  37. package/src/utils/error-doc.js +55 -0
  38. package/src/utils/lifecycle.js +1 -1
  39. package/src/utils/model-catalog.js +1 -1
  40. package/src/utils/pricing.js +93 -0
  41. package/src/utils/result-schema.js +21 -2
  42. package/src/utils/session-abort.js +40 -13
  43. package/src/utils/validators.js +17 -17
  44. /package/{skill → skills/sidecar}/SKILL.md +0 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * CLI Run Handlers (WS-2 extraction)
3
+ *
4
+ * Extracted from bin/amicus.js to keep the CLI entry point under the 300-line
5
+ * size gate and to make handlers unit-testable without running main().
6
+ *
7
+ * Contains: handleStart, handleFanout, handleRead
8
+ * Remaining inline in bin/amicus.js: handleList, handleResume, handleContinue
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { validateStartArgs } = require('./cli');
14
+ const { validateTaskId } = require('./utils/validators');
15
+ const { resolveModelFromArgs, validateFallbackModel } = require('./utils/start-helpers');
16
+ const { failJson, ERROR_CODES } = require('./utils/error-doc');
17
+
18
+ /**
19
+ * Handle 'sidecar start' command
20
+ * Spec Reference: §4.1
21
+ */
22
+ async function handleStart(args) {
23
+ const useJson = !!args.json;
24
+
25
+ // F4: --prompt-file support (XOR --prompt) and --json gating
26
+ if (args.prompt !== undefined || args['prompt-file'] !== undefined) {
27
+ const { resolvePromptSource } = require('./utils/prompt-source');
28
+ const promptRes = resolvePromptSource(args);
29
+ if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
30
+ args.prompt = promptRes.prompt;
31
+ }
32
+ if (args.json && !args['no-ui']) {
33
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
34
+ }
35
+
36
+ const mc = args['max-cost'];
37
+ if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
38
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
39
+ }
40
+
41
+ const { model, alias } = resolveModelFromArgs(args);
42
+ args.model = model;
43
+ args.model = await validateFallbackModel(args, alias);
44
+
45
+ // Normalize agent: --agent takes precedence, otherwise use --mode
46
+ args.agent = args.agent || args.mode;
47
+
48
+ const validation = validateStartArgs(args);
49
+ if (!validation.valid) {
50
+ process.exit(failJson(useJson, { code: validation.code || ERROR_CODES.BAD_ARGS, message: validation.error }));
51
+ }
52
+
53
+ // Budget gate for solo start
54
+ if (!args['no-cost-gate']) {
55
+ const { lookupPricing } = require('./utils/pricing');
56
+ const { checkBudget, formatBudgetError } = require('./sidecar/budget');
57
+ const { loadConfig } = require('./utils/config');
58
+ const cfg = loadConfig() || {};
59
+ const soloLeg = { modelInput: alias || args.model, model: args.model, pricing: lookupPricing(args.model) };
60
+ const promptChars = (args.prompt && String(args.prompt).length) || 0;
61
+ const budget = checkBudget([soloLeg], { maxCostPerMtok: cfg.maxCostPerMtok, maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost, promptChars });
62
+ if (!budget.ok) {
63
+ process.exit(failJson(useJson, { code: ERROR_CODES.BUDGET_EXCEEDED, message: 'Error: budget gate refused the run', hint: formatBudgetError(budget) }));
64
+ }
65
+ }
66
+
67
+ const { startSidecar } = require('./index');
68
+
69
+ return await startSidecar({
70
+ taskId: args['task-id'],
71
+ model: args.model,
72
+ prompt: args.prompt,
73
+ sessionId: args['session-id'],
74
+ cwd: args.cwd,
75
+ contextTurns: args['context-turns'],
76
+ contextSince: args['context-since'],
77
+ contextMaxTokens: args['context-max-tokens'],
78
+ noUi: args['no-ui'],
79
+ timeout: args.timeout,
80
+ agent: args.agent,
81
+ mcp: args.mcp,
82
+ mcpConfig: args['mcp-config'],
83
+ thinking: args.thinking,
84
+ summaryLength: args['summary-length'],
85
+ client: args.client,
86
+ sessionDir: args['session-dir'],
87
+ foldShortcut: args['fold-shortcut'],
88
+ opencodePort: args['opencode-port'],
89
+ noMcp: args['no-mcp'],
90
+ excludeMcp: args['exclude-mcp'],
91
+ coworkProcess: args['cowork-process'],
92
+ position: args.position,
93
+ json: !!args.json,
94
+ modelInput: alias || null,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Handle 'amicus fanout' command (F4).
100
+ * Returns the wave exit code: 0 all complete, 2 partial, 1 none/hard failure,
101
+ * 130/143 when the wave was signal-aborted.
102
+ */
103
+ async function handleFanout(args) {
104
+ const useJson = !!args.json;
105
+
106
+ const { resolvePromptSource } = require('./utils/prompt-source');
107
+ const promptRes = resolvePromptSource(args);
108
+ if (promptRes.error) {
109
+ process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error }));
110
+ }
111
+ if (typeof args.models !== 'string' || !args.models.trim()) {
112
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models is required (comma-separated aliases or provider/model IDs)' }));
113
+ }
114
+ if (args['wave-id']) {
115
+ const check = validateTaskId(String(args['wave-id']));
116
+ if (!check.valid) {
117
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error }));
118
+ }
119
+ }
120
+ if (args.agent && String(args.agent).toLowerCase() === 'chat') {
121
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --agent chat is interactive-only; fanout is headless' }));
122
+ }
123
+ if (args.timeout !== undefined && args.timeout <= 0) {
124
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' }));
125
+ }
126
+ const mc = args['max-cost'];
127
+ if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
128
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
129
+ }
130
+ const { parseModelsList } = require('./sidecar/fanout');
131
+ if (parseModelsList(args.models).length === 0) {
132
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models must contain at least one non-empty entry' }));
133
+ }
134
+
135
+ // Direct require — the src/index.js public re-export is added later (Task 13)
136
+ const { runFanout } = require('./sidecar/fanout');
137
+ const { loadConfig } = require('./utils/config');
138
+ const cfg = loadConfig() || {};
139
+ const { exitCode } = await runFanout({
140
+ models: args.models,
141
+ prompt: promptRes.prompt,
142
+ promptMeta: promptRes.promptMeta,
143
+ waveId: args['wave-id'],
144
+ project: args.cwd || process.cwd(),
145
+ agent: args.agent || args.mode,
146
+ thinking: args.thinking,
147
+ timeout: args.timeout,
148
+ summaryLength: args['summary-length'],
149
+ includeContext: !args['no-context'],
150
+ sessionId: args['session-id'],
151
+ contextTurns: args['context-turns'],
152
+ contextSince: args['context-since'],
153
+ contextMaxTokens: args['context-max-tokens'],
154
+ mcp: args.mcp,
155
+ mcpConfig: args['mcp-config'],
156
+ noMcp: args['no-mcp'],
157
+ excludeMcp: args['exclude-mcp'],
158
+ noValidateModel: args['no-validate-model'],
159
+ json: !!args.json,
160
+ client: args.client,
161
+ maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,
162
+ noCostGate: !!args['no-cost-gate'],
163
+ maxCostPerMtok: cfg.maxCostPerMtok,
164
+ });
165
+ return exitCode;
166
+ }
167
+
168
+ /**
169
+ * Handle 'sidecar read' command
170
+ * Spec Reference: §4.5
171
+ */
172
+ async function handleRead(args) {
173
+ const useJson = !!args.json;
174
+ const taskId = args._[1];
175
+
176
+ if (!taskId) {
177
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: 'Error: task_id is required for read' }));
178
+ }
179
+
180
+ const taskIdCheck = validateTaskId(taskId);
181
+ if (!taskIdCheck.valid) {
182
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: taskIdCheck.error }));
183
+ }
184
+
185
+ const { readSidecar } = require('./index');
186
+
187
+ await readSidecar({
188
+ taskId,
189
+ conversation: args.conversation,
190
+ metadata: args.metadata,
191
+ json: args.json,
192
+ project: args.cwd
193
+ });
194
+ }
195
+
196
+ module.exports = { handleStart, handleFanout, handleRead };
@@ -97,7 +97,7 @@ async function handleAbort(args) {
97
97
 
98
98
  if (!taskId) {
99
99
  console.error('Error: task_id is required for abort');
100
- console.error('Usage: sidecar abort <task_id>');
100
+ console.error('Usage: amicus abort <task_id>');
101
101
  process.exit(1);
102
102
  }
103
103
 
package/src/cli.js CHANGED
@@ -111,7 +111,8 @@ function isBooleanFlag(key) {
111
111
  'api-keys',
112
112
  'validate-model',
113
113
  'no-validate-model',
114
- 'remove' // used by 'key' command only; other handlers ignore it
114
+ 'remove', // used by 'key' command only; other handlers ignore it
115
+ 'no-cost-gate', // disable the budget gate for this run
115
116
  ];
116
117
  return booleanFlags.includes(key);
117
118
  }
@@ -120,6 +121,9 @@ function isBooleanFlag(key) {
120
121
  * Parse a value to the appropriate type
121
122
  */
122
123
  function parseValue(key, value) {
124
+ // max-cost is a float (dollars), not an integer
125
+ if (key === 'max-cost') { return parseFloat(value); }
126
+
123
127
  // Numeric options
124
128
  const numericOptions = ['context-turns', 'context-max-tokens', 'timeout', 'opencode-port'];
125
129
  if (numericOptions.includes(key)) {
@@ -158,7 +162,7 @@ function validateStartArgs(args) {
158
162
 
159
163
  // Validate model format if model is present (model is resolved externally via resolveModel)
160
164
  if (args.model && !isValidModelFormat(args.model)) {
161
- return { valid: false, error: 'Error: --model must be in format provider/model (e.g., google/gemini-2.5-flash) or openrouter/provider/model' };
165
+ return { valid: false, code: 'BAD_MODEL', error: 'Error: --model must be in format provider/model (e.g., google/gemini-2.5-flash) or openrouter/provider/model' };
162
166
  }
163
167
 
164
168
  // Validate cwd path exists (if provided)
@@ -299,6 +303,9 @@ Commands:
299
303
  continue New session building on previous
300
304
  read Output session summary/conversation
301
305
  models List/search the model catalog, refresh it, audit aliases
306
+ council tally <input.json> [--json] Tally council findings → tiers/street-cred
307
+ council stats [--json] Reviewer-reliability from the ledger
308
+ doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
302
309
  abort Abort a running session (or --all)
303
310
  setup Configure default model and aliases
304
311
  --api-keys Open API key setup window
@@ -352,6 +359,8 @@ Options for 'fanout':
352
359
  with --prompt. Also works with 'start'.
353
360
  --wave-id <id> Explicit wave ID (leg IDs become <id>-1..N)
354
361
  --json Emit the wave result as stable JSON on stdout
362
+ --max-cost <$> Refuse the wave if the estimated total exceeds $ (soft ceiling)
363
+ --no-cost-gate Disable the budget gate (per-$/Mtok threshold + ceiling) for this run
355
364
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
356
365
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
357
366
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
@@ -0,0 +1,48 @@
1
+ // src/council/findings.js
2
+ 'use strict';
3
+
4
+ const SEVERITIES = ['blocker', 'major', 'minor', 'nit'];
5
+ const REQUIRED = ['claim', 'location', 'rationale'];
6
+
7
+ /** Extract the LAST ```json fenced block's body, or null. */
8
+ function lastJsonBlock(text) {
9
+ const re = /```json\s*\n([\s\S]*?)```/g;
10
+ let m, last = null;
11
+ while ((m = re.exec(text)) !== null) { last = m[1]; }
12
+ return last;
13
+ }
14
+
15
+ /**
16
+ * Validate a Stage-1 reviewer's fenced findings JSON.
17
+ * @param {string} jsonText full review text (prose + fenced block)
18
+ * @returns {{ok:boolean, findings:Array, errors:Array<{code:string,detail:string}>}}
19
+ */
20
+ function validateFindings(jsonText) {
21
+ const errors = [];
22
+ const body = lastJsonBlock(jsonText || '');
23
+ if (body === null) {
24
+ return { ok: false, findings: [], errors: [{ code: 'NO_FENCED_BLOCK', detail: 'no ```json block found' }] };
25
+ }
26
+ let parsed;
27
+ try { parsed = JSON.parse(body); }
28
+ catch (e) { return { ok: false, findings: [], errors: [{ code: 'NOT_PARSEABLE', detail: e.message }] }; }
29
+
30
+ const findings = Array.isArray(parsed.findings) ? parsed.findings : [];
31
+ if (findings.length === 0) {
32
+ errors.push({ code: 'EMPTY_FINDINGS', detail: 'findings is missing or empty' });
33
+ }
34
+ const seen = new Set();
35
+ findings.forEach((f, i) => {
36
+ if (seen.has(f.id)) { errors.push({ code: 'DUPLICATE_ID', detail: `id ${f.id} repeats` }); }
37
+ seen.add(f.id);
38
+ if (f.id !== i + 1) { errors.push({ code: 'NON_SEQUENTIAL_ID', detail: `expected id ${i + 1}, got ${f.id}` }); }
39
+ if (!SEVERITIES.includes(f.severity)) { errors.push({ code: 'BAD_SEVERITY', detail: `bad severity '${f.severity}' on id ${f.id}` }); }
40
+ for (const k of REQUIRED) {
41
+ if (typeof f[k] !== 'string' || f[k].trim() === '') { errors.push({ code: 'MISSING_FIELD', detail: `missing ${k} on id ${f.id}` }); }
42
+ }
43
+ });
44
+
45
+ return { ok: errors.length === 0, findings: errors.length === 0 ? findings : [], errors };
46
+ }
47
+
48
+ module.exports = { validateFindings, SEVERITIES };
@@ -0,0 +1,82 @@
1
+ // src/council/ledger.js
2
+ 'use strict';
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getConfigDir } = require('../utils/config');
6
+
7
+ const LEDGER_SCHEMA_VERSION = 1;
8
+ const LEDGER_FILE = 'council-ledger.jsonl';
9
+
10
+ function countSeverity(findings) {
11
+ const c = { blocker: 0, major: 0, minor: 0, nit: 0 };
12
+ for (const f of findings) { if (c[f.severity] !== undefined) { c[f.severity] += 1; } }
13
+ return c;
14
+ }
15
+
16
+ /** One model-level row per council model. Rates are over RAW raised findings. */
17
+ function buildLedgerRows(record) {
18
+ const { meta, findings, streetCred, runStats, judged } = record;
19
+ const sc = new Map(streetCred.map(s => [s.model, s]));
20
+ const rs = new Map(runStats.map(r => [r.model, r]));
21
+ return meta.models.map(model => {
22
+ const raised = findings.filter(f => f.raiser === model);
23
+ const s = sc.get(model) || {};
24
+ const r = rs.get(model) || {};
25
+ const denom = raised.length;
26
+ return {
27
+ schemaVersion: LEDGER_SCHEMA_VERSION,
28
+ runId: meta.runId, date: meta.date, runType: meta.runType, model,
29
+ role: r.role || 'council', wasChair: !!r.wasChair, judged: judged === true,
30
+ streetCredWithSelf: judged ? (s.withSelf ?? null) : null,
31
+ streetCredPeersOnly: judged ? (s.peersOnly ?? null) : null,
32
+ findingsRaised: denom,
33
+ bySeverity: countSeverity(raised),
34
+ confirmRate: judged && denom ? raised.filter(f => f.tier === 'Confirmed').length / denom : null,
35
+ factErrorRate: judged && denom ? raised.filter(f => f.tier === 'Disputed').length / denom : null,
36
+ conformance: r.conformance || 'clean',
37
+ };
38
+ });
39
+ }
40
+
41
+ function appendRun(record, opts = {}) {
42
+ const dir = opts.dir || getConfigDir();
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ const file = path.join(dir, LEDGER_FILE);
45
+ const rows = buildLedgerRows(record);
46
+ for (const row of rows) { fs.appendFileSync(file, JSON.stringify(row) + '\n'); }
47
+ return rows;
48
+ }
49
+
50
+ function readRows(dir) {
51
+ const file = path.join(dir, LEDGER_FILE);
52
+ if (!fs.existsSync(file)) { return []; }
53
+ return fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean)
54
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
55
+ }
56
+
57
+ function avg(nums) { return nums.length ? nums.reduce((s, x) => s + x, 0) / nums.length : null; }
58
+
59
+ /** Aggregate the ledger per model. peersOnly nulls excluded; lowN flags < 3 runs. */
60
+ function deriveReliability(opts = {}) {
61
+ const dir = opts.dir || getConfigDir();
62
+ const byModel = new Map();
63
+ for (const row of readRows(dir)) {
64
+ if (!byModel.has(row.model)) { byModel.set(row.model, []); }
65
+ byModel.get(row.model).push(row);
66
+ }
67
+ return [...byModel.entries()].map(([model, rows]) => {
68
+ const peers = rows.map(r => r.streetCredPeersOnly).filter(v => typeof v === 'number');
69
+ const confirms = rows.map(r => r.confirmRate).filter(v => typeof v === 'number');
70
+ const facts = rows.map(r => r.factErrorRate).filter(v => typeof v === 'number');
71
+ const conformance = rows.reduce((acc, r) => { acc[r.conformance] = (acc[r.conformance] || 0) + 1; return acc; }, {});
72
+ return {
73
+ model, runs: rows.length, lowN: rows.length < 3,
74
+ avgStreetCredPeersOnly: avg(peers),
75
+ lifetimeConfirmRate: avg(confirms),
76
+ lifetimeFactErrorRate: avg(facts),
77
+ conformance,
78
+ };
79
+ });
80
+ }
81
+
82
+ module.exports = { buildLedgerRows, appendRun, deriveReliability, LEDGER_FILE, LEDGER_SCHEMA_VERSION };
@@ -0,0 +1,108 @@
1
+ // src/council/tally.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
6
+ * (the raiser's own adjudication is excluded by the caller).
7
+ * Exhaustive and mutually exclusive over all (a,d).
8
+ * @param {number} a - peer agree count
9
+ * @param {number} d - peer dispute count
10
+ * @returns {{tier:string, confidence:'thin'|'solid'}}
11
+ */
12
+ function assignTier(a, d) {
13
+ let tier;
14
+ if (d >= 2 && d > a) { tier = 'Disputed'; }
15
+ else if (a >= 2 && a > d) { tier = 'Confirmed'; }
16
+ else if (d >= 1) { tier = 'Contested'; }
17
+ else { tier = 'Singleton'; }
18
+ const confidence = (a + d <= 1) ? 'thin' : 'solid';
19
+ return { tier, confidence };
20
+ }
21
+
22
+ function mean(arr) { return arr.reduce((s, x) => s + x, 0) / arr.length; }
23
+
24
+ /** Map each model to its (possibly fractional) rank position in one judge's order. */
25
+ function rankPositions(order) {
26
+ const pos = new Map();
27
+ let p = 1;
28
+ for (const slot of order) {
29
+ const group = Array.isArray(slot) ? slot : [slot];
30
+ const meanPos = p + (group.length - 1) / 2;
31
+ for (const m of group) { pos.set(m, meanPos); }
32
+ p += group.length;
33
+ }
34
+ return pos;
35
+ }
36
+
37
+ /**
38
+ * Both-numbers street-cred. Lower mean rank = better.
39
+ * @param {Array<{judge:string, order:Array<string|string[]>}>} rankings
40
+ * @param {string[]} models all reviewed models (incl. claude when in-council)
41
+ */
42
+ function computeStreetCred(rankings, models) {
43
+ const judgePos = rankings.map(r => ({ judge: r.judge, pos: rankPositions(r.order) }));
44
+ return models.map(m => {
45
+ const all = [], peers = [], perJudgeRank = {};
46
+ for (const { judge, pos } of judgePos) {
47
+ if (!pos.has(m)) { continue; } // absent from this judge's ranking → skip
48
+ const rank = pos.get(m);
49
+ perJudgeRank[judge] = rank;
50
+ all.push(rank);
51
+ if (judge !== m) { peers.push(rank); }
52
+ }
53
+ return {
54
+ model: m,
55
+ withSelf: all.length ? mean(all) : null,
56
+ peersOnly: peers.length ? mean(peers) : null,
57
+ perJudgeRank,
58
+ };
59
+ });
60
+ }
61
+
62
+ const COUNCIL_SCHEMA_VERSION = 1;
63
+ const VERDICTS = { agree: 'a', dispute: 'd', neutral: 'n' };
64
+
65
+ function countTiers(findings) {
66
+ const counts = { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 };
67
+ for (const f of findings) { counts[f.tier] += 1; }
68
+ return counts;
69
+ }
70
+
71
+ /**
72
+ * Deterministic council tally. Pure: no IO. Claude assembles `input`
73
+ * (de-anonymized) and may override margin tiers afterward.
74
+ * @returns {object} record
75
+ */
76
+ function tally(input) {
77
+ const { meta, findings, rankings, adjudications, runStats } = input;
78
+ const byFinding = new Map();
79
+ for (const adj of adjudications) {
80
+ if (!byFinding.has(adj.findingId)) { byFinding.set(adj.findingId, []); }
81
+ byFinding.get(adj.findingId).push({ judge: adj.judge, verdict: adj.verdict });
82
+ }
83
+ const outFindings = findings.map(f => {
84
+ const votes = byFinding.get(f.id) || [];
85
+ const peers = votes.filter(v => v.judge !== f.raiser);
86
+ const basis = { a: 0, d: 0, n: 0 };
87
+ for (const v of peers) { basis[VERDICTS[v.verdict]] += 1; }
88
+ const { tier, confidence } = assignTier(basis.a, basis.d);
89
+ return { id: f.id, raiser: f.raiser, severity: f.severity, tier, basis, confidence,
90
+ tierOverride: null, adjudications: votes };
91
+ });
92
+ return {
93
+ schemaVersion: COUNCIL_SCHEMA_VERSION,
94
+ meta,
95
+ judged: Array.isArray(rankings) && rankings.length >= 2,
96
+ streetCred: computeStreetCred(rankings || [], meta.models),
97
+ findings: outFindings,
98
+ runStats: (runStats || []).map(r => ({
99
+ model: r.model, role: r.role, wasChair: !!r.wasChair, conformance: r.conformance || 'clean',
100
+ status: r.status || 'unknown',
101
+ durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
102
+ usage: r.usage || null,
103
+ })),
104
+ tierCounts: countTiers(outFindings),
105
+ };
106
+ }
107
+
108
+ module.exports = { assignTier, computeStreetCred, tally, COUNCIL_SCHEMA_VERSION };
@@ -0,0 +1,48 @@
1
+ // src/council/verdict.js
2
+ 'use strict';
3
+ const fs = require('fs');
4
+
5
+ const VERDICT_SCHEMA_VERSION = 1;
6
+
7
+ /**
8
+ * Merge a tally record with Claude's Stage-4 decisions into the verdict record.
9
+ * @param {object} record tally() output
10
+ * @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
11
+ */
12
+ function buildVerdict(record, decisions = []) {
13
+ const byId = new Map(decisions.map(d => [d.id, d]));
14
+ return {
15
+ schemaVersion: VERDICT_SCHEMA_VERSION,
16
+ runId: record.meta.runId,
17
+ runType: record.meta.runType,
18
+ date: record.meta.date,
19
+ chair: record.meta.chair,
20
+ council: record.meta.models,
21
+ claudeInCouncil: record.meta.claudeInCouncil,
22
+ findings: record.findings.map(f => {
23
+ const d = byId.get(f.id) || {};
24
+ const tierOverride = d.tierOverride || f.tierOverride || null;
25
+ return {
26
+ id: f.id, raiser: f.raiser, severity: f.severity,
27
+ tier: tierOverride ? tierOverride.to : f.tier,
28
+ basis: f.basis, confidence: f.confidence, tierOverride,
29
+ duplicateOf: d.duplicateOf || null,
30
+ adjudications: f.adjudications,
31
+ decision: d.decision || null,
32
+ applied: d.applied === true,
33
+ };
34
+ }),
35
+ streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly })),
36
+ runStats: record.runStats,
37
+ tierCounts: record.tierCounts,
38
+ };
39
+ }
40
+
41
+ /** Atomic write: tmp + rename (matches the repo's wave.json convention). */
42
+ function writeVerdictAtomic(filePath, verdict) {
43
+ const tmp = `${filePath}.tmp-${process.pid}`;
44
+ fs.writeFileSync(tmp, JSON.stringify(verdict, null, 2));
45
+ fs.renameSync(tmp, filePath);
46
+ }
47
+
48
+ module.exports = { buildVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };