amicus 4.6.3 → 4.7.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 (86) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +283 -0
  3. package/README.md +19 -7
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +143 -36
  6. package/docs/configuration.md +51 -3
  7. package/docs/council.md +63 -10
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/troubleshooting.md +27 -1
  11. package/docs/usage.md +59 -13
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +52 -14
  14. package/electron/workspace-ui/workspace-app.js +14 -3
  15. package/electron/workspace-ui/workspace-lazy.js +233 -0
  16. package/electron/workspace-ui/workspace-matrix.js +12 -1
  17. package/electron/workspace-ui/workspace-panels.js +24 -171
  18. package/electron/workspace-ui/workspace-render.js +6 -2
  19. package/electron/workspace-ui/workspace-seats.js +68 -0
  20. package/electron/workspace-ui/workspace.css +6 -0
  21. package/package.json +5 -2
  22. package/schemas/council-run.schema.json +1 -0
  23. package/schemas/council-stats.schema.json +9 -1
  24. package/schemas/run.schema.json +2 -1
  25. package/schemas/spend.schema.json +1 -1
  26. package/schemas/wave.schema.json +2 -1
  27. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  29. package/skills/sidecar/SKILL.md +7 -2
  30. package/src/cli-council-run-bench.js +86 -0
  31. package/src/cli-handlers-council-run.js +65 -81
  32. package/src/cli-handlers-council.js +17 -5
  33. package/src/cli-handlers-fanout.js +179 -0
  34. package/src/cli-handlers-pack.js +24 -10
  35. package/src/cli-handlers-run.js +19 -161
  36. package/src/cli-template-args.js +48 -0
  37. package/src/cli.js +39 -46
  38. package/src/council/debate.js +89 -10
  39. package/src/council/ledger.js +72 -11
  40. package/src/council/report.js +17 -6
  41. package/src/council/run-assemble.js +15 -3
  42. package/src/council/run-budget.js +2 -2
  43. package/src/council/run-chair.js +61 -5
  44. package/src/council/run-debate.js +51 -67
  45. package/src/council/run-launch.js +9 -2
  46. package/src/council/run-retry.js +4 -1
  47. package/src/council/run-stage1-launch.js +94 -0
  48. package/src/council/run-stage2.js +25 -4
  49. package/src/council/run-stages.js +79 -86
  50. package/src/council/run-state.js +10 -2
  51. package/src/council/run.js +26 -2
  52. package/src/council/tally.js +6 -2
  53. package/src/mcp-council-awareness.js +1 -0
  54. package/src/mcp-council-bench.js +4 -0
  55. package/src/mcp-council-run.js +10 -0
  56. package/src/mcp-server.js +114 -54
  57. package/src/mcp-tools.js +12 -5
  58. package/src/pack/pack-cli.js +1 -1
  59. package/src/pack/pack-forward.js +12 -4
  60. package/src/pack/pack-resolve.js +3 -0
  61. package/src/pack/pack-store.js +20 -3
  62. package/src/pack/pack-validate.js +5 -1
  63. package/src/sidecar/budget.js +38 -4
  64. package/src/sidecar/fanout-budget.js +1 -2
  65. package/src/sidecar/fanout-leg-fallback.js +7 -3
  66. package/src/sidecar/fanout-wave-io.js +13 -1
  67. package/src/sidecar/fanout.js +11 -9
  68. package/src/sidecar/list-limit.js +50 -0
  69. package/src/sidecar/list-search.js +69 -0
  70. package/src/sidecar/read.js +90 -5
  71. package/src/sidecar/start-metadata.js +58 -0
  72. package/src/sidecar/start.js +8 -43
  73. package/src/sidecar/workspace-auto-open.js +2 -2
  74. package/src/spend-query.js +2 -1
  75. package/src/template/apply.js +7 -4
  76. package/src/template/render.js +6 -2
  77. package/src/template/store.js +1 -1
  78. package/src/utils/cli-preflight.js +27 -1
  79. package/src/utils/config.js +15 -0
  80. package/src/utils/result-schema-rebuild.js +1 -0
  81. package/src/utils/result-schema.js +6 -1
  82. package/src/utils/session-index-tmp-sweep.js +18 -3
  83. package/src/utils/session-index.js +1 -0
  84. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  85. package/src/utils/spend-ledger.js +11 -4
  86. package/src/utils/validators.js +16 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Stage-1 launch pass for the council engine.
3
+ *
4
+ * launchStage1 moved verbatim from run-stages.js (v4.7 PR1 Task 1) to free
5
+ * gate headroom before the row-per-launch edits land there.
6
+ */
7
+ 'use strict';
8
+
9
+ const briefings = require('./briefings');
10
+ const runState = require('./run-state');
11
+ const { isAbortExit } = require('./run-launch');
12
+
13
+ /** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
14
+ async function launchStage1(ctx) {
15
+ const { o, launchers } = ctx;
16
+ // `noCostGate` rides EVERY launch object in this file (here, the findings
17
+ // repair, the judge wave, the judge repair) — see run-launch.js's fanout call.
18
+ const common = {
19
+ project: o.runDir, timeout: o.timeout, gateway: o.gateway,
20
+ noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
21
+ // v4.3 Task 3 (spec §7.2): attribution ids, forwarded verbatim to runFanout
22
+ // via run-launch.js so every Stage-1 leg's ledger row carries them.
23
+ councilRunId: o.runId, councilName: o.councilName,
24
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
25
+ // v4.3 Task 18 (spec §6.2): fallback chains apply to STAGE legs only —
26
+ // the chair (run-chair.js) and debate legs (run-debate.js) never receive
27
+ // this, so they never substitute via chains.
28
+ fallback: o.fallback, catalog: o.catalog,
29
+ };
30
+ const launches = [];
31
+ const seated = []; // parallel to `launches`: what each one was SUPPOSED to seat
32
+ // Record every sub-wave BEFORE it launches: `amicus abort` cascades over
33
+ // stages[].waveIds, so an id written after the launch leaves that leg
34
+ // reachable only by the pid kill (no per-leg abort marker).
35
+ const record = (waveId) => runState.appendStageWave(o.runDir, 'stage1', waveId);
36
+ if (o.lenses) {
37
+ o.models.forEach((m, i) => {
38
+ const waveId = `${o.runId}-l${i + 1}`;
39
+ record(waveId);
40
+ seated.push({ waveId, models: [m] });
41
+ launches.push(launchers.launchSolo({
42
+ ...common, model: m, waveId,
43
+ prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
44
+ }));
45
+ });
46
+ } else {
47
+ const seats = o.models.filter(m => m !== o.critic);
48
+ if (seats.length > 0) {
49
+ record(`${o.runId}-s1`);
50
+ seated.push({ waveId: `${o.runId}-s1`, models: seats.slice() });
51
+ launches.push(launchers.launchWave({
52
+ ...common, models: seats, waveId: `${o.runId}-s1`,
53
+ prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
54
+ }));
55
+ }
56
+ if (o.critic) {
57
+ record(`${o.runId}-c1`);
58
+ seated.push({ waveId: `${o.runId}-c1`, models: [o.critic] });
59
+ launches.push(launchers.launchSolo({
60
+ ...common, model: o.critic, waveId: `${o.runId}-c1`,
61
+ prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
62
+ }));
63
+ }
64
+ }
65
+ const results = await Promise.all(launches);
66
+ let aborted = null;
67
+ const legs = [];
68
+ const deadWaves = [];
69
+ results.forEach((r, i) => {
70
+ ctx.addWave(r.wave);
71
+ const abort = isAbortExit(r.exitCode);
72
+ if (abort) { aborted = r.exitCode; }
73
+ const got = (r.wave && Array.isArray(r.wave.legs)) ? r.wave.legs : [];
74
+ legs.push(...got);
75
+ // ⚠️ Step 10's uncovered half. A wave that died BEFORE its legs (the server
76
+ // never started; `database is locked`) contributes NOTHING to `legs`, so
77
+ // deadLegs cannot see it either — which is how run v441plan01 recorded
78
+ // stage1 'complete' with four seats missing and no trace of them. In lens
79
+ // mode every seat is its own wave, so a run could lose seats and still exit
80
+ // 0; the quorum gate only catches the non-lens seat wave. A budget refusal
81
+ // has its own louder channel already (run-budget.noteBudgetRefusal) and
82
+ // must not be double-counted here.
83
+ if (got.length > 0 || abort) { return; }
84
+ if (r.errorDoc && r.errorDoc.code === 'BUDGET_EXCEEDED') { return; }
85
+ deadWaves.push({
86
+ waveId: seated[i].waveId, models: seated[i].models,
87
+ reason: (r.wave && (r.wave.reason || r.wave.error))
88
+ || (r.errorDoc && r.errorDoc.message) || 'the wave produced no legs',
89
+ });
90
+ });
91
+ return { aborted, legs, deadWaves };
92
+ }
93
+
94
+ module.exports = { launchStage1 };
@@ -23,6 +23,7 @@ const stage2 = require('./briefings-stage2');
23
23
  const { parseJudgeOutput } = require('./parse-stage2');
24
24
  const { sanitizeName, isAbortExit } = require('./run-launch');
25
25
  const runState = require('./run-state');
26
+ const { buildRunStatsEntry } = require('./run-assemble');
26
27
 
27
28
  /**
28
29
  * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
@@ -31,7 +32,12 @@ const runState = require('./run-state');
31
32
  * extraLabeled?: Array<{label: string, text: string}>}} args
32
33
  * `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
33
34
  * a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
34
- * @returns {Promise<{aborted: number|null, judgeResults: Array}>}
35
+ * @returns {Promise<{aborted: number|null, judgeResults: Array, extraRows: Array}>}
36
+ * `extraRows` (v4.7 D2, mirroring runStage1's channel) is one `role:'repair'`
37
+ * row per `-q<N>` judge-repair solo (error status when the repair itself
38
+ * failed) — the judge's own judgeResults entry keeps attributing its ORIGINAL
39
+ * Stage-2 wave leg throughout (the #83 comment below), so a repair never
40
+ * overwrites the primary judge row; it only adds this separate one.
35
41
  */
36
42
  async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
37
43
  const { o } = ctx;
@@ -59,12 +65,18 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
59
65
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
60
66
  noCostGate: o.noCostGate,
61
67
  councilRunId: o.runId, councilName: o.councilName,
68
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
62
69
  fallback: o.fallback, catalog: o.catalog,
63
70
  });
64
71
  ctx.addWave(wave);
65
- if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
72
+ if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [], extraRows: [] }; }
66
73
 
67
74
  const judgeResults = [];
75
+ // v4.7 D2: every judge-repair launch is a billed leg of its own, distinct from
76
+ // the judge's original Stage-2 wave leg it is trying to fix — it gets its own
77
+ // row so its cost is never folded into, or lost from, the judge's row (mirrors
78
+ // runStage1's -p<N> extraRows, ./run-stages.js:117-120).
79
+ const extraRows = [];
68
80
  let repairSeq = 0;
69
81
  for (const leg of (wave && wave.legs) || []) {
70
82
  const judge = leg.modelInput || leg.model;
@@ -95,10 +107,19 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
95
107
  project: ctx.scratchDir, waveId, timeout: o.timeout,
96
108
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
97
109
  councilRunId: o.runId, councilName: o.councilName,
110
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
98
111
  fallback: o.fallback, catalog: o.catalog,
99
112
  });
100
113
  ctx.addWave(solo.wave);
101
- if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
114
+ if (isAbortExit(solo.exitCode)) {
115
+ // Abort paths add no rows (aborted runs never reach tally) — extraRows
116
+ // is returned only for shape consistency, never read past this point.
117
+ return { aborted: solo.exitCode, judgeResults, extraRows };
118
+ }
119
+ // Every -q<N> launch gets a row — INCLUDING a repair that failed: the
120
+ // error status rides naturally off solo.leg (null/'error'-status leg ⇒
121
+ // buildRunStatsEntry's own never-invent defaults), no special-casing needed.
122
+ extraRows.push(buildRunStatsEntry({ leg: solo.leg, model: judge, role: 'repair', wasChair: false }));
102
123
  const out = (solo.leg && solo.leg.summary) || '';
103
124
  if (out.trim()) { judging = out; }
104
125
  parsed = parseJudgeOutput(out, parseCtx);
@@ -120,7 +141,7 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
120
141
  judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance,
121
142
  leg: leg || null });
122
143
  }
123
- return { aborted: null, judgeResults };
144
+ return { aborted: null, judgeResults, extraRows };
124
145
  }
125
146
 
126
147
  module.exports = { runStage2 };
@@ -23,91 +23,13 @@ const { materializeReviews, isAbortExit } = require('./run-launch');
23
23
  const { retryStage1Losses } = require('./run-retry');
24
24
  const runState = require('./run-state');
25
25
  const { runStage2 } = require('./run-stage2');
26
+ const { launchStage1 } = require('./run-stage1-launch');
27
+ const { buildRunStatsEntry } = require('./run-assemble');
26
28
 
27
29
  function slug(text) {
28
30
  return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
29
31
  }
30
32
 
31
- /** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
32
- async function launchStage1(ctx) {
33
- const { o, launchers } = ctx;
34
- // `noCostGate` rides EVERY launch object in this file (here, the findings
35
- // repair, the judge wave, the judge repair) — see run-launch.js's fanout call.
36
- const common = {
37
- project: o.runDir, timeout: o.timeout, gateway: o.gateway,
38
- noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
39
- // v4.3 Task 3 (spec §7.2): attribution ids, forwarded verbatim to runFanout
40
- // via run-launch.js so every Stage-1 leg's ledger row carries them.
41
- councilRunId: o.runId, councilName: o.councilName,
42
- // v4.3 Task 18 (spec §6.2): fallback chains apply to STAGE legs only —
43
- // the chair (run-chair.js) and debate legs (run-debate.js) never receive
44
- // this, so they never substitute via chains.
45
- fallback: o.fallback, catalog: o.catalog,
46
- };
47
- const launches = [];
48
- const seated = []; // parallel to `launches`: what each one was SUPPOSED to seat
49
- // Record every sub-wave BEFORE it launches: `amicus abort` cascades over
50
- // stages[].waveIds, so an id written after the launch leaves that leg
51
- // reachable only by the pid kill (no per-leg abort marker).
52
- const record = (waveId) => runState.appendStageWave(o.runDir, 'stage1', waveId);
53
- if (o.lenses) {
54
- o.models.forEach((m, i) => {
55
- const waveId = `${o.runId}-l${i + 1}`;
56
- record(waveId);
57
- seated.push({ waveId, models: [m] });
58
- launches.push(launchers.launchSolo({
59
- ...common, model: m, waveId,
60
- prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
61
- }));
62
- });
63
- } else {
64
- const seats = o.models.filter(m => m !== o.critic);
65
- if (seats.length > 0) {
66
- record(`${o.runId}-s1`);
67
- seated.push({ waveId: `${o.runId}-s1`, models: seats.slice() });
68
- launches.push(launchers.launchWave({
69
- ...common, models: seats, waveId: `${o.runId}-s1`,
70
- prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
71
- }));
72
- }
73
- if (o.critic) {
74
- record(`${o.runId}-c1`);
75
- seated.push({ waveId: `${o.runId}-c1`, models: [o.critic] });
76
- launches.push(launchers.launchSolo({
77
- ...common, model: o.critic, waveId: `${o.runId}-c1`,
78
- prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
79
- }));
80
- }
81
- }
82
- const results = await Promise.all(launches);
83
- let aborted = null;
84
- const legs = [];
85
- const deadWaves = [];
86
- results.forEach((r, i) => {
87
- ctx.addWave(r.wave);
88
- const abort = isAbortExit(r.exitCode);
89
- if (abort) { aborted = r.exitCode; }
90
- const got = (r.wave && Array.isArray(r.wave.legs)) ? r.wave.legs : [];
91
- legs.push(...got);
92
- // ⚠️ Step 10's uncovered half. A wave that died BEFORE its legs (the server
93
- // never started; `database is locked`) contributes NOTHING to `legs`, so
94
- // deadLegs cannot see it either — which is how run v441plan01 recorded
95
- // stage1 'complete' with four seats missing and no trace of them. In lens
96
- // mode every seat is its own wave, so a run could lose seats and still exit
97
- // 0; the quorum gate only catches the non-lens seat wave. A budget refusal
98
- // has its own louder channel already (run-budget.noteBudgetRefusal) and
99
- // must not be double-counted here.
100
- if (got.length > 0 || abort) { return; }
101
- if (r.errorDoc && r.errorDoc.code === 'BUDGET_EXCEEDED') { return; }
102
- deadWaves.push({
103
- waveId: seated[i].waveId, models: seated[i].models,
104
- reason: (r.wave && (r.wave.reason || r.wave.error))
105
- || (r.errorDoc && r.errorDoc.message) || 'the wave produced no legs',
106
- });
107
- });
108
- return { aborted, legs, deadWaves };
109
- }
110
-
111
33
  /** Role of a seat by its input alias. */
112
34
  function roleFor(o, alias) {
113
35
  if (o.lenses) {
@@ -120,8 +42,15 @@ function roleFor(o, alias) {
120
42
  /**
121
43
  * Stage 1: independent reviews + findings validation + bounded repair.
122
44
  * @returns {Promise<{aborted: number|null, reviews: Array, deadLegs: Array,
123
- * deadWaves: Array, degraded: boolean}>} `degraded` covers BOTH ways a seat
124
- * can go missing: a leg that ran and died (deadLegs) and a whole sub-wave that
45
+ * deadWaves: Array, degraded: boolean, extraRows: Array}>} `extraRows` (v4.7
46
+ * D2/E4) is the row-per-launch channel for legs that never became or
47
+ * stopped being — a seat's primary review: one `role:'repair'` row per
48
+ * findings-repair solo (error status when the repair itself failed), one
49
+ * `role:'superseded'` row per first leg a later attempt replaced (healed OR
50
+ * still-dead — either way the first leg stopped being primary), and one
51
+ * PRIMARY error row per seat with no surviving review at all.
52
+ * `degraded` covers BOTH ways a seat can go missing: a leg that ran and died
53
+ * (deadLegs) and a whole sub-wave that
125
54
  * died before its legs existed (deadWaves). A pushed review carries
126
55
  * `findingsUnverified: true` (LC-11) when its findings came from a repair whose
127
56
  * contract could not be checked — the original block was absent or unparseable,
@@ -132,7 +61,7 @@ function roleFor(o, alias) {
132
61
  async function runStage1(ctx) {
133
62
  const { o } = ctx;
134
63
  const { aborted, legs, deadWaves } = await launchStage1(ctx);
135
- if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
64
+ if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false, extraRows: [] }; }
136
65
 
137
66
  const firstPass = materializeReviews(o.runDir, legs);
138
67
  const alive0 = new Set(firstPass.map(m => m.leg));
@@ -147,7 +76,7 @@ async function runStage1(ctx) {
147
76
  // abort fixed ~87 lines below ("Must be the post-retry set") — subtract
148
77
  // whatever retry.recoveredLegs already healed before this abort landed.
149
78
  const healed = new Set(retry.recoveredLegs.map(l => l.modelInput || l.model));
150
- return { aborted: retry.aborted, reviews: [], degraded: false,
79
+ return { aborted: retry.aborted, reviews: [], degraded: false, extraRows: [],
151
80
  deadLegs: deadLegs0.filter(l => !healed.has(l.modelInput || l.model)),
152
81
  deadWaves: deadWaves.map(w => ({ ...w, models: (w.models || []).filter(m => !healed.has(m)) })).filter(w => w.models.length > 0) };
153
82
  }
@@ -185,6 +114,10 @@ async function runStage1(ctx) {
185
114
  const stillDeadWaves = [...retry.skippedDeadWaves, ...retry.stillDeadWaves];
186
115
 
187
116
  const reviews = [];
117
+ // v4.7 D2/E4: every repair launch is a billed leg of its own, distinct from
118
+ // the seat's own review leg (m.leg) it is trying to fix — it gets its own
119
+ // row so its cost is never folded into, or lost from, the review's row.
120
+ const extraRows = [];
188
121
  let repairSeq = 0;
189
122
  for (const m of materialized) {
190
123
  let conformance = 'clean';
@@ -227,6 +160,7 @@ async function runStage1(ctx) {
227
160
  project: o.runDir, waveId, timeout: o.timeout,
228
161
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
229
162
  councilRunId: o.runId, councilName: o.councilName,
163
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
230
164
  fallback: o.fallback, catalog: o.catalog,
231
165
  });
232
166
  ctx.addWave(solo.wave);
@@ -236,8 +170,15 @@ async function runStage1(ctx) {
236
170
  // short-circuit, so a heal-then-abort run was recording seats as dead
237
171
  // that had actually reviewed on retry. Must be the post-retry set,
238
172
  // same as the normal-completion return below.
239
- return { aborted: solo.exitCode, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves, degraded: false };
173
+ // Abort paths add no rows (aborted runs never reach tally) — extraRows
174
+ // is returned only for shape consistency, never read past this point.
175
+ return { aborted: solo.exitCode, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves,
176
+ degraded: false, extraRows };
240
177
  }
178
+ // Every -p<N> launch gets a row — INCLUDING a repair that failed: the
179
+ // error status rides naturally off solo.leg (null/'error'-status leg ⇒
180
+ // buildRunStatsEntry's own never-invent defaults), no special-casing needed.
181
+ extraRows.push(buildRunStatsEntry({ leg: solo.leg, model: m.modelInput, role: 'repair', wasChair: false }));
241
182
  const repaired = (solo.leg && solo.leg.summary) || '';
242
183
  if (repaired.trim()) { repairing = repaired; }
243
184
  res = validateFindings(repaired);
@@ -285,8 +226,60 @@ async function runStage1(ctx) {
285
226
  ...(repairRefused ? { repairRefused } : {}),
286
227
  });
287
228
  }
229
+
230
+ // v4.7 D2/E4 — superseded rows: a leg-origin seat's FIRST leg stops being
231
+ // primary the moment a retry was actually attempted for it, healed or not
232
+ // (deadLegs0 × recovered-or-still-dead seats — mirrors the healed-set idiom
233
+ // above, extended to the still-dead half E4 also requires). A skipped seat
234
+ // (cost ceiling / unmappable — never got a second leg) keeps NO superseded
235
+ // row: nothing replaced it. Wave-origin seats never had a first leg at all,
236
+ // so they can never appear here regardless of healed/dead outcome (E4).
237
+ const supersededAliases = new Set([
238
+ ...retry.recoveredLegs.map(l => l.modelInput || l.model),
239
+ ...retry.stillDeadLegs.map(l => l.modelInput || l.model),
240
+ ]);
241
+ for (const dead of deadLegs0) {
242
+ const alias = dead.modelInput || dead.model;
243
+ if (supersededAliases.has(alias)) {
244
+ extraRows.push(buildRunStatsEntry({ leg: dead, model: alias, role: 'superseded', wasChair: false }));
245
+ }
246
+ }
247
+
248
+ // v4.7 D2/E4 — primary error rows: one per seat with NO surviving review
249
+ // (every alias still in stillDeadLegs/stillDeadWaves after retry). E5
250
+ // amended (Task-4 review, owner-ruled): run-retry.js now surfaces the real
251
+ // retry leg (stillDeadRetryLegs), from the ONE branch it exists in —
252
+ // retryLegStillDeadNote, a retry leg that came back unusable. Prefer that
253
+ // REAL leg: status/waveId/usage/duration all real, all from the SAME
254
+ // attempt (no more pairing a retry's waveId with a different attempt's
255
+ // status). The other two dead-leg note classes — srcLegStillDeadNote (retry
256
+ // wave died wholesale, zero legs) and missingLegStillDeadNote (partial
257
+ // return never named this seat) — never get a real leg, so `leg: null`
258
+ // (no phantom waveId: one must never appear without a real billed leg).
259
+ // No 'dead-leg' note at all ⇒ never retried (skipped) ⇒ the original dead
260
+ // leg is this seat's only, and therefore final, leg.
261
+ const retryLegByAlias = new Map();
262
+ for (const leg of retry.stillDeadRetryLegs) { retryLegByAlias.set(leg.modelInput || leg.model, leg); }
263
+ const attemptedAliases = new Set();
264
+ for (const n of retry.stillDeadNotes) {
265
+ if (n.channel === 'dead-leg' && n.data && n.data.seat) { attemptedAliases.add(n.data.seat); }
266
+ }
267
+ const deadAliases = new Set([
268
+ ...stillDeadLegs.map(l => l.modelInput || l.model),
269
+ ...stillDeadWaves.flatMap(w => w.models || []),
270
+ ]);
271
+ for (const alias of deadAliases) {
272
+ let finalLeg = retryLegByAlias.get(alias);
273
+ if (!finalLeg) {
274
+ finalLeg = attemptedAliases.has(alias)
275
+ ? null // retried; no leg at all for this seat
276
+ : (deadLegs0.find(l => (l.modelInput || l.model) === alias) || null); // never retried
277
+ }
278
+ extraRows.push(buildRunStatsEntry({ leg: finalLeg, model: alias, role: roleFor(o, alias), wasChair: false }));
279
+ }
280
+
288
281
  return { aborted: null, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves,
289
- degraded: stillDeadLegs.length > 0 || stillDeadWaves.length > 0 };
282
+ degraded: stillDeadLegs.length > 0 || stillDeadWaves.length > 0, extraRows };
290
283
  }
291
284
 
292
285
  // runStage2 lives in ./run-stage2.js (300-line gate) but is re-exported here so
@@ -101,13 +101,21 @@ function initCouncilRun(o) {
101
101
  // "no debate key" contract and fail the object-typed schema), and with a VALID
102
102
  // outcome from the first write so a run killed mid-debate stays schema-valid.
103
103
  ...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
104
- // F9 (v4.5): additive-only — absent (not null) without --template; the MCP
105
- // seed (mcp-council-run.js, initRun directly) never sets this in v4.5.
104
+ // F9 (v4.5): additive-only — absent (not null) without --template. T15-m2
105
+ // (v4.7): the MCP handler (mcp-council-run.js) now pre-seeds this directly
106
+ // via its own initRun call when a pack forwards briefing.template; the
107
+ // spawned CLI child's own seed here still passes o.template: null (no
108
+ // --template flag crosses that spawn boundary), so this spread omits the
109
+ // key and mergeRun's shallow merge preserves the parent's pre-seeded value.
106
110
  ...(o.template ? { template: o.template } : {}),
107
111
  // v4.5 Task 12 (B7/F5): additive-only — absent (not null) without --pack.
108
112
  // Preserved across a later MCP-child initRun whose own seed omits it —
109
113
  // mergeRun's plain shallow merge never drops a key patch doesn't mention.
110
114
  ...(o.pack ? { pack: o.pack } : {}),
115
+ // v4.7 F8 (D13): additive-only — absent (not null) without --tag. Same
116
+ // merge-preserve precedent as pack above (mergeRun's shallow merge never
117
+ // drops a key a later patch doesn't mention).
118
+ ...(o.tag ? { tag: o.tag } : {}),
111
119
  // v4.5 Wave 2 (post-HOLD chip, task-23-report.md Anomaly 1): additive-only
112
120
  // — absent (never an empty array) when nothing was dropped. Handler-computed
113
121
  // (cli-handlers-council-run.js's resolveBench, via resolveCouncilMembers) so
@@ -207,9 +207,15 @@ async function runCouncil(options, deps = {}) {
207
207
  }
208
208
 
209
209
  // ---- Chair synthesis (provisional tally feeds the packet) ----
210
+ // v4.7 D2: two independent extraRows channels — Stage 1's findings-repair
211
+ // rows and Stage 2's judge-repair rows — concatenate into the ONE array
212
+ // buildTallyInput appends after the primary review rows (run-assemble.js
213
+ // docblock). Neither stage invents a second mechanism for the other's kind
214
+ // of row.
210
215
  const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
211
216
  runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
212
217
  reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
218
+ extraRows: [...s1.extraRows, ...s2.extraRows],
213
219
  });
214
220
  const provisionalInput = mkInput(null, o.chair);
215
221
  const provisional = tally(provisionalInput);
@@ -231,17 +237,35 @@ async function runCouncil(options, deps = {}) {
231
237
  packet, degrade, statsFn, isSignalled: () => signalled,
232
238
  });
233
239
  if (chairRes.aborted !== null) { return finalize(chairRes.aborted); }
234
- const { chairLeg, actualChair, chairText, chairConformance, overallVerdict } = chairRes;
240
+ const { chairLeg, actualChair, chairText, chairConformance, overallVerdict, chairRows, chairAttempts } = chairRes;
235
241
 
236
242
  // ---- Final tally (chair row included) + ledger + artifacts ----
237
243
  const chairStats = chairLeg ? asm.buildRunStatsEntry({
238
244
  leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
239
245
  conformance: chairConformance,
240
246
  }) : null;
247
+ // v4.7 D2: a give-up (no chairLeg) with at least one recorded attempt gets
248
+ // an explicit error row so the walk's outcome isn't silently absorbed.
249
+ // Keyed on chairAttempts, NOT chairRows — attempts that die pre-wave (no
250
+ // money spent) record an outcome but yield no row (errata E3).
251
+ const giveUpRow = (!chairLeg && chairAttempts && chairAttempts.length)
252
+ ? asm.buildRunStatsEntry({ leg: null, model: o.chair, role: 'chair', wasChair: false })
253
+ : null;
241
254
  // Built on the (possibly debated) input so the debate's amended claims, replaced
242
255
  // adjudications and rebuttal/revote runStats rows all reach the final record.
243
256
  const finalInput = { ...debatedInput, meta: { ...debatedInput.meta, chair: actualChair || o.chair } };
244
- if (chairStats) { finalInput.runStats = [...(finalInput.runStats || []), chairStats]; }
257
+ // Item 8, final-review consolidated wave: was three sequential
258
+ // reassignments (chairStats, then chairRows, then giveUpRow), each
259
+ // rebuilding finalInput.runStats from scratch — collapsed into the one
260
+ // spread that was always the net effect. The `|| []` fallbacks were
261
+ // dead: `runStats` is a real array on every debatedInput
262
+ // (asm.buildTallyInput always returns one via .map()), never undefined.
263
+ finalInput.runStats = [
264
+ ...finalInput.runStats,
265
+ ...(chairStats ? [chairStats] : []),
266
+ ...chairRows,
267
+ ...(giveUpRow ? [giveUpRow] : []),
268
+ ];
245
269
  const record = tally(finalInput);
246
270
  if (debateFindings) { decorateRecord(record, debateFindings); }
247
271
  if (!o.lenses) {
@@ -120,10 +120,14 @@ function tally(input) {
120
120
  // travel with it: LC-11's `findingsUnverified` (contract uncheckable) and
121
121
  // F1's `repairRefused` (contract checked and broken). Additive, emitted only
122
122
  // when set, and the runStats schema declares no additionalProperties, so a
123
- // run without either is byte-for-byte unchanged. The append-only LEDGER is
124
- // deliberately NOT extended that is a schema-versioned product decision.
123
+ // run without either is byte-for-byte unchanged. v4.7 GOA-7 exercised the
124
+ // ledger's schema-versioned extension slot: `resolvedModel` rides this
125
+ // allowlist and reaches ledger rows via the ledger's model-keyed join of
126
+ // primary rows (ledger.js, LEDGER_SCHEMA_VERSION 2).
125
127
  ...(r.findingsUnverified ? { findingsUnverified: true } : {}),
126
128
  ...(r.repairRefused ? { repairRefused: r.repairRefused } : {}),
129
+ ...(r.waveId ? { waveId: r.waveId } : {}),
130
+ ...(r.resolvedModel ? { resolvedModel: r.resolvedModel } : {}),
127
131
  status: r.status || 'unknown',
128
132
  durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
129
133
  usage: r.usage || null,
@@ -223,6 +223,7 @@ function listCouncilRuns(project) {
223
223
  model: null, agent: 'Plan', createdAt: run.createdAt,
224
224
  briefing: sanitizePreview(briefing, 80),
225
225
  stage: active ? active.name : null,
226
+ ...(run.tag ? { tag: run.tag } : {}),
226
227
  });
227
228
  }
228
229
  return out;
@@ -21,6 +21,10 @@
21
21
  * spawns the CLI child with an already-expanded `--models` list (never
22
22
  * `--council`), so the preset name would otherwise be lost; the caller
23
23
  * forwards it via the internal `--council-name` passthrough instead.
24
+ * Parallel twin: cli-council-run-bench.js's `resolveBench` wraps the same
25
+ * `resolveCouncilMembers` core with its own XOR rules (CLI failJson docs there,
26
+ * plain `{error}` strings here) and carries one guard this side lacks. The two
27
+ * wrappers evolve independently — change a validation rule on one, change both.
24
28
  */
25
29
  function resolveBenchInput(input) {
26
30
  const inputModels = Array.isArray(input.models) ? input.models : [];
@@ -94,11 +94,13 @@ async function handleCouncilRunTool(input, project, helpers) {
94
94
  // onto input.template above) is the only way one reaches this handler.
95
95
  // {{prompt}} = the briefingFile content; the RENDERED text is what lands in
96
96
  // briefing.md below (mirrors the CLI's single template-application point).
97
+ let templateMeta = null;
97
98
  if (input.template !== undefined) {
98
99
  const { applyTemplate } = require('./template/apply');
99
100
  const t = applyTemplate({ templateRef: input.template, prompt: briefing, project });
100
101
  if (t.error) { return textResult(t.error.message, true); }
101
102
  briefing = t.prompt;
103
+ templateMeta = t.promptMeta && t.promptMeta.template;
102
104
  notices.push(...t.notices);
103
105
  }
104
106
 
@@ -158,6 +160,10 @@ async function handleCouncilRunTool(input, project, helpers) {
158
160
  // v4.5 Wave 2: additive, same preserved-across-the-child's-own-initRun
159
161
  // precedent as `pack` above — absent (never []) when nothing dropped.
160
162
  ...(droppedMembers.length ? { droppedMembers } : {}),
163
+ // T15-m2 (v4.7): additive-only — absent (not null) without a pack-forwarded
164
+ // template. Same preserved-across-the-child's-own-initRun precedent as
165
+ // `pack`/`droppedMembers` above (run-state.js:104-110).
166
+ ...(templateMeta ? { template: templateMeta } : {}),
161
167
  usage: null, createdAt: new Date().toISOString(),
162
168
  });
163
169
  runState.writePointer(project, runId, runDir);
@@ -195,6 +201,9 @@ async function handleCouncilRunTool(input, project, helpers) {
195
201
  if (input.debate) { args.push('--debate'); }
196
202
  if (input.claudeReviewFile) { args.push('--claude-review', path.resolve(project, String(input.claudeReviewFile))); }
197
203
  if (input.noCostGate) { args.push('--no-cost-gate'); }
204
+ // v4.7 F8 (D13): the spawned CLI child's own cli-handlers-council-run.js
205
+ // stores the tag on the run.json seed (Task 3) — this handler only forwards.
206
+ if (input.tag) { args.push('--tag', input.tag); }
198
207
 
199
208
  let child;
200
209
  try { child = helpers.spawnFn(args, runDir); } catch (err) {
@@ -275,6 +284,7 @@ const awareness = require('./mcp-council-awareness');
275
284
 
276
285
  module.exports = {
277
286
  handleCouncilRunTool,
287
+ COUNCIL_PACK_PARAM_MAP,
278
288
  buildCouncilStatusPayload: awareness.buildCouncilStatusPayload,
279
289
  listCouncilRuns: awareness.listCouncilRuns,
280
290
  abortCouncilRun: awareness.abortCouncilRun,