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.
Files changed (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +948 -1
  3. package/README.md +3 -2
  4. package/docs/CITATIONS.md +114 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +3 -3
  8. package/docs/council.md +181 -34
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/testing.md +1 -1
  12. package/docs/troubleshooting.md +6 -4
  13. package/docs/usage.md +15 -11
  14. package/electron/setup-ui-aliases.js +2 -2
  15. package/electron/workspace-ui/index.html +9 -0
  16. package/electron/workspace-ui/live-dead-seats.js +228 -0
  17. package/electron/workspace-ui/live-model.js +10 -236
  18. package/electron/workspace-ui/live-seats.js +126 -0
  19. package/electron/workspace-ui/workspace-app.js +6 -41
  20. package/electron/workspace-ui/workspace-banners.js +95 -0
  21. package/electron/workspace-ui/workspace-lazy.js +55 -12
  22. package/electron/workspace-ui/workspace-matrix.js +2 -2
  23. package/electron/workspace-ui/workspace-panels.js +42 -10
  24. package/electron/workspace-ui/workspace-render.js +2 -2
  25. package/electron/workspace-ui/workspace-seats.js +101 -17
  26. package/package.json +6 -3
  27. package/schemas/council-run-live.schema.json +1 -0
  28. package/schemas/council-run.schema.json +19 -0
  29. package/schemas/council-tally.schema.json +34 -2
  30. package/schemas/council-verdict.schema.json +15 -0
  31. package/scripts/postinstall.js +6 -3
  32. package/scripts/setup-hooks.js +49 -3
  33. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  34. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  35. package/skills/second-opinion/SKILL.md +25 -11
  36. package/skills/sidecar/SKILL.md +3 -1
  37. package/src/cli-handlers-doctor.js +7 -0
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/peer-split.js +196 -0
  47. package/src/council/report-html.js +12 -5
  48. package/src/council/report-md.js +146 -0
  49. package/src/council/report.js +188 -112
  50. package/src/council/run-assemble.js +100 -79
  51. package/src/council/run-chair.js +17 -1
  52. package/src/council/run-debate-revote.js +268 -0
  53. package/src/council/run-debate.js +92 -102
  54. package/src/council/run-finish.js +70 -0
  55. package/src/council/run-launch.js +56 -18
  56. package/src/council/run-retry-group.js +266 -0
  57. package/src/council/run-retry-keys.js +74 -0
  58. package/src/council/run-retry-launch.js +55 -0
  59. package/src/council/run-retry-notes.js +65 -13
  60. package/src/council/run-retry.js +179 -162
  61. package/src/council/run-stage1-launch.js +27 -10
  62. package/src/council/run-stage1-rows.js +220 -0
  63. package/src/council/run-stage1-superseded.js +156 -0
  64. package/src/council/run-stage2.js +65 -5
  65. package/src/council/run-stages.js +72 -69
  66. package/src/council/run-state.js +1 -1
  67. package/src/council/run-stats-entry.js +71 -0
  68. package/src/council/run-verdict-files.js +52 -0
  69. package/src/council/run.js +42 -52
  70. package/src/council/seats.js +262 -0
  71. package/src/council/stage1-bind.js +142 -0
  72. package/src/council/street-cred.js +258 -0
  73. package/src/council/tally.js +100 -49
  74. package/src/council/verdict.js +49 -1
  75. package/src/headless.js +83 -6
  76. package/src/mcp-tools.js +71 -1
  77. package/src/observe/council-legs.js +7 -1
  78. package/src/observe/live-doc.js +3 -3
  79. package/src/sidecar/continue.js +8 -23
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-retry.js +15 -3
  82. package/src/sidecar/fanout-wave-io.js +26 -1
  83. package/src/sidecar/fanout.js +4 -10
  84. package/src/sidecar/leg-ids.js +19 -0
  85. package/src/sidecar/models-probe.js +7 -4
  86. package/src/sidecar/reopen-spend.js +32 -0
  87. package/src/sidecar/resume.js +1 -1
  88. package/src/sidecar/setup.js +13 -2
  89. package/src/utils/config.js +109 -11
  90. package/src/utils/curated-models.js +17 -2
  91. package/src/utils/degrade.js +5 -0
  92. package/src/utils/doctor-engine-check.js +32 -0
  93. package/src/utils/engine-install-scan.js +98 -15
  94. package/src/utils/engine-repair.js +96 -2
  95. package/src/utils/no-output-backstop.js +1 -1
  96. package/src/utils/remediation-hints.js +42 -0
  97. package/src/utils/session-index-prune.js +297 -0
  98. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  99. package/src/workspace/artifact-guard.js +8 -114
  100. package/src/workspace/artifact-names.js +222 -0
  101. package/src/workspace/fold-format.js +9 -6
  102. package/src/workspace/live-normalize.js +6 -2
  103. package/src/workspace/matrix-model.js +141 -19
  104. package/src/workspace/run-detail.js +27 -1
  105. package/src/workspace/seat-space.js +143 -0
@@ -14,20 +14,16 @@
14
14
 
15
15
  const fsReal = require('fs');
16
16
  const path = require('path');
17
- const { sanitizeName } = require('../council/run-launch');
18
17
  const { readPointer } = require('./run-scan');
19
18
  const { isRealpathContained } = require('../utils/path-fence');
19
+ // v4.8 PR5a: name derivation moved to ./artifact-names (the 300-line gate). The two
20
+ // constants and artifactAllowlist are re-exported below unchanged, so every existing
21
+ // caller and test keeps importing them from here.
22
+ // `isSeatTable` joins them (fix-wave, council A1/B1): run-detail.js has to answer "is
23
+ // this run in seat space?" with THE predicate artifactAllowlist gates on, not a second
24
+ // spelling of it, and this module is already its import surface.
25
+ const { artifactAllowlist, isSeatTable, FIXED_ARTIFACTS, DEBATE_ARTIFACTS } = require('./artifact-names');
20
26
 
21
- const FIXED_ARTIFACTS = Object.freeze(['briefing-stage1.md', 'bundle-stage2.md', 'chair-packet.md', 'chair-output.md', 'tally-input.json']);
22
- // ⚠️ DE-ROT (F28): v4.1's debate stage writes five MORE run-dir artifact kinds the original
23
- // allowlist never named, so the Workspace hard-refused every `--debate` output with
24
- // `artifact not allowed: <name>`. Writers: tally-provisional.json = src/council/run.js:199;
25
- // revote-bundle.md = run-debate.js:119; debate.json = run-debate.js:261; the per-seat
26
- // rebuttal-/revote- pair = materializeDebate (run-launch.js:127-136).
27
- // ⚠️ FIVE KINDS, THREE ENTRIES — that is not a miscount (v4.4.1 DOC-7, re-verified). This const
28
- // holds only the three RUN-LEVEL names; the last two of the five, the rebuttal-/revote- pair, are
29
- // per BENCH MODEL and are appended inside artifactAllowlist below, next to review-/judge-.
30
- const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
31
27
  const MAX_ARTIFACT_BYTES = 200 * 1024;
32
28
 
33
29
  // isRealpathContained itself now lives in ../utils/path-fence.js (the shared "fence 2"
@@ -53,108 +49,6 @@ function truncateUtf8(buf, max) {
53
49
  return buf.subarray(0, end);
54
50
  }
55
51
 
56
- /**
57
- * @param {object} run parsed run.json (may be partial)
58
- * @returns {string[]} the allowlist. When two or more DISTINCT bench entries sanitize to the
59
- * same artifact name, a non-enumerable-in-spirit (but plain, test-visible) `collisions`
60
- * array is attached: `[{sanitized, models: [rawA, rawB, ...]}, ...]`. See the R4
61
- * council-review note below for why this is surfaced rather than silently deduped.
62
- */
63
- function artifactAllowlist(run) {
64
- const names = [...FIXED_ARTIFACTS];
65
- const bench = run && Array.isArray(run.bench) ? run.bench : [];
66
- // ⚠️ DE-ROT (F28): run.json carries a `debate` key ONLY on --debate runs, and it is seeded
67
- // on the FIRST write (src/council/run.js:74-77), so this gate is safe and keeps the
68
- // allowlist tight for the common case.
69
- const debated = !!(run && run.debate);
70
- if (debated) { names.push(...DEBATE_ARTIFACTS); }
71
-
72
- // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): sanitizeName is NOT
73
- // injective — it maps every character outside [a-zA-Z0-9._-] to '-', so two DISTINCT bench
74
- // entries ('vendor/a', 'vendor?a') both produce 'vendor-a'. Both models would then request
75
- // the SAME artifact file, and the renderer's `[data-artifact="..."]` lookup (drillIntoJudge)
76
- // hands back whichever section matches first — prose silently misattributed to the wrong
77
- // model. That is a run-integrity defect (this run directory genuinely cannot hold both
78
- // models' review/judge files under distinct names), not a display quirk, so it must be
79
- // DETECTED and surfaced, never smoothed away by deduping the resulting name list.
80
- //
81
- // A bench with genuinely REPEATED identical entries (['gemini', 'gemini']) is a different,
82
- // harmless case that must keep collapsing to one set of rows (preserved intent) — collapse
83
- // those via a Set over the RAW bench values FIRST, so identical entries never even reach
84
- // the collision check below (only entries that are distinct as raw strings but coincide
85
- // after sanitizeName count as a collision).
86
- const uniqueModels = [...new Set(bench)];
87
- const rawBySanitized = new Map(); // sanitized name -> first raw model seen for it
88
- const collisionModels = new Map(); // sanitized name -> Set(raw models) once >1 raw maps to it
89
- for (const m of uniqueModels) {
90
- const s = sanitizeName(m);
91
- if (rawBySanitized.has(s)) {
92
- if (!collisionModels.has(s)) { collisionModels.set(s, new Set([rawBySanitized.get(s)])); }
93
- collisionModels.get(s).add(m);
94
- } else {
95
- rawBySanitized.set(s, m);
96
- }
97
- }
98
-
99
- // ⚠️ Task 18 (RN-1): the collision above is a real run-integrity defect — the run directory
100
- // physically holds ONE file where two models' artifacts should be, and no renderer trick can
101
- // recover both. What the renderer CAN stop doing is showing model A's prose under model B's
102
- // name. Deterministic disambiguation: per colliding sanitized name, sort the RAW models
103
- // (sorting, not insertion order, is what keeps this reproducible across processes/runs); the
104
- // first (sorted) keeps the bare sanitized name, the rest get `~2`, `~3`, ... The suffixed
105
- // names deliberately do not exist on disk — the presence manifest (run-detail.js, via
106
- // fs.statSync over this same allowlist) marks them absent, so the renderer shows the honest
107
- // "not written yet" empty state for every model but the first, instead of cross-matching.
108
- const nameFor = new Map(); // raw model -> its (possibly suffixed) sanitized name
109
- for (const m of uniqueModels) {
110
- let s = sanitizeName(m);
111
- const collision = collisionModels.get(s);
112
- if (collision) {
113
- const sortedRaw = [...collision].sort();
114
- const index = sortedRaw.indexOf(m);
115
- if (index > 0) { s = `${s}~${index + 1}`; }
116
- }
117
- nameFor.set(m, s);
118
- }
119
-
120
- for (const m of uniqueModels) {
121
- const s = nameFor.get(m);
122
- names.push(`review-${s}.md`);
123
- names.push(`judge-${s}.md`);
124
- // rebuttal-/revote- are keyed on the same BENCH ALIAS through the same (now possibly
125
- // suffixed) name — materializeDebate is called with `d.raiser` / the revote leg's model
126
- // (both aliases), so a colliding pair's debate artifacts are disambiguated the same way.
127
- if (debated) {
128
- names.push(`rebuttal-${s}.md`);
129
- names.push(`revote-${s}.md`);
130
- }
131
- }
132
- // `uniqueModels` already collapsed genuinely-repeated bench entries, so this final Set is
133
- // now just a belt-and-suspenders no-op for names — it can no longer mask a real collision,
134
- // since that path is detected above from the RAW (pre-sanitize) values instead.
135
- const list = [...new Set(names)];
136
- if (collisionModels.size) {
137
- list.collisions = [...collisionModels.entries()].map(([sanitized, models]) => ({
138
- sanitized, models: [...models],
139
- }));
140
- }
141
- // Consumed by workspace-panels.js (wireLazyPanels' file lists + drillIntoJudge's artifact
142
- // lookup), which prefers this map over re-deriving names via sanitizeName(model) directly —
143
- // that re-derivation is exactly what would ignore the suffixing above and misattribute prose.
144
- // ⚠️ Fix-wave (review finding 1) residual limit this map cannot close: the BARE (unsuffixed)
145
- // name is still exactly ONE physical file on disk, and its actual bytes belong to whichever
146
- // colliding model's writer ran LAST — no map can recover which one that was. The guarantee
147
- // delivered here is narrower than "attribution is fully sound": at most the sorted-first
148
- // model can still be misattributed under the bare name; artifactCollisions (the run-integrity
149
- // banner rendered by workspace-app.js's renderBanners) is what covers that residual case.
150
- list.artifactsByModel = Object.fromEntries(
151
- [...nameFor].map(([m, s]) => [m, {
152
- review: `review-${s}.md`, judge: `judge-${s}.md`,
153
- rebuttal: `rebuttal-${s}.md`, revote: `revote-${s}.md`,
154
- }]),
155
- );
156
- return list;
157
- }
158
52
 
159
53
  /**
160
54
  * @param {string} project
@@ -241,6 +135,6 @@ function readRunArtifact(project, runId, name, deps = {}) {
241
135
  }
242
136
 
243
137
  module.exports = {
244
- artifactAllowlist, readRunArtifact, isRealpathContained,
138
+ artifactAllowlist, isSeatTable, readRunArtifact, isRealpathContained,
245
139
  FIXED_ARTIFACTS, DEBATE_ARTIFACTS, MAX_ARTIFACT_BYTES,
246
140
  };
@@ -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:776-778). The guard is restored in buildFoldText below.
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 (src/headless.js:776).
53
- // Without it a missing nonce emits `[SIDECAR_FOLD:]`, which the hex-only marker
54
- // regex (src/utils/fold-marker.js:68) never parses a silently unfoldable block.
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:428), so a multi-line or marker-bearing value must never
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:30 stamps labelMap values from the alias, never the
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.