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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +169 -0
  3. package/README.md +4 -4
  4. package/commands/council.md +6 -6
  5. package/package.json +3 -2
  6. package/schemas/council-run.schema.json +15 -1
  7. package/schemas/council-tally.schema.json +10 -1
  8. package/schemas/council-verdict.schema.json +10 -1
  9. package/schemas/error.schema.json +1 -1
  10. package/scripts/postinstall.js +6 -3
  11. package/skills/second-opinion/COUNCIL-DESIGN.md +40 -0
  12. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +266 -0
  13. package/skills/second-opinion/MODEL-NOTES.md +21 -0
  14. package/skills/second-opinion/SEAT-BRIEFS.md +4 -0
  15. package/skills/second-opinion/SKILL.md +319 -333
  16. package/skills/sidecar/SKILL.md +5 -5
  17. package/src/cli-handlers-council-run.js +9 -0
  18. package/src/cli-handlers-council.js +20 -2
  19. package/src/cli.js +8 -0
  20. package/src/council/briefings-debate.js +158 -0
  21. package/src/council/briefings-stage2.js +16 -9
  22. package/src/council/debate.js +98 -0
  23. package/src/council/ledger.js +2 -1
  24. package/src/council/parse-stage2.js +83 -1
  25. package/src/council/report-html.js +28 -1
  26. package/src/council/report.js +64 -4
  27. package/src/council/run-assemble.js +91 -9
  28. package/src/council/run-chair.js +145 -0
  29. package/src/council/run-debate.js +293 -0
  30. package/src/council/run-launch.js +27 -1
  31. package/src/council/run-stages.js +19 -7
  32. package/src/council/run.js +104 -110
  33. package/src/council/verdict.js +43 -2
  34. package/src/mcp-council-run.js +7 -0
  35. package/src/mcp-server.js +28 -3
  36. package/src/mcp-tools.js +24 -4
  37. package/src/utils/curated-models.js +22 -20
  38. package/src/utils/error-doc.js +2 -0
  39. package/src/utils/model-fetcher.js +6 -0
@@ -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 judges = verdict.council || [];
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: judges, claudeInCouncil: verdict.claudeInCouncil === true,
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
- * Pure assembly + artifact emission for the headless council engine (spec §5):
7
- * the five-keys tally input (meta pins: claudeInCouncil false, models = bench
8
- * seats exactly critic included, chair excludedrunType 'headless'),
9
- * runStats rows copied verbatim from leg docs, and the run-dir artifact set
10
- * (tally-input.json, tally.json, verdict.json with overallVerdict, report.html,
11
- * chair-output.md). Raiser self-votes are INCLUDED in adjudications — exclusion
12
- * is tally's job (tally.js:95); judged is tally's job (tally.js:110).
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 };