amicus 4.0.1 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +169 -0
- package/README.md +4 -4
- package/commands/council.md +6 -6
- package/package.json +3 -2
- package/schemas/council-run.schema.json +15 -1
- package/schemas/council-tally.schema.json +10 -1
- package/schemas/council-verdict.schema.json +10 -1
- package/schemas/error.schema.json +1 -1
- package/scripts/postinstall.js +6 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +40 -0
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +266 -0
- package/skills/second-opinion/MODEL-NOTES.md +21 -0
- package/skills/second-opinion/SEAT-BRIEFS.md +4 -0
- package/skills/second-opinion/SKILL.md +319 -333
- package/skills/sidecar/SKILL.md +5 -5
- package/src/cli-handlers-council-run.js +9 -0
- package/src/cli-handlers-council.js +20 -2
- package/src/cli.js +8 -0
- package/src/council/briefings-debate.js +158 -0
- package/src/council/briefings-stage2.js +16 -9
- package/src/council/debate.js +98 -0
- package/src/council/ledger.js +2 -1
- package/src/council/parse-stage2.js +83 -1
- package/src/council/report-html.js +28 -1
- package/src/council/report.js +64 -4
- package/src/council/run-assemble.js +91 -9
- package/src/council/run-chair.js +145 -0
- package/src/council/run-debate.js +293 -0
- package/src/council/run-launch.js +27 -1
- package/src/council/run-stages.js +19 -7
- package/src/council/run.js +104 -110
- package/src/council/verdict.js +43 -2
- package/src/mcp-council-run.js +7 -0
- package/src/mcp-server.js +28 -3
- package/src/mcp-tools.js +24 -4
- package/src/utils/curated-models.js +22 -20
- package/src/utils/error-doc.js +2 -0
- package/src/utils/model-fetcher.js +6 -0
package/src/council/report.js
CHANGED
|
@@ -20,7 +20,19 @@ function toModel(verdict, wave) {
|
|
|
20
20
|
if (!verdict || !Array.isArray(verdict.findings)) {
|
|
21
21
|
throw new Error('verdict.json must have a findings[] array');
|
|
22
22
|
}
|
|
23
|
-
const
|
|
23
|
+
const council = verdict.council || [];
|
|
24
|
+
// 'council' (verdict.council / meta.models) is the street-cred universe and
|
|
25
|
+
// legitimately includes 'claude' on a --claude-review run (run-assemble.js:
|
|
26
|
+
// 123-125, docs/council.md:326). 'judges' is the adjudication-matrix column
|
|
27
|
+
// set: SKILL.md:482 / run-stages.js:162-163 guarantee Claude is judged but
|
|
28
|
+
// never judges, so its reserved seat must never grow a matrix column — filter
|
|
29
|
+
// it out ONLY when claudeInCouncil is true. This is name+flag gated, not
|
|
30
|
+
// vote-derived: a bench judge that cast zero adjudications (dead/unstructured
|
|
31
|
+
// leg, run-stages.js:204-207) is still in council/judges and must still get
|
|
32
|
+
// its (blank) column — deriving the roster from "who actually voted" would
|
|
33
|
+
// silently delete that column too and break the byte-unchanged-artifact
|
|
34
|
+
// contract for degraded v4.0.1-shaped runs.
|
|
35
|
+
const judges = verdict.claudeInCouncil === true ? council.filter(j => j !== 'claude') : council;
|
|
24
36
|
const findings = verdict.findings.map((f) => {
|
|
25
37
|
const byJudge = {};
|
|
26
38
|
for (const j of judges) { byJudge[j] = null; }
|
|
@@ -28,9 +40,30 @@ function toModel(verdict, wave) {
|
|
|
28
40
|
return {
|
|
29
41
|
id: f.id, severity: f.severity, raiser: f.raiser, tier: f.tier,
|
|
30
42
|
basis: f.basis || { a: 0, d: 0, n: 0 }, decision: f.decision || null,
|
|
31
|
-
applied: f.applied === true, byJudge,
|
|
43
|
+
applied: f.applied === true, byJudge, debate: f.debate || null,
|
|
32
44
|
};
|
|
33
45
|
});
|
|
46
|
+
// 'movements' is deliberately re-vote-only (defended/amended): a withdrawn or
|
|
47
|
+
// no-response finding is never bundled into the re-vote (run-debate.js's
|
|
48
|
+
// bundleFor()), so its tier — even if it happens to differ from previousTier —
|
|
49
|
+
// was never "moved after re-vote". Listing it there would read as "still live,
|
|
50
|
+
// just downgraded" when it was actually retracted; withdrawn findings get their
|
|
51
|
+
// own list below so a reader can tell the two apart.
|
|
52
|
+
// no-response findings (spec §5.7: a dead defense leg, or one still
|
|
53
|
+
// unstructured after its single repair, makes that raiser's bundled
|
|
54
|
+
// findings all 'no-response') get their own list, same idiom as withdrawn —
|
|
55
|
+
// silently dropping them would leave a "## Debate round" heading with
|
|
56
|
+
// nothing beneath it whenever a run's only debating raiser never responded.
|
|
57
|
+
const debate = {
|
|
58
|
+
present: findings.some(f => f.debate) === true,
|
|
59
|
+
withdrawn: verdict.findings.filter(f => f.debate && f.debate.action === 'withdrawn')
|
|
60
|
+
.map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
|
|
61
|
+
movements: verdict.findings.filter(f => f.debate && f.debate.action !== 'withdrawn' && f.debate.action !== 'no-response'
|
|
62
|
+
&& f.debate.previousTier && f.debate.previousTier !== f.tier)
|
|
63
|
+
.map(f => ({ id: f.id, action: f.debate.action, previousTier: f.debate.previousTier, tier: f.tier })),
|
|
64
|
+
noResponse: verdict.findings.filter(f => f.debate && f.debate.action === 'no-response')
|
|
65
|
+
.map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
|
|
66
|
+
};
|
|
34
67
|
const runStats = verdict.runStats || [];
|
|
35
68
|
const costRows = runStats.map(r => ({
|
|
36
69
|
model: r.model, status: r.status, durationMs: r.durationMs,
|
|
@@ -40,10 +73,10 @@ function toModel(verdict, wave) {
|
|
|
40
73
|
return {
|
|
41
74
|
header: {
|
|
42
75
|
runType: verdict.runType || 'review', runId: verdict.runId, date: verdict.date,
|
|
43
|
-
chair: verdict.chair, council
|
|
76
|
+
chair: verdict.chair, council, claudeInCouncil: verdict.claudeInCouncil === true,
|
|
44
77
|
},
|
|
45
78
|
tierCounts: verdict.tierCounts || { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 },
|
|
46
|
-
judges, findings,
|
|
79
|
+
judges, findings, debate,
|
|
47
80
|
streetCred: verdict.streetCred || [],
|
|
48
81
|
cost: { rows: costRows, total },
|
|
49
82
|
};
|
|
@@ -92,6 +125,33 @@ function renderMd(m) {
|
|
|
92
125
|
out.push('');
|
|
93
126
|
}
|
|
94
127
|
|
|
128
|
+
// Defensive: never emit the heading unless at least one grouping has
|
|
129
|
+
// content — a heading over nothing is worse than no heading.
|
|
130
|
+
if (m.debate.present && (m.debate.withdrawn.length || m.debate.movements.length || m.debate.noResponse.length)) {
|
|
131
|
+
out.push('\n## Debate round\n');
|
|
132
|
+
if (m.debate.withdrawn.length) {
|
|
133
|
+
out.push('**Withdrawn by raiser:**');
|
|
134
|
+
for (const w of m.debate.withdrawn) {
|
|
135
|
+
const arrow = w.previousTier && w.previousTier !== w.tier ? `${w.previousTier} → ${w.tier}` : (w.previousTier || w.tier);
|
|
136
|
+
out.push(`- ${w.id}: ${arrow} (withdrawn — no longer live)`);
|
|
137
|
+
}
|
|
138
|
+
out.push('');
|
|
139
|
+
}
|
|
140
|
+
if (m.debate.movements.length) {
|
|
141
|
+
out.push('**Tier movements after re-vote:**');
|
|
142
|
+
for (const mv of m.debate.movements) { out.push(`- ${mv.id}: ${mv.previousTier} → ${mv.tier} (${mv.action})`); }
|
|
143
|
+
out.push('');
|
|
144
|
+
}
|
|
145
|
+
if (m.debate.noResponse.length) {
|
|
146
|
+
out.push('**No response (raiser did not defend):**');
|
|
147
|
+
for (const nr of m.debate.noResponse) {
|
|
148
|
+
const arrow = nr.previousTier && nr.previousTier !== nr.tier ? `${nr.previousTier} → ${nr.tier}` : (nr.previousTier || nr.tier);
|
|
149
|
+
out.push(`- ${nr.id}: ${arrow} (no response — original stands)`);
|
|
150
|
+
}
|
|
151
|
+
out.push('');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
95
155
|
out.push('## Cost\n');
|
|
96
156
|
out.push('| Model | Status | Duration | Cost |\n|---|---|---|---|');
|
|
97
157
|
for (const r of m.cost.rows) { out.push(`| ${r.model} | ${r.status} | ${fmtDur(r.durationMs)} | ${formatCost(r.cost)} |`); }
|
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @module council/run-assemble
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* chair-output.md)
|
|
12
|
-
*
|
|
6
|
+
* Assembly + artifact emission for the headless council engine (spec §5), plus
|
|
7
|
+
* the `--claude-review` pre-flight it now also owns: the five-keys tally input
|
|
8
|
+
* (meta pins: claudeInCouncil false, models = bench seats exactly — critic
|
|
9
|
+
* included, chair excluded — runType 'headless'), runStats rows copied
|
|
10
|
+
* verbatim from leg docs, the run-dir artifact set (tally-input.json,
|
|
11
|
+
* tally.json, verdict.json with overallVerdict, report.html, chair-output.md),
|
|
12
|
+
* v4.1 §4.4 pre-flight validation of the file-sourced Claude review
|
|
13
|
+
* (preflightClaudeReview — the reserved-seat/chair/critic guards), its review-N+1
|
|
14
|
+
* labelling (labelClaudeReview), and its synthesized null-usage runStats row
|
|
15
|
+
* (claudeRunStatsRow). Raiser self-votes are INCLUDED in adjudications —
|
|
16
|
+
* exclusion is tally's job (tally.js:95); judged is tally's job (tally.js:110).
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
const fs = require('fs');
|
|
@@ -17,9 +21,15 @@ const path = require('path');
|
|
|
17
21
|
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
18
22
|
const { buildVerdict, writeVerdictAtomic } = require('./verdict');
|
|
19
23
|
const { buildReport } = require('./report');
|
|
24
|
+
const { validateFindings } = require('./findings');
|
|
25
|
+
const { toGlobalFindings } = require('./anonymize');
|
|
20
26
|
|
|
21
27
|
const CONFORMANCE_RANK = { clean: 0, repaired: 1, unstructured: 2 };
|
|
22
28
|
|
|
29
|
+
/** v4.1 §4.4: the reserved seat name for the file-sourced Claude review. */
|
|
30
|
+
const CLAUDE_SEAT = 'claude';
|
|
31
|
+
const CLAUDE_REVIEW_ERROR = 'COUNCIL_CLAUDE_REVIEW_INVALID';
|
|
32
|
+
|
|
23
33
|
/** Worst-wins merge of Stage-1 findings conformance and Stage-2 judge conformance. */
|
|
24
34
|
function worseConformance(a, b) {
|
|
25
35
|
return (CONFORMANCE_RANK[a] || 0) >= (CONFORMANCE_RANK[b] || 0) ? a : b;
|
|
@@ -43,14 +53,79 @@ function buildRunStatsEntry({ leg, model, role, wasChair, conformance }) {
|
|
|
43
53
|
};
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Pre-flight for `--claude-review <path>` (v4.1 §4.4). Runs AFTER initRun (so the
|
|
58
|
+
* error doc lands in a run dir that exists) and BEFORE any launch, so an invalid
|
|
59
|
+
* file costs zero spend. The orchestrator authored the file, so there is no repair
|
|
60
|
+
* loop — fix and relaunch is free.
|
|
61
|
+
* @param {{claudeReviewFile: ?string, chair: ?string, critic: ?string, models: ?Array<string>}} o run options
|
|
62
|
+
* @returns {{claudeReview: object|null, error: ?{code: string, message: string}}}
|
|
63
|
+
*/
|
|
64
|
+
function preflightClaudeReview(o) {
|
|
65
|
+
if (!o.claudeReviewFile) { return { claudeReview: null, error: null }; }
|
|
66
|
+
const bad = (detail) => ({ claudeReview: null,
|
|
67
|
+
error: { code: CLAUDE_REVIEW_ERROR, message: `council_claude_review_invalid: ${detail}` } });
|
|
68
|
+
if (o.chair === CLAUDE_SEAT) {
|
|
69
|
+
return bad('claude may not chair (it is judged, never votes or chairs)');
|
|
70
|
+
}
|
|
71
|
+
if (o.critic === CLAUDE_SEAT) {
|
|
72
|
+
return bad("'claude' is a reserved seat name and cannot be the critic");
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(o.models) && o.models.includes(CLAUDE_SEAT)) {
|
|
75
|
+
// 'claude' is a reserved seat name for the file-sourced review N+1 (it
|
|
76
|
+
// joins meta.models synthetically — see buildTallyInput). A real bench
|
|
77
|
+
// leg ALSO named 'claude' would collide on that same key: labelMap gets
|
|
78
|
+
// two 'claude' entries, and ledger.js's Map join lets the synthesized
|
|
79
|
+
// claude row overwrite the real leg's role/conformance/wasChair and
|
|
80
|
+
// double-count findingsRaised — permanently, since the ledger is
|
|
81
|
+
// append-only. Reject it here so every entry point (CLI, MCP, GitHub
|
|
82
|
+
// Action, direct require('./council/run')) is covered, not just the
|
|
83
|
+
// CLI's option whitelist.
|
|
84
|
+
return bad("'claude' is a reserved seat name and cannot also appear in --models");
|
|
85
|
+
}
|
|
86
|
+
let text = '';
|
|
87
|
+
try { text = fs.readFileSync(o.claudeReviewFile, 'utf-8'); }
|
|
88
|
+
catch (e) { return bad(`cannot read ${o.claudeReviewFile}: ${e.message}`); }
|
|
89
|
+
const v = validateFindings(text);
|
|
90
|
+
if (!v.ok) { return bad(v.errors.map(e => `${e.code}: ${e.detail}`).join('; ')); }
|
|
91
|
+
return { claudeReview: { model: CLAUDE_SEAT, text, findings: v.findings }, error: null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stamp the file-sourced Claude review with its label (always the LAST entry —
|
|
96
|
+
* review N+1) and its run-global finding ids. Mutates + returns the new ids so
|
|
97
|
+
* run.js can concat them onto the bench's globalFindings in one expression.
|
|
98
|
+
* @returns {Array<object>} claude's run-global findings
|
|
99
|
+
*/
|
|
100
|
+
function labelClaudeReview(claudeReview, labels) {
|
|
101
|
+
const e = labels.entries[labels.entries.length - 1];
|
|
102
|
+
claudeReview.label = e.label;
|
|
103
|
+
claudeReview.globalFindings = toGlobalFindings(e.letter, CLAUDE_SEAT, claudeReview.findings);
|
|
104
|
+
return claudeReview.globalFindings;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The synthesized runStats row for a review that never ran a leg (v4.1 §4.4).
|
|
109
|
+
* durationMs/usage are null per the never-invent rule — nothing was launched.
|
|
110
|
+
*/
|
|
111
|
+
function claudeRunStatsRow() {
|
|
112
|
+
return { model: CLAUDE_SEAT, role: CLAUDE_SEAT, wasChair: false, conformance: 'clean',
|
|
113
|
+
status: 'complete', durationMs: null, usage: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
46
116
|
/**
|
|
47
117
|
* Assemble the five-keys tally input (spec §5 / SKILL.md Stage-2 recipe).
|
|
48
118
|
* @param {{runId: string, date: string, bench: string[], chair: string,
|
|
49
119
|
* reviews: Array<{model, role, conformance, leg, globalFindings}>,
|
|
50
120
|
* judgeResults: Array<{judge, ok, order, adjudications}>,
|
|
51
|
-
* chairStats: object|null}} args
|
|
121
|
+
* chairStats: object|null, claudeReview?: object|null}} args
|
|
122
|
+
* `claudeReview` (v4.1 §4.4) amends the v4.0 meta pin: present ⇒ claudeInCouncil
|
|
123
|
+
* true, 'claude' joins meta.models (the street-cred universe), its findings join
|
|
124
|
+
* the pool and it gets the synthesized null-usage runStats row. Absent ⇒ v4.0
|
|
125
|
+
* output byte-for-byte.
|
|
52
126
|
*/
|
|
53
|
-
function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, chairStats
|
|
127
|
+
function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, chairStats,
|
|
128
|
+
claudeReview }) {
|
|
54
129
|
const meta = {
|
|
55
130
|
runId, date, runType: 'headless',
|
|
56
131
|
models: bench.slice(), // bench seats exactly: critic included, chair excluded
|
|
@@ -65,6 +140,12 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
|
|
|
65
140
|
const runStats = reviews.map(r => buildRunStatsEntry({
|
|
66
141
|
leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
|
|
67
142
|
}));
|
|
143
|
+
if (claudeReview) {
|
|
144
|
+
meta.models.push(CLAUDE_SEAT); // last, mirroring its review-N+1 label
|
|
145
|
+
meta.claudeInCouncil = true;
|
|
146
|
+
findings.push(...claudeReview.globalFindings);
|
|
147
|
+
runStats.push(claudeRunStatsRow());
|
|
148
|
+
}
|
|
68
149
|
if (chairStats) { runStats.push(chairStats); }
|
|
69
150
|
return { meta, findings, adjudications, rankings, runStats };
|
|
70
151
|
}
|
|
@@ -97,4 +178,5 @@ function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
|
|
|
97
178
|
|
|
98
179
|
module.exports = {
|
|
99
180
|
buildRunStatsEntry, worseConformance, buildTallyInput, writeTallyFiles, writeVerdictFiles,
|
|
181
|
+
preflightClaudeReview, labelClaudeReview, claudeRunStatsRow, CLAUDE_SEAT,
|
|
100
182
|
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/council/run-chair.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run-chair
|
|
6
|
+
* Chair synthesis + VERDICT-line repair for the headless council driver,
|
|
7
|
+
* lifted VERBATIM out of run.js for the 300-line gate (v4.1 Task 0.5). Pure
|
|
8
|
+
* refactor: same launches, same waveIds, same run.json checkpoints, same
|
|
9
|
+
* degradation rules. Launchers come in through `ctx` exactly as run-stages.js
|
|
10
|
+
* takes them; run.js keeps the chair-packet build, the tally sequencing, the
|
|
11
|
+
* signal bookkeeping and finalize().
|
|
12
|
+
*
|
|
13
|
+
* `isSignalled` is a GETTER, not a snapshot: run.js's signal handler mutates
|
|
14
|
+
* its `signalled` local between awaits and the v4.0 code re-read it after every
|
|
15
|
+
* chair launch. Passing the value instead would silently drop those aborts.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const stage2 = require('./briefings-stage2');
|
|
19
|
+
const { parseChairVerdict } = require('./parse-stage2');
|
|
20
|
+
const runState = require('./run-state');
|
|
21
|
+
const { isAbortExit } = require('./run-stages');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Chair fallback promotion (spec §4): the highest peers-only street-cred
|
|
25
|
+
* model from `council stats` that is not a bench seat and not the failed
|
|
26
|
+
* chair. "Highest street-cred" = BEST = numerically LOWEST mean rank
|
|
27
|
+
* (deriveReliability's avgStreetCredPeersOnly; lower is better).
|
|
28
|
+
*
|
|
29
|
+
* The reserved seat name 'claude' is never eligible (v4.1 §4.4 "never chairs"):
|
|
30
|
+
* a --claude-review run puts a real 'claude' row in the ledger, so without this
|
|
31
|
+
* filter a LATER run could promote it and walk straight past the pre-flight
|
|
32
|
+
* --chair claude guard — with no Claude leg to launch.
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
function pickFallbackChair(statsRows, bench, failedChair) {
|
|
36
|
+
const benchSet = new Set(bench);
|
|
37
|
+
const candidates = (statsRows || [])
|
|
38
|
+
.filter(r => r.model !== 'claude' && !benchSet.has(r.model) && r.model !== failedChair
|
|
39
|
+
&& typeof r.avgStreetCredPeersOnly === 'number')
|
|
40
|
+
.sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
|
|
41
|
+
return candidates.length ? candidates[0].model : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Chair chain (attempt → retry → ledger-promoted fallback → give up) plus the
|
|
46
|
+
* single VERDICT-line repair re-prompt.
|
|
47
|
+
* @param {object} ctx run.js's {o, launchers, addWave, overBudget, scratchDir}
|
|
48
|
+
* @param {{packet: string, degraded: {value: boolean}, statsFn: Function,
|
|
49
|
+
* isSignalled: function(): (number|null)}} args
|
|
50
|
+
* @returns {Promise<{aborted: number|null, chairLeg: object|null,
|
|
51
|
+
* actualChair: string|null, chairText: string|null,
|
|
52
|
+
* chairConformance: string, overallVerdict: string|null}>}
|
|
53
|
+
*/
|
|
54
|
+
async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
|
|
55
|
+
const { o, launchers, addWave, overBudget } = ctx;
|
|
56
|
+
const now = () => new Date().toISOString();
|
|
57
|
+
const bail = (code) => ({
|
|
58
|
+
aborted: code, chairLeg: null, actualChair: null, chairText: null,
|
|
59
|
+
chairConformance: 'clean', overallVerdict: null,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const attemptChair = async (model, waveId) => {
|
|
63
|
+
runState.appendStageWave(o.runDir, 'chair', waveId);
|
|
64
|
+
const solo = await launchers.launchSolo({
|
|
65
|
+
model, prompt: packet, project: o.runDir, waveId,
|
|
66
|
+
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
67
|
+
// v4.1 §4.5d: the chair chain (ch1/ch2/ch3) and the ch4 VERDICT repair
|
|
68
|
+
// below are the launches a re-armed price gate would refuse LAST, after
|
|
69
|
+
// the whole bench has already been paid for.
|
|
70
|
+
noCostGate: o.noCostGate,
|
|
71
|
+
});
|
|
72
|
+
addWave(solo.wave);
|
|
73
|
+
const ok = solo.leg && solo.leg.status === 'complete'
|
|
74
|
+
&& solo.leg.summary && solo.leg.summary.trim();
|
|
75
|
+
return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let chairLeg = null;
|
|
79
|
+
let actualChair = null;
|
|
80
|
+
if (overBudget()) {
|
|
81
|
+
// Ceiling hit after the tally is computable: skip the chair, write the
|
|
82
|
+
// verdict with overallVerdict null, exit 2 (spec §4 degradation table).
|
|
83
|
+
// Never abort in-flight legs for cost — this only stops NEW launches.
|
|
84
|
+
degraded.value = true;
|
|
85
|
+
runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
|
|
86
|
+
} else {
|
|
87
|
+
runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
|
|
88
|
+
// Fallback chain (spec §4): retry same chair once → promote best
|
|
89
|
+
// non-bench model from the ledger → give up (no Claude fallback headless).
|
|
90
|
+
let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
|
|
91
|
+
if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
|
|
92
|
+
if (!attempt.leg && !overBudget()) {
|
|
93
|
+
attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
|
|
94
|
+
if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
|
|
95
|
+
}
|
|
96
|
+
if (attempt.leg) { actualChair = o.chair; }
|
|
97
|
+
else if (!overBudget()) {
|
|
98
|
+
let statsRows = [];
|
|
99
|
+
try { statsRows = statsFn(); } catch { /* no ledger yet */ }
|
|
100
|
+
const fallback = pickFallbackChair(statsRows, o.models, o.chair);
|
|
101
|
+
if (fallback) {
|
|
102
|
+
attempt = await attemptChair(fallback, `${o.runId}-ch3`);
|
|
103
|
+
if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
|
|
104
|
+
if (attempt.leg) { actualChair = fallback; }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
chairLeg = attempt.leg;
|
|
108
|
+
runState.updateStage(o.runDir, 'chair',
|
|
109
|
+
{ status: chairLeg ? 'complete' : 'error', completedAt: now() });
|
|
110
|
+
// The chair chain may have promoted a fallback (or given up) — checkpoint
|
|
111
|
+
// the ACTUAL chair into run.json now so status/`--json`/the human summary
|
|
112
|
+
// never report the originally-requested chair after a promotion. Mirrors
|
|
113
|
+
// mkInput's actualChair || o.chair (a give-up with no actual chair keeps
|
|
114
|
+
// the requested chair).
|
|
115
|
+
runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
|
|
116
|
+
}
|
|
117
|
+
const chairText = chairLeg ? chairLeg.summary : null;
|
|
118
|
+
let chairConformance = 'clean';
|
|
119
|
+
|
|
120
|
+
// ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
|
|
121
|
+
let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
|
|
122
|
+
if (chairText && !overallVerdict && !overBudget()) {
|
|
123
|
+
runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
|
|
124
|
+
const repair = await launchers.launchSolo({
|
|
125
|
+
model: actualChair, prompt: stage2.buildChairRepairPrompt(),
|
|
126
|
+
project: o.runDir, waveId: `${o.runId}-ch4`,
|
|
127
|
+
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
128
|
+
noCostGate: o.noCostGate,
|
|
129
|
+
});
|
|
130
|
+
addWave(repair.wave);
|
|
131
|
+
if (isAbortExit(repair.exitCode) || isSignalled()) { return bail(repair.exitCode || isSignalled()); }
|
|
132
|
+
overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
|
|
133
|
+
chairConformance = overallVerdict ? 'repaired' : 'unstructured';
|
|
134
|
+
}
|
|
135
|
+
// A completed chair whose verdict never parsed is 'unstructured' even when
|
|
136
|
+
// the repair was skipped (e.g. the chair leg itself tripped --max-cost).
|
|
137
|
+
if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
|
|
138
|
+
if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
aborted: null, chairLeg, actualChair, chairText, chairConformance, overallVerdict,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { runChair, pickFallbackChair };
|