amicus 4.7.1 → 4.8.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 +988 -1
- package/README.md +5 -4
- package/docs/CITATIONS.md +122 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +179 -32
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/troubleshooting.md +3 -3
- package/docs/usage.md +2 -2
- package/electron/ipc-setup.js +18 -2
- package/electron/main.js +46 -3
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +231 -29
- 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 +4 -1
- 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/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/src/cli-handlers-doctor.js +18 -14
- 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/parse-stage2.js +1 -1
- 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 +45 -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 +166 -161
- 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 +20 -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/fanout-leg.js +22 -1
- 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 +1 -1
- package/src/sidecar/setup.js +137 -2
- package/src/utils/alias-audit.js +81 -3
- 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-alias-check.js +152 -0
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +37 -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 +30 -4
- package/src/workspace/seat-space.js +143 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — artifact NAME derivation (v4.8 PR5a).
|
|
3
|
+
*
|
|
4
|
+
* Split out of artifact-guard.js on the natural seam: this module decides WHICH names a
|
|
5
|
+
* run dir may legitimately hold and who owns each one; artifact-guard.js keeps the two
|
|
6
|
+
* read fences (allowlist membership, realpath containment). The guard grew past the
|
|
7
|
+
* 300-line gate when the seat-space rebuild landed, and the two halves have no shared
|
|
8
|
+
* state beyond the constants re-exported below.
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { sanitizeName } = require('../council/run-launch');
|
|
13
|
+
// ⚠️ v4.8 PR5b: the seat-space PREDICATES moved to ./seat-space (this file was at 300/300,
|
|
14
|
+
// having itself been split out of artifact-guard.js for that same gate). Imported back, and
|
|
15
|
+
// isSeatTable is re-exported below, so every caller and test keeps its existing import path.
|
|
16
|
+
const { isSeatTable, orphanExonerations } = require('./seat-space');
|
|
17
|
+
|
|
18
|
+
// ⚠️ v4.8 PR5a T1b: `review-claude.md` is NOT an engine artifact — the second-opinion skill
|
|
19
|
+
// authors it as an INPUT and --out-dir places it in the run dir (v4.1 design §:156). It sat
|
|
20
|
+
// unreadable in five real run dirs on the author's machine, so "How Claude's review fared" was
|
|
21
|
+
// permanently unopenable in the Workspace. It is a FIXED name rather than a gated one because
|
|
22
|
+
// run.json carries no claude marker at all: `claudeInCouncil` is set only on tally/verdict meta
|
|
23
|
+
// (run-assemble.js:178) and `claudeReviewFile` never leaves the in-memory options object —
|
|
24
|
+
// run-state.js:129 writes a fixed four-key `options` projection. An unconditional entry is
|
|
25
|
+
// honest here: the presence manifest already reports four fixed names as absent on a normal run.
|
|
26
|
+
const FIXED_ARTIFACTS = Object.freeze(['briefing-stage1.md', 'bundle-stage2.md', 'chair-packet.md', 'chair-output.md', 'tally-input.json', 'review-claude.md']);
|
|
27
|
+
// ⚠️ DE-ROT (F28): v4.1's debate stage writes five MORE run-dir artifact kinds the original
|
|
28
|
+
// allowlist never named, so the Workspace hard-refused every `--debate` output with
|
|
29
|
+
// `artifact not allowed: <name>`. Writers (re-derived v4.8 PR3 — Task 1 moved runRevoteWave):
|
|
30
|
+
// tally-provisional.json = run-debate-stage.js :: runDebateStage; revote-bundle.md =
|
|
31
|
+
// run-debate-revote.js :: runRevoteWave; debate.json = run-debate.js :: runDebate; the
|
|
32
|
+
// rebuttal-/revote- pair = run-launch.js :: materializeDebate.
|
|
33
|
+
// ⚠️ FIVE KINDS, THREE ENTRIES — that is not a miscount (v4.4.1 DOC-7, re-verified). This const
|
|
34
|
+
// holds only the three RUN-LEVEL names; the last two of the five, the rebuttal-/revote- pair, are
|
|
35
|
+
// appended inside artifactAllowlist below, next to review-/judge-.
|
|
36
|
+
const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} run parsed run.json (may be partial)
|
|
40
|
+
* @returns {string[]} the allowlist. When two or more DISTINCT bench entries sanitize to the
|
|
41
|
+
* same artifact name, a non-enumerable-in-spirit (but plain, test-visible) `collisions`
|
|
42
|
+
* array is attached: `[{sanitized, models: [rawA, rawB, ...]}, ...]`. See the R4
|
|
43
|
+
* council-review note below for why this is surfaced rather than silently deduped.
|
|
44
|
+
*/
|
|
45
|
+
function artifactAllowlist(run) {
|
|
46
|
+
const names = [...FIXED_ARTIFACTS];
|
|
47
|
+
const bench = run && Array.isArray(run.bench) ? run.bench : [];
|
|
48
|
+
// ⚠️ DE-ROT (F28): run.json carries a `debate` key ONLY on --debate runs, and it is seeded
|
|
49
|
+
// on the FIRST write (initCouncilRun, src/council/run-state.js:100-103), so this gate is safe
|
|
50
|
+
// and keeps the allowlist tight for the common case.
|
|
51
|
+
const debated = !!(run && run.debate);
|
|
52
|
+
if (debated) { names.push(...DEBATE_ARTIFACTS); }
|
|
53
|
+
|
|
54
|
+
// ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): sanitizeName is NOT
|
|
55
|
+
// injective — it maps every character outside [a-zA-Z0-9._-] to '-', so two DISTINCT bench
|
|
56
|
+
// entries ('vendor/a', 'vendor?a') both produce 'vendor-a'. Both models would then request
|
|
57
|
+
// the SAME artifact file, and the renderer's `[data-artifact="..."]` lookup (drillIntoJudge)
|
|
58
|
+
// hands back whichever section matches first — prose silently misattributed to the wrong
|
|
59
|
+
// model. That is a run-integrity defect (this run directory genuinely cannot hold both
|
|
60
|
+
// models' review/judge files under distinct names), not a display quirk, so it must be
|
|
61
|
+
// DETECTED and surfaced, never smoothed away by deduping the resulting name list.
|
|
62
|
+
//
|
|
63
|
+
// ⚠️ v4.8 PR5a (R5-13) INVERTS THE INTENT STATED HERE THROUGH v4.7. A bench with repeated
|
|
64
|
+
// identical entries no longer "collapses to one set of rows": since PR3 each such entry is a
|
|
65
|
+
// distinct SEAT and the engine writes a distinct file per seat (seats.js artifactName), so
|
|
66
|
+
// collapsing them is exactly what made both twins' reviews unreadable. What survives from the
|
|
67
|
+
// old intent is the RN-1 machinery below — two DISTINCT raw entries that coincide after
|
|
68
|
+
// sanitizeName is still a genuine run-integrity defect and is still surfaced, never deduped.
|
|
69
|
+
//
|
|
70
|
+
// The entity list is therefore the SEAT ids when run.json carries a usable seat table, and the
|
|
71
|
+
// unique raw bench values otherwise. On a bench with no repeated alias the two are the same
|
|
72
|
+
// list in the same order (seat id === alias, spec §4.2), so the output is byte-identical.
|
|
73
|
+
const entities = isSeatTable(run && run.seats)
|
|
74
|
+
? [...new Set(run.seats.map(s => s.id))]
|
|
75
|
+
: [...new Set(bench)];
|
|
76
|
+
const rawBySanitized = new Map(); // sanitized name -> first raw model seen for it
|
|
77
|
+
const collisionModels = new Map(); // sanitized name -> Set(raw models) once >1 raw maps to it
|
|
78
|
+
for (const m of entities) {
|
|
79
|
+
const s = sanitizeName(m);
|
|
80
|
+
if (rawBySanitized.has(s)) {
|
|
81
|
+
if (!collisionModels.has(s)) { collisionModels.set(s, new Set([rawBySanitized.get(s)])); }
|
|
82
|
+
collisionModels.get(s).add(m);
|
|
83
|
+
} else {
|
|
84
|
+
rawBySanitized.set(s, m);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ⚠️ Task 18 (RN-1): the collision above is a real run-integrity defect — the run directory
|
|
89
|
+
// physically holds ONE file where two models' artifacts should be, and no renderer trick can
|
|
90
|
+
// recover both. What the renderer CAN stop doing is showing model A's prose under model B's
|
|
91
|
+
// name. Deterministic disambiguation: per colliding sanitized name, sort the RAW models
|
|
92
|
+
// (sorting, not insertion order, is what keeps this reproducible across processes/runs); the
|
|
93
|
+
// first (sorted) keeps the bare sanitized name, the rest get `~2`, `~3`, ... The suffixed
|
|
94
|
+
// names deliberately do not exist on disk — the presence manifest (run-detail.js, via
|
|
95
|
+
// fs.statSync over this same allowlist) marks them absent, so the renderer shows the honest
|
|
96
|
+
// "not written yet" empty state for every model but the first, instead of cross-matching.
|
|
97
|
+
const nameFor = new Map(); // raw model -> its (possibly suffixed) sanitized name
|
|
98
|
+
for (const m of entities) {
|
|
99
|
+
let s = sanitizeName(m);
|
|
100
|
+
const collision = collisionModels.get(s);
|
|
101
|
+
if (collision) {
|
|
102
|
+
const sortedRaw = [...collision].sort();
|
|
103
|
+
const index = sortedRaw.indexOf(m);
|
|
104
|
+
if (index > 0) { s = `${s}~${index + 1}`; }
|
|
105
|
+
}
|
|
106
|
+
nameFor.set(m, s);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// primary name -> the ALIAS of the entity that owns it (its own alias in the legacy
|
|
110
|
+
// branch). Used below to tell "an orphan's file IS this seat's own primary" (harmless,
|
|
111
|
+
// same alias) from "an orphan's file collides with ANOTHER seat's primary" (ambiguous).
|
|
112
|
+
const aliasOfEntity = new Map(
|
|
113
|
+
isSeatTable(run && run.seats) ? run.seats.map(s => [s.id, s.alias]) : bench.map(m => [m, m]),
|
|
114
|
+
);
|
|
115
|
+
const ownerOf = new Map();
|
|
116
|
+
for (const m of entities) {
|
|
117
|
+
const s = nameFor.get(m);
|
|
118
|
+
for (const k of ['review', 'judge', 'rebuttal', 'revote']) {
|
|
119
|
+
ownerOf.set(`${k}-${s}.md`, { entity: m, alias: aliasOfEntity.get(m), stem: s });
|
|
120
|
+
}
|
|
121
|
+
names.push(`review-${s}.md`);
|
|
122
|
+
names.push(`judge-${s}.md`);
|
|
123
|
+
// rebuttal-/revote- ride the SAME entity stem as review-/judge-, so DISTINCT aliases
|
|
124
|
+
// colliding after sanitizeName are disambiguated identically. (The PR3 warning that sat
|
|
125
|
+
// here — "no longer matches what the engine WRITES on a bench that repeats an alias" —
|
|
126
|
+
// described the alias-space loop this rebuild replaced. `s` now derives from the seat id,
|
|
127
|
+
// exactly what materializeDebate's artifactName(seat, prefix) uses, seats.js:165.)
|
|
128
|
+
if (debated) {
|
|
129
|
+
names.push(`rebuttal-${s}.md`);
|
|
130
|
+
names.push(`revote-${s}.md`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// v4.8 PR5a (R5-13): names an ORPHANED leg wrote under its alias. Emitted AFTER every
|
|
134
|
+
// primary so the Set below keeps first-occurrence order, which is what preserves
|
|
135
|
+
// byte-identity on every bench that orphaned nothing (the overwhelming majority).
|
|
136
|
+
// Listed so a review that LANDED stays readable (stage1-bind.js:35); never attributed,
|
|
137
|
+
// because bindSeats could not name that leg and guessing is the mis-attribution §4.4 forbids.
|
|
138
|
+
const orphanContested = new Set(); // artifact names attributed to NOBODY (council-3 B4)
|
|
139
|
+
const orphanByStem = new Map(); // sanitized stem -> Set(claimants), deduped for the banner
|
|
140
|
+
// Both loops below default to NOT ASSERTING, in the direction that fails safe for each:
|
|
141
|
+
// LISTING a name nobody wrote costs nothing (the presence manifest marks it absent) while
|
|
142
|
+
// omitting one that exists makes it permanently unreadable, so `orphanKinds` stays wide.
|
|
143
|
+
// CONTESTING is likewise the safe default — an unattributed file is still listed and still
|
|
144
|
+
// bannered, while a wrong attribution serves one seat's prose under another's name. So a
|
|
145
|
+
// kind is contested unless the note PROVES the orphan did not write it (orphanExonerations).
|
|
146
|
+
const orphanKinds = debated ? ['review', 'judge', 'rebuttal', 'revote'] : ['review', 'judge'];
|
|
147
|
+
for (const [alias, exonerated] of orphanExonerations(run)) {
|
|
148
|
+
const stem = sanitizeName(alias);
|
|
149
|
+
for (const kind of orphanKinds) {
|
|
150
|
+
const n = `${kind}-${stem}.md`;
|
|
151
|
+
const owner = ownerOf.get(n);
|
|
152
|
+
// Its own seat's primary — same alias, so nothing is ambiguous.
|
|
153
|
+
if (owner && owner.alias === alias) { continue; }
|
|
154
|
+
if (owner) {
|
|
155
|
+
// ANOTHER entity's primary. Attribution survives ONLY on a positive proof of
|
|
156
|
+
// non-authorship; otherwise the file may be either seat's and is contested.
|
|
157
|
+
if (exonerated.has(kind)) { continue; }
|
|
158
|
+
// The file is one or the other and run.json cannot say which, so it stays listed
|
|
159
|
+
// (the owner's push did that), is attributed to NOBODY, and is surfaced ONCE per
|
|
160
|
+
// stem — the kinds share a stem, and emitting per kind double-counts.
|
|
161
|
+
orphanContested.add(n);
|
|
162
|
+
if (!orphanByStem.has(stem)) { orphanByStem.set(stem, new Set()); }
|
|
163
|
+
orphanByStem.get(stem).add(owner.entity).add(alias);
|
|
164
|
+
} else {
|
|
165
|
+
names.push(n);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const list = [...new Set(names)];
|
|
170
|
+
if (collisionModels.size || orphanByStem.size) {
|
|
171
|
+
list.collisions = [
|
|
172
|
+
...[...collisionModels.entries()].map(([sanitized, models]) => ({
|
|
173
|
+
sanitized, models: [...models],
|
|
174
|
+
})),
|
|
175
|
+
// `models` names BOTH claimants of the stem, which is what the banner has to say:
|
|
176
|
+
// the owning ENTITY (a seat id in seat space — `a#1`, since `a`'s alias never wrote
|
|
177
|
+
// `review-a-1.md`) AND the orphan's own ALIAS (`a-1`), the string its writer used.
|
|
178
|
+
// The orphan half is deliberately not projected into entity space: no seat id names
|
|
179
|
+
// it — being unattributable to a seat is what made it an orphan.
|
|
180
|
+
...[...orphanByStem.entries()].map(([sanitized, who]) => ({
|
|
181
|
+
sanitized, models: [...who], orphan: true,
|
|
182
|
+
})),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
// Consumed by workspace-panels.js (wireLazyPanels' file lists + drillIntoJudge's artifact
|
|
186
|
+
// lookup), which prefers this map over re-deriving names via sanitizeName(model) directly —
|
|
187
|
+
// that re-derivation is exactly what would ignore the suffixing above and misattribute prose.
|
|
188
|
+
// ⚠️ Fix-wave (review finding 1) residual limit this map cannot close: the BARE (unsuffixed)
|
|
189
|
+
// name is still exactly ONE physical file on disk, and its actual bytes belong to whichever
|
|
190
|
+
// colliding model's writer ran LAST — no map can recover which one that was. The guarantee
|
|
191
|
+
// delivered here is narrower than "attribution is fully sound": at most the sorted-first
|
|
192
|
+
// model can still be misattributed under the bare name; artifactCollisions (the run-integrity
|
|
193
|
+
// banner rendered by workspace-app.js's renderBanners) is what covers that residual case.
|
|
194
|
+
// v4.8 PR5a: keyed by SEAT ID when the run is in seat space (the map and its consumers
|
|
195
|
+
// move together — RULE OF ONE SPACE). A kind whose name an orphan also claims is
|
|
196
|
+
// dropped from the map rather than attributed: the entry would name a file that may
|
|
197
|
+
// hold the other seat's prose, which is the RN-1 defect this whole block exists to kill.
|
|
198
|
+
list.artifactsByModel = Object.fromEntries(
|
|
199
|
+
[...nameFor].map(([m, s]) => {
|
|
200
|
+
const row = {};
|
|
201
|
+
for (const k of ['review', 'judge', 'rebuttal', 'revote']) {
|
|
202
|
+
const n = `${k}-${s}.md`;
|
|
203
|
+
if (!orphanContested.has(n)) { row[k] = n; }
|
|
204
|
+
}
|
|
205
|
+
return [m, row];
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
return list;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// `orphanNames` is deliberately gone (council-2 B1): revision 1 left it as a thin wrapper
|
|
212
|
+
// with zero callers once artifactAllowlist moved to orphanExonerations. An export nothing
|
|
213
|
+
// imports is a second spelling waiting to drift from the one that runs.
|
|
214
|
+
// ⚠️ v4.8 PR5b: `isSeatTable` and `orphanExonerations` are RE-EXPORTS of ./seat-space, not
|
|
215
|
+
// definitions — the same courtesy artifact-guard.js does for this file. Kept so the split is
|
|
216
|
+
// invisible to every caller (run-detail.js reaches them through artifact-guard; four test
|
|
217
|
+
// files import from one layer or the other), and pinned by an identity assertion in
|
|
218
|
+
// tests/workspace/seat-space.test.js so no layer can ever grow its own copy.
|
|
219
|
+
module.exports = {
|
|
220
|
+
artifactAllowlist, isSeatTable, orphanExonerations, FIXED_ARTIFACTS, DEBATE_ARTIFACTS,
|
|
221
|
+
};
|
|
222
|
+
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* src/workspace/ must stay free of.
|
|
18
18
|
* ⚠️ DE-ROT (F58): the header used to claim it "reuses the v4.0 marker/nonce
|
|
19
19
|
* contract exactly" while silently dropping formatFoldOutput's nonce-required
|
|
20
|
-
* throw (src/headless.js
|
|
20
|
+
* throw (src/headless.js :: formatFoldOutput). The guard is restored in
|
|
21
|
+
* buildFoldText below.
|
|
21
22
|
* No model call — the chair result already exists on disk, so a workspace fold
|
|
22
23
|
* is a local read+format.
|
|
23
24
|
* The chair body is UNTRUSTED model text: it passes through stripFoldMarkers
|
|
@@ -49,17 +50,19 @@ function stageSummary(run) {
|
|
|
49
50
|
* @returns {string} the fold block (marker first line; no trailing newline)
|
|
50
51
|
*/
|
|
51
52
|
function buildFoldText(o) {
|
|
52
|
-
// ⚠️ DE-ROT (F58): mirror formatFoldOutput's v4.0 §9 guard
|
|
53
|
-
// Without it a missing nonce emits
|
|
54
|
-
//
|
|
53
|
+
// ⚠️ DE-ROT (F58): mirror formatFoldOutput's v4.0 §9 guard
|
|
54
|
+
// (src/headless.js :: formatFoldOutput). Without it a missing nonce emits
|
|
55
|
+
// `[SIDECAR_FOLD:]`, which the hex-only marker regex
|
|
56
|
+
// (src/utils/fold-marker.js :: extractNonceFromText) never parses — a
|
|
57
|
+
// silently unfoldable block.
|
|
55
58
|
if (!o || !o.nonce) { throw new TypeError('buildFoldText requires a per-run nonce (v4.0 §9)'); }
|
|
56
59
|
const run = o.run || {};
|
|
57
60
|
const verdict = ok(o.verdict);
|
|
58
61
|
const tally = ok(o.tally);
|
|
59
62
|
// Review follow-up #2: verdict.json is NOT re-validated here (the
|
|
60
63
|
// amicus_verdict MCP path types overallVerdict as a bare z.string().nullable()
|
|
61
|
-
// — mcp-tools.js
|
|
62
|
-
// reach the head verbatim: an embedded '\n' would shift every line below
|
|
64
|
+
// — mcp-tools.js :: amicus_verdict), so a multi-line or marker-bearing value
|
|
65
|
+
// must never reach the head verbatim: an embedded '\n' would shift every line below
|
|
63
66
|
// VERDICT: (a raw string containing '\n' becomes several elements once the
|
|
64
67
|
// head array is '\n'-joined), and an embedded marker could spoof the fold.
|
|
65
68
|
// Safe on the shipped engine path (parseChairVerdict returns a canonical
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* awareness.js) is UNVERSIONED (F63) and carries: {taskId, type, runId,
|
|
11
11
|
* runDir, status, currentStage, stages:[{name,status,waveId}], legsTotal,
|
|
12
12
|
* legsComplete, elapsed, exitCode, version, view:'live', usage?, reason?,
|
|
13
|
-
* legs:[{taskId, model, modelInput, role, status, messages, stage,
|
|
13
|
+
* legs:[{taskId, model, modelInput, role, seat, status, messages, stage,
|
|
14
14
|
* latestPreview, lastActivityAt, stalled, usage?}], stalled?,
|
|
15
15
|
* stalledForSeconds?}. Do NOT copy the WAVE doc's shape (src/mcp-server.js:
|
|
16
16
|
* 592-662) — that is a different document, gated on metadata.type === 'wave'.
|
|
@@ -42,7 +42,7 @@ function seatOf(leg) {
|
|
|
42
42
|
// ⚠️ DE-ROT (F34/F36): `model` and `modelInput` are TWO SEPARATE fields, never collapsed.
|
|
43
43
|
// A live leg's `model` is the resolved executable id (e.g. `google/gemini-2.5`); `modelInput`
|
|
44
44
|
// is the council ALIAS (e.g. `gemini`) that run.json's labelMap and blind mode's labelFor()
|
|
45
|
-
// key on (src/council/anonymize.js
|
|
45
|
+
// key on (src/council/anonymize.js :: assignLabels stamps labelMap values from the alias, never the
|
|
46
46
|
// resolved id). The already-shipped electron/workspace-ui/live-model.js:55 reads
|
|
47
47
|
// `seat.modelInput || seat.model` to pick the alias for its label lookup — collapsing the two
|
|
48
48
|
// into one field here would silently break blind mode (a resolved-id lookup never matches
|
|
@@ -50,6 +50,10 @@ function seatOf(leg) {
|
|
|
50
50
|
model: leg.model || null,
|
|
51
51
|
modelInput: leg.modelInput || null,
|
|
52
52
|
role: leg.role || null,
|
|
53
|
+
// ⚠️ v4.8 R5: the leg's seat id, and the reason live-dead-seats.js:209's
|
|
54
|
+
// `if (s.seat)` arm is reachable at all. Null on a unique-alias bench, so the
|
|
55
|
+
// guard there stays load-bearing — see the comment at that line.
|
|
56
|
+
seat: leg.seat || null,
|
|
53
57
|
status: leg.status || 'unknown',
|
|
54
58
|
// ⚠️ PRE-FLIGHT (P5): `leg.phase` is dead weight — Task 0.5 does not emit it. `leg.stage` IS
|
|
55
59
|
// emitted (src/observe/council-legs.js:88), so it is the only source; no fallback to invent.
|
|
@@ -2,26 +2,50 @@
|
|
|
2
2
|
* Council Workspace — adjudication matrix view model (v4.4 §5.2).
|
|
3
3
|
*
|
|
4
4
|
* Pure: tally.json + labelMap (+ verdict.json) → renderable rows/cells.
|
|
5
|
-
* Symbols come from council/report.js SYMBOL
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Symbols come from council/report.js SYMBOL, and the seat-space decision from
|
|
6
|
+
* its isSeatSpace (single source — the report and the workspace can never
|
|
7
|
+
* disagree about what the symbols mean, nor about which space a document is
|
|
8
|
+
* in; the latter added by council review A3/B1). Every name-bearing field
|
|
9
|
+
* carries BOTH spellings ({model, label}) so the renderer's blind toggle is a
|
|
10
|
+
* pure display flip with no re-fetch. Missing
|
|
9
11
|
* votes (partial waves) are blank cells, never invented neutrals — tier math
|
|
10
12
|
* already excluded them (v4.0).
|
|
11
13
|
*
|
|
12
14
|
* ⚠️ DE-ROT (F07): `tally()` writes `tierOverride: null` on EVERY finding,
|
|
13
|
-
* unconditionally (src/council/tally.js
|
|
15
|
+
* unconditionally (src/council/tally.js :: tally) — it is never a real source for
|
|
14
16
|
* either the override badge or the post-override tier. Only `buildVerdict`
|
|
15
17
|
* materializes `{from,to,reason}` and rewrites `tier` to `tierOverride.to`
|
|
16
|
-
* (src/council/verdict.js:
|
|
18
|
+
* (src/council/verdict.js:122-126). So both fields are joined in from
|
|
17
19
|
* verdict.findings[] by `id`; when verdict is absent/unparseable (caller
|
|
18
20
|
* passes null/undefined, or a finding has no verdict-side counterpart) the
|
|
19
21
|
* row falls back to tally's own (pre-override) tier and renders no badge.
|
|
20
22
|
*/
|
|
21
23
|
'use strict';
|
|
22
24
|
|
|
23
|
-
const { SYMBOL } = require('../council/report');
|
|
24
|
-
const { pairFor } = require('./blind-mode');
|
|
25
|
+
const { SYMBOL, isSeatSpace } = require('../council/report');
|
|
26
|
+
const { labelFor, pairFor } = require('./blind-mode');
|
|
27
|
+
|
|
28
|
+
// v4.8 T-C2 (SI-22.5, ruling R18): the ONE column every vote whose key names no
|
|
29
|
+
// column folds into. A SECOND implementation on purpose — ruling R17 took the
|
|
30
|
+
// narrow option, so this is NOT imported from src/council/report.js and nothing
|
|
31
|
+
// is extracted for it. Two implementations can DRIFT — T-C2 shipped one document
|
|
32
|
+
// rendering two ways for a single commit — so the rule below is pinned as an
|
|
33
|
+
// AGREEMENT with report.js, not merely as this file's own behaviour:
|
|
34
|
+
// tests/council/seat-matrix.test.js drives BOTH consumers over that document.
|
|
35
|
+
const UNATTRIBUTED = 'UNATTRIBUTED';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A finding's adjudications AS AN ARRAY, whatever the document actually carries.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ ONE expression, TWO readers, and that is the whole reason it is a function.
|
|
41
|
+
* The `folded` pre-pass and the per-finding loop both walk this list, and in
|
|
42
|
+
* report.js the identical pair drifted apart on type for one commit — `.some`
|
|
43
|
+
* is Array-only while `for...of` takes any iterable, so `adjudications: "abc"`
|
|
44
|
+
* rendered on one side and threw on the other. A non-array contributes no
|
|
45
|
+
* votes: the same answer isSeatSpace gives a malformed seats table, and the
|
|
46
|
+
* same answer this expression already gave inline before T-C2 hoisted it.
|
|
47
|
+
*/
|
|
48
|
+
function adjOf(f) { return Array.isArray(f.adjudications) ? f.adjudications : []; }
|
|
25
49
|
|
|
26
50
|
/** Index verdict.findings[] by id, tolerating an absent/malformed verdict doc. */
|
|
27
51
|
function indexVerdictFindings(verdict) {
|
|
@@ -44,16 +68,111 @@ function indexVerdictFindings(verdict) {
|
|
|
44
68
|
*/
|
|
45
69
|
function buildMatrixModel(tally, labelMap, verdict) {
|
|
46
70
|
const map = labelMap || {};
|
|
47
|
-
const
|
|
71
|
+
const meta = (tally && tally.meta) || {};
|
|
72
|
+
const aliasJudges = Array.isArray(meta.models) ? meta.models : [];
|
|
73
|
+
// v4.8 PR4c §3.6 (R4c-8): the IDENTICAL decision report.js applies to
|
|
74
|
+
// verdict.seats, here over tally.meta.seats — and since council A3/B1, the
|
|
75
|
+
// identical FUNCTION rather than a second copy of the expression.
|
|
76
|
+
// run-detail.js already hands this function the parsed tally.json, so the
|
|
77
|
+
// 3-arg signature is unchanged. One flag for all three readers (roster, vote
|
|
78
|
+
// key, raiser); see isSeatSpace for why every element must carry a string id
|
|
79
|
+
// and why `??` is wrong.
|
|
80
|
+
const seatSpace = isSeatSpace(meta.seats);
|
|
81
|
+
// report.js filters the reserved claude seat out of ITS roster; this one
|
|
82
|
+
// never has — tally.meta.models carries `claude` (run-assemble.js appends it)
|
|
83
|
+
// and HEAD renders a blank column for it. seats[] is bench-only, so a seat
|
|
84
|
+
// roster would silently DELETE that column; re-append it so the only thing a
|
|
85
|
+
// claude run's matrix changes is the twin split.
|
|
86
|
+
const claudeTail = meta.claudeInCouncil === true && aliasJudges.includes('claude') ? ['claude'] : [];
|
|
87
|
+
// A column is {key, pair}: `key` is what votes and the raiser are matched
|
|
88
|
+
// against, `pair` is what the renderer shows.
|
|
89
|
+
// ⚠️ BLIND MODE: resolve the label from the seat's ALIAS and carry the seat's
|
|
90
|
+
// ID only as identity. `pairFor(seat.id, map)` returns label:null (labelMap's
|
|
91
|
+
// values are aliases), and workspace-render.js's display() then falls back to
|
|
92
|
+
// pair.model and prints `deepseek#1` with blind mode ON. A seat id contains
|
|
93
|
+
// its alias, so rendering one in blind mode defeats blind mode. Both twins
|
|
94
|
+
// therefore collapse to `Review A` when blind — exactly as at HEAD.
|
|
95
|
+
const bench = seatSpace
|
|
96
|
+
? meta.seats.map(s => ({ key: s.id, pair: { model: s.id, label: labelFor(s.alias, map) } }))
|
|
97
|
+
.concat(claudeTail.map(j => ({ key: j, pair: pairFor(j, map) })))
|
|
98
|
+
: aliasJudges.map(j => ({ key: j, pair: pairFor(j, map) }));
|
|
48
99
|
const findings = tally && Array.isArray(tally.findings) ? tally.findings : [];
|
|
49
100
|
const verdictById = indexVerdictFindings(verdict);
|
|
101
|
+
// v4.8 T-C2 (SI-22.5): CLASSIFY the key instead of trusting it. At 32a63e92 the
|
|
102
|
+
// domain split in two and BOTH halves lost the vote: `typeof adj.judge !==
|
|
103
|
+
// 'string'` refused an absent or non-string judge outright — in seat space
|
|
104
|
+
// testing a field that is not even the key, so a valid seat with a numeric
|
|
105
|
+
// judge rendered nowhere — while an orphaned seat id and `''` were written to
|
|
106
|
+
// a `votes` key no column reads. The roster is what makes a key mean a column,
|
|
107
|
+
// so `keys.has` is the orphan test, and the three conjuncts refuse, in order, a
|
|
108
|
+
// non-string, the empty string, and a string naming no column.
|
|
109
|
+
// ⚠️ `key !== ''` IS NOT REDUNDANT WITH `keys.has`, and T-C2 shipped it wrong
|
|
110
|
+
// for one commit, which is why the reason is written down. `isSeatSpace`
|
|
111
|
+
// accepts `{id: ''}`, so a roster CAN hold `''` — and matching a `''` roster
|
|
112
|
+
// key against a `''` judge matches TWO NON-IDENTITIES. That is structurally
|
|
113
|
+
// the defect v4.8 T-B4 removed from src/council/peer-split.js :: peersOf, where
|
|
114
|
+
// a falsy raiser matched a falsy judge and corroborated its own finding.
|
|
115
|
+
// Rulings R18 and R2 both say `''` is not an identity, and a malformed roster
|
|
116
|
+
// carrying `''` does not make it into a name. Without the conjunct the two
|
|
117
|
+
// consumers rendered ONE document differently — the desync class PR B exists to
|
|
118
|
+
// remove — so this is now the SAME classification rule report.js applies, over
|
|
119
|
+
// this file's own roster. Written again, never shared (R17); a pin drives BOTH
|
|
120
|
+
// consumers over that document so the agreement is enforced, not re-derived.
|
|
121
|
+
// Named mutants, with their measured red sets:
|
|
122
|
+
// tests/council/seat-matrix.test.js :: WSJUNKKEY and
|
|
123
|
+
// tests/council/seat-matrix.test.js :: WSEMPTYOK.
|
|
124
|
+
const keys = new Set(bench.map(c => c.key));
|
|
125
|
+
const columnFor = (adj) => {
|
|
126
|
+
const key = (seatSpace && adj.seat) || adj.judge;
|
|
127
|
+
return (typeof key === 'string' && key !== '' && keys.has(key)) ? key : UNATTRIBUTED;
|
|
128
|
+
};
|
|
129
|
+
// ⚠️ TWO-PHASE, and here it could not be anything else: `cells` is built by
|
|
130
|
+
// MAPPING this roster, so a roster decided inside the per-finding map would
|
|
131
|
+
// give two rows DIFFERENT CELL COUNTS — a body that no longer matches its own
|
|
132
|
+
// header. The pre-pass therefore walks every finding before the map starts.
|
|
133
|
+
// ⚠️ CONDITIONAL: added unconditionally it grows a column on every matrix that
|
|
134
|
+
// has no such vote. Named mutant: tests/council/seat-matrix.test.js :: WSALWAYSCOL.
|
|
135
|
+
// ⚠️ `!keys.has(UNATTRIBUTED)`: a bench model literally aliased UNATTRIBUTED
|
|
136
|
+
// already owns that column and R18 says ONE column. It then SHARES its cell
|
|
137
|
+
// with the folded votes — disclosed, not fixed, because the column key is also
|
|
138
|
+
// what the fold writes and separating them needs a renderer change.
|
|
139
|
+
// ⚠️ LAST, after `claudeTail`: the fold column is not a bench member, and
|
|
140
|
+
// appending keeps every existing column at the index it had at 32a63e92.
|
|
141
|
+
// `concat` for uniformity with the `claudeTail` append above, NOT for safety:
|
|
142
|
+
// report.js needs it because its alias roster IS `verdict.council` by
|
|
143
|
+
// reference, and measured, this file has no such trap — every branch of
|
|
144
|
+
// `bench` ends in a `.map`, which always allocates.
|
|
145
|
+
// ⚠️ BLIND MODE — both name slots carry the same literal rather than
|
|
146
|
+
// `pairFor(UNATTRIBUTED, map)`. UNATTRIBUTED has no alias to protect and no
|
|
147
|
+
// identity to reveal, so the flip must be a no-op on it BY CONSTRUCTION, not
|
|
148
|
+
// by `labelFor` happening to return null — which it stops doing the moment a
|
|
149
|
+
// labelMap value IS `UNATTRIBUTED`, when the blind header would print a review
|
|
150
|
+
// label over a column of nobody's votes.
|
|
151
|
+
// Named mutant: tests/council/seat-matrix.test.js :: WSPAIRFOR.
|
|
152
|
+
const folded = findings.some(f => adjOf(f).some(a => a && columnFor(a) === UNATTRIBUTED));
|
|
153
|
+
const columns = folded && !keys.has(UNATTRIBUTED)
|
|
154
|
+
? bench.concat([{ key: UNATTRIBUTED, pair: { model: UNATTRIBUTED, label: UNATTRIBUTED } }])
|
|
155
|
+
: bench;
|
|
50
156
|
|
|
51
157
|
const rows = findings.map((f) => {
|
|
52
158
|
const votes = {};
|
|
53
|
-
for (const adj of (
|
|
54
|
-
|
|
55
|
-
|
|
159
|
+
for (const adj of adjOf(f)) {
|
|
160
|
+
// ⚠️ HALF of 32a63e92's guard survives, and that half is doing work: `!adj`
|
|
161
|
+
// skips a falsy element, which carries no verdict to fold and which
|
|
162
|
+
// `columnFor` would dereference.
|
|
163
|
+
// ⚠️ THE STRICTNESS DIFFERENCE THIS COMMENT USED TO RECORD IS GONE. It read
|
|
164
|
+
// "report.js has no such guard and THROWS on that document"; v4.8 T-C4 gave
|
|
165
|
+
// `report.js :: adjOf` a `.filter(Boolean)`, which is this predicate spelled
|
|
166
|
+
// a second time — R17, two implementations, never shared. That file no longer
|
|
167
|
+
// throws on a falsy element and no longer grows a phantom column for one.
|
|
168
|
+
// The `typeof adj.judge` half is GONE, subsumed by the classification
|
|
169
|
+
// above: it refused votes where R18 requires them folded.
|
|
170
|
+
if (!adj) { continue; }
|
|
171
|
+
votes[columnFor(adj)] = adj.verdict;
|
|
56
172
|
}
|
|
173
|
+
// The raiser's column key. In alias space this is `f.raiser` and every
|
|
174
|
+
// expression below is byte-identical to HEAD.
|
|
175
|
+
const raiserKey = seatSpace ? (f.raiserSeat || f.raiser) : f.raiser;
|
|
57
176
|
const vf = verdictById.get(f.id);
|
|
58
177
|
return {
|
|
59
178
|
id: f.id,
|
|
@@ -62,29 +181,32 @@ function buildMatrixModel(tally, labelMap, verdict) {
|
|
|
62
181
|
thin: f.confidence === 'thin',
|
|
63
182
|
tierOverride: (vf && vf.tierOverride) || null,
|
|
64
183
|
// ⚠️ DE-ROT (F29): v4.1 decorates tally.json findings in place with
|
|
65
|
-
// `debate: {action, previousTier}` (src/council/debate.js
|
|
184
|
+
// `debate: {action, previousTier}` (src/council/debate.js :: decorateRecord; action ∈
|
|
66
185
|
// defended|amended|withdrawn|no-response) and verdict.json carries it through
|
|
67
186
|
// (src/council/verdict.js:43). Consumed by electron/workspace-ui/workspace-matrix.js's
|
|
68
187
|
// renderMatrix, which renders a `.debate-badge` in the tier cell (alongside the
|
|
69
188
|
// thin/tierOverride badges) so a withdrawn/amended/defended/no-response finding never
|
|
70
189
|
// renders as an ordinary live row. Absent on non-debate runs, hence `|| null`.
|
|
71
190
|
debate: f.debate || null,
|
|
72
|
-
|
|
191
|
+
// The THIRD reader. Its label still resolves from the ALIAS (blind mode
|
|
192
|
+
// must not leak a seat id here either), while its identity is the seat —
|
|
193
|
+
// otherwise the starred column and the Raiser cell name different things.
|
|
194
|
+
raiser: { model: raiserKey, label: labelFor(f.raiser, map) },
|
|
73
195
|
basis: f.basis || { a: 0, d: 0, n: 0 },
|
|
74
|
-
cells:
|
|
75
|
-
const vote = Object.prototype.hasOwnProperty.call(votes,
|
|
196
|
+
cells: columns.map((c) => {
|
|
197
|
+
const vote = Object.prototype.hasOwnProperty.call(votes, c.key) ? votes[c.key] : null;
|
|
76
198
|
return {
|
|
77
|
-
judge:
|
|
199
|
+
judge: c.pair,
|
|
78
200
|
verdict: vote,
|
|
79
201
|
sym: vote ? (SYMBOL[vote] || '?') : ' ',
|
|
80
|
-
isRaiser:
|
|
202
|
+
isRaiser: c.key === raiserKey,
|
|
81
203
|
};
|
|
82
204
|
}),
|
|
83
205
|
};
|
|
84
206
|
});
|
|
85
207
|
|
|
86
208
|
return {
|
|
87
|
-
judges:
|
|
209
|
+
judges: columns.map((c) => c.pair),
|
|
88
210
|
rows,
|
|
89
211
|
tierCounts: (tally && tally.tierCounts) || null,
|
|
90
212
|
judged: !(tally && tally.judged === false),
|
|
@@ -13,7 +13,7 @@ const { formatCost } = require('../utils/pricing');
|
|
|
13
13
|
const { readPointer } = require('./run-scan');
|
|
14
14
|
const { buildNamePairs } = require('./blind-mode');
|
|
15
15
|
const { buildMatrixModel } = require('./matrix-model');
|
|
16
|
-
const { artifactAllowlist } = require('./artifact-guard');
|
|
16
|
+
const { artifactAllowlist, isSeatTable } = require('./artifact-guard');
|
|
17
17
|
const { isRealpathContained } = require('../utils/path-fence');
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -73,6 +73,13 @@ function costPanel(run, tally) {
|
|
|
73
73
|
const stats = tally && Array.isArray(tally.runStats) ? tally.runStats : [];
|
|
74
74
|
const rows = stats.map((r) => ({
|
|
75
75
|
model: r.model,
|
|
76
|
+
// v4.8 PR5a T3: carry the seat. The engine computes it, writes it to tally.json and
|
|
77
|
+
// verdict.json, and this five-key projection was throwing it away one function before
|
|
78
|
+
// the renderer. ⚠️ Its consumer (renderCost) lands in PR5b, so this is honestly a
|
|
79
|
+
// payload-shape change here, not a visible one — shipping it now keeps PR5b from
|
|
80
|
+
// needing a src/ change of its own. Emit-when-set: a unique bench has no seat on any
|
|
81
|
+
// row, so the payload is byte-identical there.
|
|
82
|
+
...(r.seat ? { seat: r.seat } : {}),
|
|
76
83
|
role: r.role || (r.wasChair ? 'chair' : 'seat'),
|
|
77
84
|
status: r.status || 'unknown',
|
|
78
85
|
durationMs: r.durationMs === undefined ? null : r.durationMs,
|
|
@@ -117,13 +124,13 @@ function costPanel(run, tally) {
|
|
|
117
124
|
|
|
118
125
|
// ⚠️ PRE-FLIGHT (P3): F04's correction is implemented here rather than left as prose.
|
|
119
126
|
// VERIFIED on shipped main (Task 0): `finalize(exitCode, error)` writes `error: error || null`
|
|
120
|
-
// (
|
|
127
|
+
// (run-finalize.js :: writeRunTerminal), and `return finalize(degraded.value ? 2 : 0)` (run.js:279) is the ONLY
|
|
121
128
|
// exit-2 path — it passes NO error. Every error-bearing call is `finalize(1, …)`. So on a
|
|
122
129
|
// `status:'partial'` run — precisely the run this panel exists to explain — `run.error` is
|
|
123
130
|
// GUARANTEED null, and the old one-line formula rendered "undefined: undefined".
|
|
124
131
|
// Name the stage instead. Stage status is a closed set (DE-ROT F19): running / complete /
|
|
125
|
-
// skipped / error, and `run-chair.js
|
|
126
|
-
// fallback promotion, 'skipped' (
|
|
132
|
+
// skipped / error, and `run-chair.js :: chairStatus` writes 'error' for a chair that failed after retry +
|
|
133
|
+
// fallback promotion, 'skipped' (run-chair.js :: skippedForCost) for one the cost ceiling skipped.
|
|
127
134
|
function degradedReason(run) {
|
|
128
135
|
// exit-1 path: the engine wrote a structured {code, message}.
|
|
129
136
|
if (run.error && run.error.code) { return `${run.error.code}: ${run.error.message}`; }
|
|
@@ -215,6 +222,25 @@ function getRunDetail(project, runId) {
|
|
|
215
222
|
// pre-v4.5 runs, live-doc consumers not yet updated) apart from a real map, and fall back
|
|
216
223
|
// to its legacy sanitizeName(model) computation only in the former case.
|
|
217
224
|
artifactsByModel: artifactNames.artifactsByModel || null,
|
|
225
|
+
// ⚠️ v4.8 PR5a fix-wave (council A1/B1): WHICH SPACE the payload above is in, decided
|
|
226
|
+
// by the one predicate artifactAllowlist itself gates on (isSeatTable, re-exported
|
|
227
|
+
// through artifact-guard). The renderer is a plain browser script and cannot
|
|
228
|
+
// require() it, and the alternative — re-spelling the predicate in workspace-lazy.js
|
|
229
|
+
// the way sanitizeName/TERMINAL_STATUSES are hand-copied — is exactly the drift that
|
|
230
|
+
// caused the finding: roster() gated on `!seats || !seats.length`, which is strictly
|
|
231
|
+
// weaker, so a malformed seats[] sent this map to ALIAS space while the roster stayed
|
|
232
|
+
// in SEAT space and every artifactsByModel lookup missed. Shipping the ANSWER instead
|
|
233
|
+
// of a copy of the question makes that divergence unrepresentable.
|
|
234
|
+
seatSpace: isSeatTable(run.seats),
|
|
235
|
+
// ⚠️ Fix-wave 3 (council-3 C2): seats[] is PRESENT but unusable, so the run silently
|
|
236
|
+
// lost per-seat behaviour — two seats on one model become indistinguishable in every
|
|
237
|
+
// panel. isSeatTable fails WHOLE (one malformed entry drops the entire table), which
|
|
238
|
+
// is the fail-safe direction but is exactly the correct-but-silent degrade the product
|
|
239
|
+
// principle rejects. Emitted here, beside the predicate that decides it, rather than
|
|
240
|
+
// re-derived renderer-side; workspace-app.js's renderBanners is its only consumer.
|
|
241
|
+
// NOT the same as `!seatSpace`: a run with no seats[] at all is a legacy run, not a
|
|
242
|
+
// broken one, and must not be bannered.
|
|
243
|
+
seatTableRejected: Array.isArray(run.seats) && run.seats.length > 0 && !isSeatTable(run.seats),
|
|
218
244
|
};
|
|
219
245
|
}
|
|
220
246
|
|