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.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +19 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +22 -1
- package/README.md +12 -2
- package/bin/amicus.js +13 -161
- package/package.json +6 -4
- package/scripts/postinstall.js +16 -7
- package/skills/second-opinion/COUNCIL-DESIGN.md +34 -34
- package/skills/second-opinion/MODEL-NOTES.md +23 -17
- package/skills/second-opinion/SKILL.md +77 -45
- package/src/cli-handlers-council.js +59 -0
- package/src/cli-handlers-doctor.js +173 -0
- package/src/cli-handlers-run.js +196 -0
- package/src/cli-handlers.js +1 -1
- package/src/cli.js +11 -2
- package/src/council/findings.js +48 -0
- package/src/council/ledger.js +82 -0
- package/src/council/tally.js +108 -0
- package/src/council/verdict.js +48 -0
- package/src/headless.js +43 -149
- package/src/mcp-server.js +6 -0
- package/src/sidecar/budget.js +83 -0
- package/src/sidecar/conversation-mirror.js +128 -0
- package/src/sidecar/fanout-leg.js +4 -1
- package/src/sidecar/fanout.js +34 -7
- package/src/sidecar/interactive-mirror.js +66 -0
- package/src/sidecar/interactive.js +35 -21
- package/src/sidecar/models.js +10 -9
- package/src/sidecar/session-finalize.js +26 -0
- package/src/sidecar/session-utils.js +5 -5
- package/src/sidecar/setup.js +2 -2
- package/src/sidecar/start.js +19 -6
- package/src/utils/activity-poller.js +47 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/config.js +4 -4
- package/src/utils/error-doc.js +55 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/pricing.js +93 -0
- package/src/utils/result-schema.js +21 -2
- package/src/utils/session-abort.js +40 -13
- package/src/utils/validators.js +17 -17
- /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 };
|
package/src/cli-handlers.js
CHANGED
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'
|
|
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 };
|