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
|
@@ -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,
|
|
@@ -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
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — the seat-space PREDICATES (v4.8 PR5b).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from artifact-names.js, which was at 300/300 with zero headroom — and which was
|
|
5
|
+
* itself split out of artifact-guard.js for the same gate one rev earlier. Comment prose was
|
|
6
|
+
* shaved three times during PR5a's fix waves to land defect fixes inside the limit; that was
|
|
7
|
+
* the signal this seam was overdue, not that the comments were too long.
|
|
8
|
+
*
|
|
9
|
+
* Split line: this module answers two QUESTIONS ABOUT A RUN — "is this seat table usable?"
|
|
10
|
+
* and "what has an orphan note proven?" — from run.json fields alone (`seats`, `degrades`,
|
|
11
|
+
* `runId`). It derives no names and knows nothing about artifacts. artifact-names.js keeps
|
|
12
|
+
* every NAME decision and imports both answers.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Pure and total: no I/O, no fs, no throw path. Both functions take a schema-free
|
|
15
|
+
* `JSON.parse` of run.json and must tolerate any shape, which is why the conjuncts below are
|
|
16
|
+
* spelled out rather than assumed.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ The workspace -> council import lives here now (isSeatSpace, src/council/report.js).
|
|
19
|
+
* That direction is deliberate and predates this split: ONE seat-space predicate for the
|
|
20
|
+
* whole tree, never re-spelled — it is defined and exported at
|
|
21
|
+
* src/council/report.js :: isSeatSpace, and
|
|
22
|
+
* src/workspace/matrix-model.js:25 already imports it the same way. Council-4's B3 called the
|
|
23
|
+
* layering an inversion; it is not fixed here, and moving it OUT of the security-critical
|
|
24
|
+
* name-derivation file is a side effect of this split, not a response to that finding.
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { isSeatSpace } = require('../council/report');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Is `seats` a seat table this guard may trust?
|
|
32
|
+
*
|
|
33
|
+
* `isSeatSpace` (shared, src/council/report.js) was written for `verdict.seats`, a
|
|
34
|
+
* producer-controlled document. This function reads a schema-free `JSON.parse` of
|
|
35
|
+
* run.json, so it adds three conjuncts `isSeatSpace` has no reason to carry:
|
|
36
|
+
* - non-empty ids — `{id:''}` otherwise emits `review-.md` and a `""` map key;
|
|
37
|
+
* - unique ids — the alias path's `new Set` used to guarantee this, and without it
|
|
38
|
+
* duplicate ids mint a one-element "collision" whose `join(' and ')` renders
|
|
39
|
+
* malformed English AND suppresses the real run.error banner (workspace-app.js
|
|
40
|
+
* returns after the collision branch);
|
|
41
|
+
* - STRING aliases (council-2 C3) — `isSeatSpace` checks only `id`, so `[{id:'a#1'}]` passed
|
|
42
|
+
* and put the RENDERER in seat space, where roster() resolves `labelByModel[s.alias]` ->
|
|
43
|
+
* undefined and AmicusRender.display() falls through to `pair.model`, printing the seat id
|
|
44
|
+
* `a#1` with blind mode ON — which defeats blind mode, a seat id containing its alias. The
|
|
45
|
+
* renderer now trusts this predicate wholesale (council-1 B1), so a conjunct the ALIAS
|
|
46
|
+
* path depends on belongs in it.
|
|
47
|
+
* Fails WHOLE: one malformed id sends the run back to the alias branch. Fail-safe but
|
|
48
|
+
* silent, and only reachable from a hand-edited run.json — parseList trims and filters,
|
|
49
|
+
* and the MCP path re-joins through the same code.
|
|
50
|
+
*/
|
|
51
|
+
function isSeatTable(seats) {
|
|
52
|
+
if (!isSeatSpace(seats)) { return false; }
|
|
53
|
+
const ids = seats.map(s => s.id);
|
|
54
|
+
return ids.every(id => id !== '') && new Set(ids).size === ids.length
|
|
55
|
+
&& seats.every(s => typeof s.alias === 'string' && s.alias !== '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The alias names an ORPHANED leg wrote, taken from run.json's own degrade notes.
|
|
60
|
+
*
|
|
61
|
+
* The engine names an artifact from the seat when a leg binds and from the leg's
|
|
62
|
+
* `modelInput || model` when it does not (run-launch.js :: materializeReviews).
|
|
63
|
+
* Only the second case puts
|
|
64
|
+
* an alias-named file on disk, and `run-degrade.js` records exactly that case: channel
|
|
65
|
+
* 'seat-unbound' with `data.legId` set. `data.seat` on such a note IS the string the writer
|
|
66
|
+
* used, so the name derived here matches the file byte-for-byte — including where the leg
|
|
67
|
+
* reported no modelInput and the writer fell back to the resolved model id, which no
|
|
68
|
+
* seat.alias could have reproduced. Gating fallbacks on the NOTE, rather than emitting one
|
|
69
|
+
* per seat alias, is what keeps a healthy run (twin, --debate, or with a merely-dead seat)
|
|
70
|
+
* from claiming names nobody wrote and raising a run-integrity banner for it.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ Fix-wave revision 2 (council-1 B2, then council-2 A1/C1 which refuted revision 1).
|
|
73
|
+
* Contesting DROPS the owning seat's attribution, so the question is never "can the orphan
|
|
74
|
+
* own this kind?" — absence of evidence is not evidence, and reading it that way handed a
|
|
75
|
+
* bound seat a `rebuttal-` an orphan may have written, re-arming RN-1 in the debate
|
|
76
|
+
* namespace (MEASURED; the one regression this fix wave introduced). The question is:
|
|
77
|
+
* does the note POSITIVELY PROVE the orphan did NOT write this kind? Exactly one such
|
|
78
|
+
* proof exists. `orphanLegNote` has three call sites — run-stages.js:71 and :140 (Stage-1
|
|
79
|
+
* and its retry) and run-stage2.js:110 (the -s2 judge wave) — and `data.waveId` separates
|
|
80
|
+
* them EXACTLY, not heuristically: run-stage2.js:67/69/90 build it as `${runId}-s2` from
|
|
81
|
+
* the runId this run.json carries. A -s2 note says the leg BOUND in Stage 1, so its review
|
|
82
|
+
* landed under a SEAT name: `review-<alias>.md` is provably not its. Nothing else is
|
|
83
|
+
* provable, and the near misses are why:
|
|
84
|
+
* - a Stage-1 note exonerates NOTHING, not even judge-: that orphan is re-admitted to
|
|
85
|
+
* Stage 2 under a PLACEHOLDER seat (run-stage2.js:92-97) that `judgeSeatOf` filters out
|
|
86
|
+
* (:105-107), so its judge leg takes the alias branch too — and emits no -s2 note,
|
|
87
|
+
* because it BOUND. Measured: run-stages.test.js :: "M2: the placeholder never becomes…".
|
|
88
|
+
* - rebuttal-/revote- are never exonerated: a debate leg whose raiser/judge key names no
|
|
89
|
+
* seat takes materializeDebate's alias branch (run-debate.js :: runDebate;
|
|
90
|
+
* run-debate-revote.js :: runRevoteWave, `seat = seatOf.get(leg) || null`). A rebuttal
|
|
91
|
+
* leg's unbound raiser still has no note recording it. A revote leg's unbound judge
|
|
92
|
+
* SOMETIMES gets one, since v4.8 T5.1 (channel `seat-unbound`, run-debate-revote.js's
|
|
93
|
+
* reVoteUnboundNote). ⚠️ SOMETIMES, not always — this read "DOES" until the
|
|
94
|
+
* whole-branch fix wave, and that was FALSE. The bullet's subject is EVERY debate leg
|
|
95
|
+
* with `seat === null`, and that set is STRICTLY LARGER than the set that gets a note.
|
|
96
|
+
* Two shapes land here SILENTLY — measured through the real runRevoteWave, not
|
|
97
|
+
* reasoned: a leg bound to a §3.4 PLACEHOLDER, and a leg bound to nothing whose
|
|
98
|
+
* bare-alias key IS one of the `judgeKeys` this wave launched. runRevoteWave publishes
|
|
99
|
+
* BOTH and notes neither, while
|
|
100
|
+
* `seatOf` still filters the placeholder back out — so `seat` is null in all three
|
|
101
|
+
* shapes and a note exists for exactly one of them.
|
|
102
|
+
* ⚠️ The PREDICATE that publishes both is `judgeKeys.includes(key)` ALONE. This read
|
|
103
|
+
* `boundLegs.has(leg) || judgeKeys.includes(key)` until v4.8 T5.5 deleted the first
|
|
104
|
+
* arm; the §3.4 placeholder shape survives that deletion on the SECOND arm, because a
|
|
105
|
+
* roster hole's own bare alias IS one of the judgeKeys — so both shapes above still
|
|
106
|
+
* land here, and this bullet is unchanged in substance. What no longer lands silently
|
|
107
|
+
* is a placeholder-bound leg carrying a FOREIGN alias: it is now refused and noted
|
|
108
|
+
* (run-debate.test.js's "T5.5: a taskId-bound leg carrying a FOREIGN alias is
|
|
109
|
+
* REFUSED" block), so the silent set shrank by exactly that shape. Pinned by run-debate.test.js's
|
|
110
|
+
* test named "roster hole whose leg is ALSO unbindable: the key IS published and the
|
|
111
|
+
* re-vote still applies", whose `ctx.degrade.all()` is `[]`; the placeholder shape is
|
|
112
|
+
* pinned for its seat (`['gpt', null]`) by that file's "§3.4 placeholder contract at
|
|
113
|
+
* the -rv call site" block, which asserts nothing about notes either way.
|
|
114
|
+
* ⚠️ The CONCLUSION is identical in all three shapes, which is why this bullet still
|
|
115
|
+
* holds: even when the note IS emitted, its `data` deliberately omits `seat`, carrying
|
|
116
|
+
* only `judge`/`key`, so this function's own `const alias = d.data.seat;` read (below)
|
|
117
|
+
* still finds nothing and this leg stays un-exonerated, the same net effect as before
|
|
118
|
+
* (reVoteUnboundNote's own docblock states why `seat` is withheld on purpose).
|
|
119
|
+
* - an unmatchable waveId exonerates nothing.
|
|
120
|
+
* @returns {Map<string, Set<string>>} orphan alias -> the kinds it provably did NOT write,
|
|
121
|
+
* in first-occurrence order (what keeps the emitted name list byte-identical).
|
|
122
|
+
*/
|
|
123
|
+
const S2_EXONERATES = Object.freeze(['review']);
|
|
124
|
+
function orphanExonerations(run) {
|
|
125
|
+
const degrades = run && Array.isArray(run.degrades) ? run.degrades : [];
|
|
126
|
+
const s2WaveId = run && typeof run.runId === 'string' ? `${run.runId}-s2` : null;
|
|
127
|
+
const byAlias = new Map();
|
|
128
|
+
for (const d of degrades) {
|
|
129
|
+
if (!d || d.channel !== 'seat-unbound' || !d.data || !d.data.legId) { continue; }
|
|
130
|
+
const alias = d.data.seat;
|
|
131
|
+
if (typeof alias !== 'string' || alias === '') { continue; }
|
|
132
|
+
// ⚠️ `has`, not `||` (council-3 B1): an empty Set is TRUTHY, so `||` worked — a cleared
|
|
133
|
+
// set stayed cleared — but read as default-when-missing. A `.size` check re-opens the union.
|
|
134
|
+
const proven = byAlias.has(alias) ? byAlias.get(alias) : new Set(S2_EXONERATES);
|
|
135
|
+
// INTERSECTION across an alias's notes, never union: two notes for one alias means two
|
|
136
|
+
// orphaned legs, and a Stage-1 one proves nothing about the review the -s2 one exonerates.
|
|
137
|
+
if (!(s2WaveId && d.data.waveId === s2WaveId)) { proven.clear(); }
|
|
138
|
+
byAlias.set(alias, proven);
|
|
139
|
+
}
|
|
140
|
+
return byAlias;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { isSeatTable, orphanExonerations };
|