amicus 4.7.0 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- package/src/workspace/seat-space.js +143 -0
|
@@ -12,42 +12,41 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const dbrief = require('./briefings-debate');
|
|
15
|
-
const { parseDebateDefense
|
|
15
|
+
const { parseDebateDefense } = require('./parse-stage2');
|
|
16
16
|
const { applyDebate, debateRunStatsRows, PAST_TENSE,
|
|
17
17
|
allNoResponse, nothingToDebate, disputingJudges, debateTargets, bundleFor } = require('./debate');
|
|
18
18
|
const { materializeDebate } = require('./run-launch');
|
|
19
|
+
const { buildSeats } = require('./seats');
|
|
19
20
|
const { tally } = require('./tally');
|
|
20
21
|
const { isAbortExit } = require('./run-stages');
|
|
21
22
|
const runState = require('./run-state');
|
|
22
|
-
const {
|
|
23
|
-
|
|
24
|
-
/** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
|
|
25
|
-
function legOpts(ctx, waveId) {
|
|
26
|
-
return { project: ctx.scratchDir, waveId, timeout: ctx.o.timeout, gateway: ctx.o.gateway,
|
|
27
|
-
noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate,
|
|
28
|
-
// v4.3 Task 3 (spec §7.2): attribution ids for every defense/re-vote leg.
|
|
29
|
-
councilRunId: ctx.o.runId, councilName: ctx.o.councilName,
|
|
30
|
-
tag: ctx.o.tag }; // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
|
|
31
|
-
}
|
|
23
|
+
const { legOpts, legRow, runRevoteWave } = require('./run-debate-revote');
|
|
32
24
|
|
|
33
25
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
26
|
+
* One raiser's defense solo.
|
|
27
|
+
*
|
|
28
|
+
* v4.8 PR3 Task 6 — the raiser boundary. `raiserKey` is a SEAT key (debateTargets
|
|
29
|
+
* keys byRaiser on `f.raiserSeat || f.raiser`), so it is projected ONCE, here, to
|
|
30
|
+
* the routable bench alias. ⚠️ `aliasOf` is a `const` local to `runDebate`; this
|
|
31
|
+
* is a module-scope function, so under 'use strict' a bare `aliasOf(...)` would
|
|
32
|
+
* be a ReferenceError on the first defense solo of every debate run — it is
|
|
33
|
+
* threaded explicitly instead.
|
|
34
|
+
*
|
|
35
|
+
* `raiserKey` survives in exactly two places here: the returned `{raiser}` (which
|
|
36
|
+
* becomes defenseByRaiser's key — §3.3: that MUST stay seat-keyed, because
|
|
37
|
+
* re-keying it to the alias is last-wins and silently drops one twin's entire
|
|
38
|
+
* debate row plus its amended claim) and the `seat` runDebate derives from it.
|
|
39
|
+
* Everything else — both launch sites, both legRow calls, the stub and the
|
|
40
|
+
* returned leg's `model` — is `raiserAlias`.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} ctx
|
|
43
|
+
* @param {string} raiserKey seat key of the raiser
|
|
44
|
+
* @param {Array<object>} findings that raiser's contested/disputed findings
|
|
45
|
+
* @param {number} idx defense-wave position (names the waveId)
|
|
46
|
+
* @param {function(string): string} aliasOf seat key → bench alias
|
|
40
47
|
*/
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
? { model, status: leg.status, durationMs: typeof leg.durationMs === 'number' ? leg.durationMs : null,
|
|
44
|
-
usage: leg.usage || null, conformance, summary: leg.summary || '',
|
|
45
|
-
...(leg.waveId ? { waveId: leg.waveId } : {}),
|
|
46
|
-
...(leg.model ? { resolvedModel: leg.model } : {}) }
|
|
47
|
-
: { model, status: 'error', durationMs: null, usage: null, conformance, summary: '' };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function runDefenseSolo(ctx, raiser, findings, idx) {
|
|
48
|
+
async function runDefenseSolo(ctx, raiserKey, findings, idx, aliasOf) {
|
|
49
|
+
const raiserAlias = aliasOf(raiserKey);
|
|
51
50
|
const brief = dbrief.buildDefenseBrief({ findings, date: ctx.o.date });
|
|
52
51
|
const waveId = `${ctx.o.runId}-d${idx + 1}`;
|
|
53
52
|
const expectedIds = findings.map(f => f.id);
|
|
@@ -56,9 +55,9 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
|
|
|
56
55
|
// launch leaves an in-flight leg reachable only by the pid kill. The v4.0.1
|
|
57
56
|
// abort-cascade fix must hold for debate stages too.
|
|
58
57
|
runState.appendStageWave(ctx.o.runDir, 'debate-defense', waveId);
|
|
59
|
-
const res = await ctx.launchers.launchSolo({ ...legOpts(ctx, waveId), model:
|
|
58
|
+
const res = await ctx.launchers.launchSolo({ ...legOpts(ctx, waveId), model: raiserAlias, prompt: brief });
|
|
60
59
|
ctx.addWave(res.wave);
|
|
61
|
-
if (isAbortExit(res.exitCode)) { return { raiser, aborted: res.exitCode }; }
|
|
60
|
+
if (isAbortExit(res.exitCode)) { return { raiser: raiserKey, aborted: res.exitCode }; }
|
|
62
61
|
let leg = res.leg && res.leg.status === 'complete' ? res.leg : null;
|
|
63
62
|
// A dead leg gets the SAME spec §5.7 fallback the parser applies to a block-level
|
|
64
63
|
// failure — every expected id 'no-response', never an empty map, so the
|
|
@@ -76,84 +75,28 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
|
|
|
76
75
|
const repairId = `${waveId}r`;
|
|
77
76
|
runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
|
|
78
77
|
const res2 = await ctx.launchers.launchSolo({
|
|
79
|
-
...legOpts(ctx, repairId), model:
|
|
78
|
+
...legOpts(ctx, repairId), model: raiserAlias,
|
|
80
79
|
// ⚠️ LC-12: a repair solo is a fresh session — the defense that failed rides along.
|
|
81
80
|
prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors, defense: leg.summary }),
|
|
82
81
|
});
|
|
83
82
|
ctx.addWave(res2.wave);
|
|
84
|
-
if (isAbortExit(res2.exitCode)) { return { raiser, aborted: res2.exitCode }; }
|
|
83
|
+
if (isAbortExit(res2.exitCode)) { return { raiser: raiserKey, aborted: res2.exitCode }; }
|
|
85
84
|
const leg2 = res2.leg && res2.leg.status === 'complete' ? res2.leg : null;
|
|
86
85
|
parsed = leg2 ? parseDebateDefense(leg2.summary, expectedIds) : parsed;
|
|
87
86
|
conformance = parsed.ok ? 'repaired' : 'unstructured';
|
|
88
|
-
if (leg2) { supersededLeg = legRow(
|
|
89
|
-
else { repairLeg = legRow(
|
|
87
|
+
if (leg2) { supersededLeg = legRow(raiserAlias, leg, 'unstructured'); leg = leg2; }
|
|
88
|
+
else { repairLeg = legRow(raiserAlias, res2.leg, 'unstructured'); }
|
|
90
89
|
}
|
|
91
90
|
// A dead leg (no complete summary) OR an 'unstructured' conformance after the one
|
|
92
91
|
// repair is a debate degradation (spec §5.7) — surfaced via the returned leg.
|
|
93
|
-
const stub = { model:
|
|
94
|
-
return { raiser, byId: parsed.byId,
|
|
95
|
-
leg: leg ? { model:
|
|
92
|
+
const stub = { model: raiserAlias, status: 'error', durationMs: null, usage: null, conformance: 'unstructured', summary: '' };
|
|
93
|
+
return { raiser: raiserKey, byId: parsed.byId,
|
|
94
|
+
leg: leg ? { model: raiserAlias, status: leg.status, durationMs: leg.durationMs, usage: leg.usage,
|
|
96
95
|
conformance, summary: leg.summary, waveId: leg.waveId,
|
|
97
96
|
...(leg.model ? { resolvedModel: leg.model } : {}) } : stub,
|
|
98
97
|
supersededLeg, repairLeg };
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
async function runRevoteWave(ctx, judges, bundleFindings) {
|
|
102
|
-
const bundle = dbrief.buildRevoteBundle({ findings: bundleFindings, date: ctx.o.date });
|
|
103
|
-
// spec §5.1 names `revote-bundle.md` a run-dir artifact: the shared re-vote prompt goes to
|
|
104
|
-
// disk exactly like Stage 2's bundle-stage2.md, so the round's model-facing input is
|
|
105
|
-
// auditable alongside briefing-stage1.md and chair-packet.md.
|
|
106
|
-
fs.writeFileSync(path.join(ctx.o.runDir, 'revote-bundle.md'), bundle, { mode: 0o600 });
|
|
107
|
-
const waveId = `${ctx.o.runId}-rv`;
|
|
108
|
-
const expectedIds = bundleFindings.map(f => f.id);
|
|
109
|
-
// run-debate — not run.js — owns this stage's `running` checkpoint AND its abort-cascade
|
|
110
|
-
// id: only this function knows whether the wave actually launched (it is skipped when
|
|
111
|
-
// nothing was defended/amended, or the cost ceiling hit).
|
|
112
|
-
runState.updateStage(ctx.o.runDir, 'debate-revote',
|
|
113
|
-
{ status: 'running', startedAt: new Date().toISOString(), project: ctx.scratchDir, waveId });
|
|
114
|
-
emitStageStarted(ctx.o.runDir, ctx.o.runId, 'debate-revote', waveId, ctx.o.follow);
|
|
115
|
-
runState.appendStageWave(ctx.o.runDir, 'debate-revote', waveId);
|
|
116
|
-
const res = await ctx.launchers.launchWave({ ...legOpts(ctx, waveId), models: judges, prompt: bundle });
|
|
117
|
-
ctx.addWave(res.wave);
|
|
118
|
-
if (isAbortExit(res.exitCode)) { return { aborted: res.exitCode }; }
|
|
119
|
-
const byJudge = {}, legs = [];
|
|
120
|
-
// v4.7 D2/E4: mirrors runDefenseSolo's supersededLeg/repairLeg — one list each,
|
|
121
|
-
// accumulated across every judge in this wave (most judges contribute neither).
|
|
122
|
-
const supersededLegs = [], repairLegs = [];
|
|
123
|
-
for (const leg of ((res.wave && res.wave.legs) || [])) {
|
|
124
|
-
// The council ALIAS, not the resolved executable id — runStats rows join
|
|
125
|
-
// meta.models by exact string (run-assemble.js's buildRunStatsEntry).
|
|
126
|
-
const judge = leg.modelInput || leg.model;
|
|
127
|
-
const alive = leg.status === 'complete' && leg.summary;
|
|
128
|
-
let outLeg = leg; // the leg actually recorded (post-repair when there is one)
|
|
129
|
-
let parsed = alive ? parseRevote(leg.summary, expectedIds)
|
|
130
|
-
: { ok: false, byId: {}, errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
|
|
131
|
-
let conformance = alive ? 'clean' : 'unstructured';
|
|
132
|
-
if (alive && !parsed.ok) {
|
|
133
|
-
// One repair, solo, to that judge.
|
|
134
|
-
const repairId = `${waveId}-${judge}r`;
|
|
135
|
-
runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
|
|
136
|
-
const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
|
|
137
|
-
// ⚠️ LC-12: ditto — the re-vote output being repaired rides with its errors.
|
|
138
|
-
prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
|
|
139
|
-
ctx.addWave(r2.wave);
|
|
140
|
-
if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
|
|
141
|
-
const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
|
|
142
|
-
parsed = leg2 ? parseRevote(leg2.summary, expectedIds) : parsed;
|
|
143
|
-
conformance = parsed.ok ? 'repaired' : 'unstructured';
|
|
144
|
-
// Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
|
|
145
|
-
// revote-<model>.md and the runStats row keep the PRE-repair output.
|
|
146
|
-
if (leg2) { supersededLegs.push(legRow(judge, leg, 'unstructured')); outLeg = leg2; }
|
|
147
|
-
else { repairLegs.push(legRow(judge, r2.leg, 'unstructured')); }
|
|
148
|
-
}
|
|
149
|
-
byJudge[judge] = parsed.byId;
|
|
150
|
-
legs.push({ model: judge, status: outLeg.status, durationMs: outLeg.durationMs, usage: outLeg.usage,
|
|
151
|
-
conformance, summary: outLeg.summary || '', waveId: outLeg.waveId,
|
|
152
|
-
...(outLeg.model ? { resolvedModel: outLeg.model } : {}) });
|
|
153
|
-
}
|
|
154
|
-
return { byJudge, legs, supersededLegs, repairLegs };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
100
|
/**
|
|
158
101
|
* Full Stage-2.5 sequence (spec §5.1). Returns everything run.js needs. Cost gate: run.js
|
|
159
102
|
* checks overBudget before invoking; this checks again before the re-vote wave (spec §5.7).
|
|
@@ -164,6 +107,26 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
|
|
|
164
107
|
const { byRaiser, previousTier } = debateTargets(provisionalRecord, tallyInput);
|
|
165
108
|
const contested = provisionalRecord.findings.filter(f => f.tier === 'Contested').length;
|
|
166
109
|
const disputed = provisionalRecord.findings.filter(f => f.tier === 'Disputed').length;
|
|
110
|
+
// v4.8 PR3 Task 6 (spec §4.5): ONE seat→alias projection for the whole round —
|
|
111
|
+
// both debate waves, both repair solos, applyDebate's fail-open push and every
|
|
112
|
+
// artifact literal read it, so there is exactly one shape and one name.
|
|
113
|
+
// ⚠️ The table is RE-DERIVED when `o.seats` is absent, mirroring
|
|
114
|
+
// run-stage1-launch.js:20-22 verbatim. Falling back to an EMPTY map instead
|
|
115
|
+
// was the PR2b failure shape, not a safe default: that same Stage-1 fallback
|
|
116
|
+
// means a direct-require caller's findings/adjudications carry composed seat
|
|
117
|
+
// ids (`deepseek#1`) while `o.seats` is falsy, so `aliasOf` would be the
|
|
118
|
+
// identity over them and send a NON-ROUTABLE model name to three launchers
|
|
119
|
+
// (measured: r-d1 model="deepseek#1", r-d2 "deepseek#2", r-rv models
|
|
120
|
+
// ["gpt","deepseek#2","deepseek#1"]). buildSeats is pure and total, so the
|
|
121
|
+
// reconstruction is the same table Stage 1 bound against (spec §4.3).
|
|
122
|
+
// `aliasOf` is the identity only for a key that is no seat id at all: the
|
|
123
|
+
// reserved 'claude' key, and — because `s.alias === s.id` there — every bench
|
|
124
|
+
// without a repeated alias, i.e. every bench that has ever run.
|
|
125
|
+
const seatTable = Array.isArray(ctx.o.seats) && ctx.o.seats.length > 0
|
|
126
|
+
? ctx.o.seats
|
|
127
|
+
: buildSeats(ctx.o.models, ctx.o.critic, ctx.o.lenses);
|
|
128
|
+
const seatById = new Map(seatTable.map(s => [s.id, s]));
|
|
129
|
+
const aliasOf = (key) => { const s = seatById.get(key); return s ? s.alias : key; };
|
|
167
130
|
|
|
168
131
|
// ---- Defense mini-wave: ONE CONCURRENT solo per raiser (spec §5.1) ----
|
|
169
132
|
// Concurrent, not sequential: every raiser gets its OWN briefing, so this is N independent
|
|
@@ -177,12 +140,16 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
|
|
|
177
140
|
// (the same "originals stand" outcome as a no-response).
|
|
178
141
|
const raisers = Object.keys(byRaiser).filter(m => m !== 'claude');
|
|
179
142
|
const defenseResults = await Promise.all(
|
|
180
|
-
raisers.map((
|
|
143
|
+
raisers.map((raiserKey, i) => runDefenseSolo(ctx, raiserKey, byRaiser[raiserKey], i, aliasOf)));
|
|
181
144
|
// A signal during the defense wave aborts the whole finalization (spec §5.7):
|
|
182
145
|
// return the abort code so run.js finalizes 'aborted' with NO tally-final / NO ledger.
|
|
183
146
|
const abortedDefense = defenseResults.find(d => d.aborted);
|
|
184
147
|
if (abortedDefense) { return { aborted: abortedDefense.aborted, contested, disputed }; }
|
|
185
|
-
|
|
148
|
+
// ⚠️ This literal lives in runDebate, where `raiserAlias` does not exist — the
|
|
149
|
+
// projection is `aliasOf(d.raiser)`. `model` must stay ALIAS-valued (R3-1);
|
|
150
|
+
// `seat` is what gives two twins two files instead of one clobbered one (R3-3).
|
|
151
|
+
materializeDebate(ctx.o.runDir, defenseResults.map(d => ({ model: aliasOf(d.raiser),
|
|
152
|
+
summary: d.leg.summary, seat: seatById.get(d.raiser) || null })), 'rebuttal');
|
|
186
153
|
|
|
187
154
|
const defenseByRaiser = {};
|
|
188
155
|
for (const dr of defenseResults) { defenseByRaiser[dr.raiser] = { ...dr.byId }; }
|
|
@@ -200,18 +167,23 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
|
|
|
200
167
|
// ---- Re-vote mini-wave (disputing judges only) ----
|
|
201
168
|
let revoteByJudge = {}, revoteLegs = [], revoteSuperseded = [], revoteRepairs = [];
|
|
202
169
|
const defendedOrAmended = bundleFor(defenseResults, tallyInput);
|
|
203
|
-
|
|
170
|
+
// Seat ids (D6: one entry per disputing SEAT, so a twin bench launches two legs
|
|
171
|
+
// where one launched before). runRevoteWave needs the seat OBJECTS too — for the
|
|
172
|
+
// -rv bind roster and each leg's artifact name — so they are resolved here off
|
|
173
|
+
// the same table `aliasOf` reads, padded inside runRevoteWave per §3.4.
|
|
174
|
+
const judgeKeys = disputingJudges(provisionalRecord, defendedOrAmended.map(f => f.id));
|
|
175
|
+
const judgeSeats = judgeKeys.map(k => seatById.get(k) || null);
|
|
204
176
|
// A re-vote is warranted only when something was defended/amended AND ≥1 judge disputed it.
|
|
205
177
|
// Skipping THAT case because the whole-run budget is spent is the 'skipped-cost-ceiling'
|
|
206
178
|
// degradation branch (spec §5.7); skipping because there is simply nothing to re-vote is NOT.
|
|
207
|
-
const wouldRevote = defendedOrAmended.length > 0 &&
|
|
179
|
+
const wouldRevote = defendedOrAmended.length > 0 && judgeKeys.length > 0;
|
|
208
180
|
const costCeiling = ctx.overBudget() && wouldRevote;
|
|
209
181
|
// run.js needs to know whether the wave actually launched so it can
|
|
210
182
|
// checkpoint debate-revote 'skipped' (not a false 'complete') when nothing
|
|
211
183
|
// was defended/amended, or the cost ceiling skipped it (spec §5.7).
|
|
212
184
|
const revoteLaunched = wouldRevote && !costCeiling;
|
|
213
185
|
if (revoteLaunched) {
|
|
214
|
-
const rv = await runRevoteWave(ctx,
|
|
186
|
+
const rv = await runRevoteWave(ctx, judgeKeys, defendedOrAmended, judgeSeats, aliasOf);
|
|
215
187
|
if (rv.aborted) { return { aborted: rv.aborted, contested, disputed }; }
|
|
216
188
|
revoteByJudge = rv.byJudge;
|
|
217
189
|
revoteLegs = rv.legs;
|
|
@@ -223,8 +195,13 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
|
|
|
223
195
|
}
|
|
224
196
|
|
|
225
197
|
// ---- Pure reassembly ----
|
|
198
|
+
// ⚠️ `aliasOf` MUST ride along: without it applyDebate's fail-open push writes a
|
|
199
|
+
// SEAT ID into the alias-space `judge` field, which reaches
|
|
200
|
+
// peer-split.js :: peersOf's `v.judge !== f.raiser` (measured: basis {a:1,d:0} →
|
|
201
|
+
// tier Confirmed where the alias spelling gives Singleton) and, as an
|
|
202
|
+
// out-of-contract `judge`, report.js :: columnFor's vote→column join.
|
|
226
203
|
const { input: debatedInput, debateFindings } = applyDebate({
|
|
227
|
-
tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge });
|
|
204
|
+
tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge, aliasOf });
|
|
228
205
|
debatedInput.runStats = [...(debatedInput.runStats || []),
|
|
229
206
|
...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs,
|
|
230
207
|
// v4.7 D2/E4: the retained loser legs from every raiser's defense repair
|
|
@@ -240,9 +217,19 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
|
|
|
240
217
|
for (const f of debatedRec.findings) { if (provTierById.get(f.id) !== f.tier) { verdictChanges += 1; } }
|
|
241
218
|
|
|
242
219
|
// ---- Artifacts + summary ----
|
|
220
|
+
// `revoteByJudge` is SEAT-keyed, but debate.json's `revotes[]` has a real
|
|
221
|
+
// consumer that joins on the ALIAS — electron/workspace-ui/workspace-panels.js's
|
|
222
|
+
// drillIntoJudge matches `r.judge === judgePair.model`, and its comment states
|
|
223
|
+
// that contract explicitly. So `judge` stays the alias and the seat rides
|
|
224
|
+
// beside it, emitted only when it differs (a unique bench writes today's
|
|
225
|
+
// byte-identical debate.json).
|
|
243
226
|
const revotesJson = [];
|
|
244
|
-
for (const [
|
|
245
|
-
|
|
227
|
+
for (const [key, perId] of Object.entries(revoteByJudge)) {
|
|
228
|
+
const alias = aliasOf(key);
|
|
229
|
+
for (const [id, rv] of Object.entries(perId)) {
|
|
230
|
+
revotesJson.push({ judge: alias, ...(alias !== key ? { seat: key } : {}),
|
|
231
|
+
id, verdict: rv.verdict, reason: rv.reason || null, applied: true });
|
|
232
|
+
}
|
|
246
233
|
}
|
|
247
234
|
fs.writeFileSync(path.join(ctx.o.runDir, 'debate.json'),
|
|
248
235
|
JSON.stringify({ findings: debateFindings, revotes: revotesJson }, null, 2), { mode: 0o600 });
|
|
@@ -265,14 +252,17 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
|
|
|
265
252
|
|
|
266
253
|
// Chair-addendum outcomes (spec §5.3c). `action` is the PAST_TENSE form
|
|
267
254
|
// buildDebateAddendum renders verbatim — only the four valid values ever reach it.
|
|
255
|
+
// ⚠️ priorVerdicts and revotes MUST share ONE key space: briefings-debate.js's
|
|
256
|
+
// renderer iterates Object.keys(revotes) and looks up prior[j], so a skew prints
|
|
257
|
+
// `no prior verdict` on EVERY line. Both are keyed seat-side here.
|
|
268
258
|
const priorById = new Map(provisionalRecord.findings.map(
|
|
269
|
-
f => [f.id, Object.fromEntries((f.adjudications || []).map(a => [a.judge, a.verdict]))]));
|
|
259
|
+
f => [f.id, Object.fromEntries((f.adjudications || []).map(a => [a.seat || a.judge, a.verdict]))]));
|
|
270
260
|
const addendumOutcomes = debateFindings.map(df => ({
|
|
271
261
|
id: df.id, originalClaim: (tallyInput.findings.find(f => f.id === df.id) || {}).claim,
|
|
272
262
|
action: PAST_TENSE[df.action] || PAST_TENSE['no-response'],
|
|
273
263
|
amendedClaim: df.action === 'amend' ? df.claim : null,
|
|
274
264
|
priorVerdicts: priorById.get(df.id) || {},
|
|
275
|
-
revotes: Object.fromEntries(revotesJson.filter(r => r.id === df.id).map(r => [r.judge, r.verdict])),
|
|
265
|
+
revotes: Object.fromEntries(revotesJson.filter(r => r.id === df.id).map(r => [r.seat || r.judge, r.verdict])),
|
|
276
266
|
}));
|
|
277
267
|
|
|
278
268
|
return { debatedInput, debateFindings, debateSummary, addendumOutcomes,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/council/run-finish.js
|
|
2
|
+
'use strict';
|
|
3
|
+
// Final tally (chair row included) + ledger gate + tally/verdict artifacts.
|
|
4
|
+
// Moved verbatim from run.js@6b0c3b6b:242-288 (v4.8 PR0 size-gate split, zero
|
|
5
|
+
// behavior). NOT run-finalize.js — that sibling owns exit codes and the
|
|
6
|
+
// terminal write; this module builds the final tally, appends the run
|
|
7
|
+
// record to the ledger (skipped for lens runs), and writes the
|
|
8
|
+
// tally/verdict artifact files + their stage events.
|
|
9
|
+
const { tally } = require('./tally');
|
|
10
|
+
const { decorateRecord } = require('./debate');
|
|
11
|
+
const runState = require('./run-state');
|
|
12
|
+
const asm = require('./run-assemble');
|
|
13
|
+
const { emitStageStarted, emitStageTerminal } = require('../observe/events');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the final tally, gate the ledger append, write tally+verdict
|
|
17
|
+
* artifacts and their stage checkpoints. Void — run.js's trailing
|
|
18
|
+
* `return finalize(...)` reads only degraded.value, which this never
|
|
19
|
+
* mutates.
|
|
20
|
+
*/
|
|
21
|
+
function finishRun({ o, chairRes, debatedInput, debateFindings, appendRunFn, degrade, deadWaves, now }) {
|
|
22
|
+
const { chairLeg, actualChair, chairText, chairConformance, overallVerdict, chairRows, chairAttempts } = chairRes;
|
|
23
|
+
const chairStats = chairLeg ? asm.buildRunStatsEntry({
|
|
24
|
+
leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
|
|
25
|
+
conformance: chairConformance,
|
|
26
|
+
}) : null;
|
|
27
|
+
// v4.7 D2: a give-up (no chairLeg) with at least one recorded attempt gets
|
|
28
|
+
// an explicit error row so the walk's outcome isn't silently absorbed.
|
|
29
|
+
// Keyed on chairAttempts, NOT chairRows — attempts that die pre-wave (no
|
|
30
|
+
// money spent) record an outcome but yield no row (errata E3).
|
|
31
|
+
const giveUpRow = (!chairLeg && chairAttempts && chairAttempts.length)
|
|
32
|
+
? asm.buildRunStatsEntry({ leg: null, model: o.chair, role: 'chair', wasChair: false })
|
|
33
|
+
: null;
|
|
34
|
+
// Built on the (possibly debated) input so the debate's amended claims, replaced
|
|
35
|
+
// adjudications and rebuttal/revote runStats rows all reach the final record.
|
|
36
|
+
const finalInput = { ...debatedInput, meta: { ...debatedInput.meta, chair: actualChair || o.chair } };
|
|
37
|
+
// Item 8, final-review consolidated wave: was three sequential
|
|
38
|
+
// reassignments (chairStats, then chairRows, then giveUpRow), each
|
|
39
|
+
// rebuilding finalInput.runStats from scratch — collapsed into the one
|
|
40
|
+
// spread that was always the net effect. The `|| []` fallbacks were
|
|
41
|
+
// dead: `runStats` is a real array on every debatedInput
|
|
42
|
+
// (asm.buildTallyInput always returns one via .map()), never undefined.
|
|
43
|
+
finalInput.runStats = [
|
|
44
|
+
...finalInput.runStats,
|
|
45
|
+
...(chairStats ? [chairStats] : []),
|
|
46
|
+
...chairRows,
|
|
47
|
+
...(giveUpRow ? [giveUpRow] : []),
|
|
48
|
+
];
|
|
49
|
+
const record = tally(finalInput);
|
|
50
|
+
if (debateFindings) { decorateRecord(record, debateFindings); }
|
|
51
|
+
if (!o.lenses) {
|
|
52
|
+
// Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
|
|
53
|
+
try { appendRunFn(record); }
|
|
54
|
+
catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
|
|
55
|
+
}
|
|
56
|
+
asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
|
|
57
|
+
const tallyStage = o.debate ? 'tally-final' : 'tally';
|
|
58
|
+
runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
|
|
59
|
+
emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
|
|
60
|
+
emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
|
|
61
|
+
// Verdict assembly is the degrade cut-off: anything noted after this line
|
|
62
|
+
// reaches stderr + run.json but not verdict.json (spec §6 rule 1).
|
|
63
|
+
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
|
|
64
|
+
critic: o.critic, deadWaves, degrades: degrade.all() });
|
|
65
|
+
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
66
|
+
emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
|
|
67
|
+
emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { finishRun };
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
|
+
// sanitizeName lives in ./seats (v4.8 PR1) so that module can stay
|
|
20
|
+
// require-free; re-exported below — run-stage2.js and workspace/artifact-guard.js
|
|
21
|
+
// import it from here.
|
|
22
|
+
const { sanitizeName, artifactName } = require('./seats');
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* Did a launch exit because a SIGNAL killed it (130 = SIGINT, 143 = SIGTERM)
|
|
@@ -63,15 +67,22 @@ function createLaunchers(deps = {}) {
|
|
|
63
67
|
/**
|
|
64
68
|
* @param {{models: string[], prompt: string, project: string, waveId: string,
|
|
65
69
|
* timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
|
|
66
|
-
* councilRunId?: string, councilName?: string, tag?: string,
|
|
67
|
-
* catalog?: Array}} opts
|
|
70
|
+
* councilRunId?: string, councilName?: string, tag?: string, seats?: Array<object>,
|
|
71
|
+
* fallback?: object, catalog?: Array, noOutputBackstopMs?: number}} opts
|
|
68
72
|
* councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
|
|
69
73
|
* ids forwarded verbatim into the runFanout call so it can stamp them onto
|
|
70
74
|
* every leg. tag (v4.7 F8 D16) rides the same forward — every call site
|
|
71
75
|
* below that sets councilRunId/councilName sets `tag: o.tag` alongside it.
|
|
76
|
+
* seats (v4.8 R5 T4.1) rides the same forward again: the wave's launch
|
|
77
|
+
* roster, index-parallel with `models`. stampLegAttribution
|
|
78
|
+
* (fanout-wave-io.js, T4.2) consumes it to name each leg's seat; it is
|
|
79
|
+
* `undefined` for every caller that does not set it.
|
|
72
80
|
* fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
|
|
73
81
|
* additive/opt-in — omitted by callers that must never substitute (the
|
|
74
82
|
* chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
|
|
83
|
+
* noOutputBackstopMs (Task 5, #129) is opt-in and spread-guarded on
|
|
84
|
+
* Number.isFinite (0 is a valid disable value); only run-retry.js sets it,
|
|
85
|
+
* to escalate the window on a Stage-1 retry.
|
|
75
86
|
* @returns {Promise<{wave: object|null, exitCode: number}>}
|
|
76
87
|
*/
|
|
77
88
|
async function launchWave(opts) {
|
|
@@ -118,6 +129,19 @@ function createLaunchers(deps = {}) {
|
|
|
118
129
|
// undefined when no --tag, so stampLegAttribution's `if (options.tag)`
|
|
119
130
|
// guard (fanout-wave-io.js) simply no-ops, byte-identical to today.
|
|
120
131
|
tag: opts.tag,
|
|
132
|
+
// v4.8 R5 T4.1: the per-wave roster, index-parallel with `models`.
|
|
133
|
+
// stampLegAttribution (fanout-wave-io.js, T4.2) consumes this key to
|
|
134
|
+
// name each leg's seat; `undefined` for every caller that does not set
|
|
135
|
+
// it (every non-council caller, and the chair/debate/repair launches).
|
|
136
|
+
seats: opts.seats,
|
|
137
|
+
// Task 5 (#129): spread-guarded on Number.isFinite, NOT on truthiness —
|
|
138
|
+
// an explicit 0 is this knob's documented disable hatch
|
|
139
|
+
// (no-output-backstop.js:13-15) and a truthiness guard would silently
|
|
140
|
+
// drop it. Guarding at all — rather than a plain
|
|
141
|
+
// `noOutputBackstopMs: opts.noOutputBackstopMs` — keeps the transport
|
|
142
|
+
// call key-identical for run-stage1-launch / run-stage2 / run-chair /
|
|
143
|
+
// run-debate, none of which set it.
|
|
144
|
+
...(Number.isFinite(opts.noOutputBackstopMs) ? { noOutputBackstopMs: opts.noOutputBackstopMs } : {}),
|
|
121
145
|
// v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
|
|
122
146
|
// substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
|
|
123
147
|
// fallback path only activates when `fallback.enabled` is true.
|
|
@@ -164,38 +188,51 @@ function createLaunchers(deps = {}) {
|
|
|
164
188
|
return { launchWave, launchSolo };
|
|
165
189
|
}
|
|
166
190
|
|
|
167
|
-
/** Filesystem-safe model name for review-/judge- artifact filenames. */
|
|
168
|
-
function sanitizeName(model) {
|
|
169
|
-
return String(model).replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
170
|
-
}
|
|
171
|
-
|
|
172
191
|
/**
|
|
173
|
-
* Write
|
|
174
|
-
*
|
|
175
|
-
*
|
|
192
|
+
* Write one review file per surviving Stage-1 leg (skill layout). Dead legs and
|
|
193
|
+
* empty summaries are skipped — the caller applies the wave-degrade rules to
|
|
194
|
+
* what remains, which is why a BOUND seat can still end up dead here.
|
|
195
|
+
*
|
|
196
|
+
* With `seatOf` the filename is the SEAT's (artifactName), byte-identical to the
|
|
197
|
+
* alias name for every bench that has ever run, and what stops two twins from
|
|
198
|
+
* clobbering one file. An unbound leg keeps its alias name rather than being
|
|
199
|
+
* dropped: it is unattributable, not unusable, and dropping it would lose a
|
|
200
|
+
* review that lands today.
|
|
201
|
+
*
|
|
176
202
|
* @param {string} runDir
|
|
177
203
|
* @param {Array<object>} legs run documents from the wave/solo docs
|
|
178
|
-
* @
|
|
204
|
+
* @param {Map<object, object>} [seatOf] leg document -> seat, keyed by object identity
|
|
205
|
+
* @returns {Array<{model: string, modelInput: string, file: string, text: string,
|
|
206
|
+
* leg: object, seat: ?object}>}
|
|
179
207
|
*/
|
|
180
|
-
function materializeReviews(runDir, legs) {
|
|
208
|
+
function materializeReviews(runDir, legs, seatOf) {
|
|
181
209
|
const out = [];
|
|
182
210
|
for (const leg of legs) {
|
|
183
211
|
if (!leg || leg.status !== 'complete') { continue; }
|
|
184
212
|
const text = leg.summary;
|
|
185
213
|
if (!text || !String(text).trim()) { continue; }
|
|
186
214
|
const modelInput = leg.modelInput || leg.model;
|
|
187
|
-
const
|
|
215
|
+
const seat = (seatOf && seatOf.get(leg)) || null;
|
|
216
|
+
const name = seat ? artifactName(seat, 'review') : `review-${sanitizeName(modelInput)}.md`;
|
|
217
|
+
const file = path.join(runDir, name);
|
|
188
218
|
fs.writeFileSync(file, text, { mode: 0o600 });
|
|
189
|
-
out.push({ model: leg.model, modelInput, file, text, leg });
|
|
219
|
+
out.push({ model: leg.model, modelInput, file, text, leg, seat });
|
|
190
220
|
}
|
|
191
221
|
return out;
|
|
192
222
|
}
|
|
193
223
|
|
|
194
224
|
/**
|
|
195
|
-
* Write per-leg debate artifacts
|
|
196
|
-
*
|
|
225
|
+
* Write per-leg debate artifacts for each leg with a non-empty summary. Mirrors
|
|
226
|
+
* materializeReviews — but these are plain `{model, summary}` literals built by
|
|
227
|
+
* the debate callers, never leg documents, so materializeReviews' object-identity
|
|
228
|
+
* `seatOf` Map does not transfer: the seat rides ON the literal instead.
|
|
229
|
+
*
|
|
230
|
+
* With `seat` the filename is the SEAT's (artifactName, v4.8 PR3 Task 6),
|
|
231
|
+
* byte-identical to `<prefix>-<sanitizeName(model)>.md` for every bench without
|
|
232
|
+
* a repeated alias, and what stops two twins from clobbering one file. Without
|
|
233
|
+
* it the alias name is kept — today's exact behaviour.
|
|
197
234
|
* @param {string} runDir
|
|
198
|
-
* @param {Array<{model: string, summary: string}>} legs
|
|
235
|
+
* @param {Array<{model: string, summary: string, seat?: ?object}>} legs
|
|
199
236
|
* @param {string} prefix 'rebuttal' | 'revote'
|
|
200
237
|
* @returns {Array<{model: string, file: string}>}
|
|
201
238
|
*/
|
|
@@ -203,7 +240,8 @@ function materializeDebate(runDir, legs, prefix) {
|
|
|
203
240
|
const out = [];
|
|
204
241
|
for (const leg of legs) {
|
|
205
242
|
if (!leg || !leg.summary || !leg.summary.trim()) { continue; }
|
|
206
|
-
const
|
|
243
|
+
const name = leg.seat ? artifactName(leg.seat, prefix) : `${prefix}-${sanitizeName(leg.model)}.md`;
|
|
244
|
+
const file = path.join(runDir, name);
|
|
207
245
|
fs.writeFileSync(file, leg.summary, { mode: 0o600 });
|
|
208
246
|
out.push({ model: leg.model, file });
|
|
209
247
|
}
|