amicus 4.6.2 → 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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +24 -13
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +172 -36
  6. package/docs/configuration.md +56 -6
  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 +68 -15
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +132 -21
  14. package/electron/workspace-ui/workspace-app.js +20 -4
  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 +15 -5
  19. package/electron/workspace-ui/workspace-seats.js +88 -5
  20. package/electron/workspace-ui/workspace-verbs.js +1 -1
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +5 -2
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  29. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  30. package/skills/sidecar/SKILL.md +7 -2
  31. package/src/cli-council-run-bench.js +86 -0
  32. package/src/cli-handlers-council-run.js +65 -81
  33. package/src/cli-handlers-council.js +24 -3
  34. package/src/cli-handlers-doctor.js +9 -3
  35. package/src/cli-handlers-fanout.js +179 -0
  36. package/src/cli-handlers-pack.js +24 -10
  37. package/src/cli-handlers-run.js +19 -161
  38. package/src/cli-template-args.js +48 -0
  39. package/src/cli.js +39 -46
  40. package/src/council/debate.js +89 -10
  41. package/src/council/ledger.js +72 -11
  42. package/src/council/presets-cli.js +6 -2
  43. package/src/council/report.js +17 -6
  44. package/src/council/run-assemble.js +15 -3
  45. package/src/council/run-budget.js +2 -2
  46. package/src/council/run-chair.js +70 -11
  47. package/src/council/run-debate.js +51 -67
  48. package/src/council/run-launch.js +9 -2
  49. package/src/council/run-retry.js +4 -1
  50. package/src/council/run-stage1-launch.js +94 -0
  51. package/src/council/run-stage2.js +25 -4
  52. package/src/council/run-stages.js +79 -86
  53. package/src/council/run-state.js +10 -2
  54. package/src/council/run.js +26 -2
  55. package/src/council/tally.js +6 -2
  56. package/src/mcp-council-awareness.js +1 -0
  57. package/src/mcp-council-bench.js +4 -0
  58. package/src/mcp-council-run.js +10 -0
  59. package/src/mcp-server.js +114 -54
  60. package/src/mcp-tools.js +12 -5
  61. package/src/pack/pack-cli.js +1 -1
  62. package/src/pack/pack-forward.js +12 -4
  63. package/src/pack/pack-resolve.js +3 -0
  64. package/src/pack/pack-store.js +20 -3
  65. package/src/pack/pack-validate.js +5 -1
  66. package/src/session-manager.js +6 -2
  67. package/src/sidecar/budget.js +38 -4
  68. package/src/sidecar/fanout-budget.js +1 -2
  69. package/src/sidecar/fanout-leg-fallback.js +7 -3
  70. package/src/sidecar/fanout-wave-io.js +13 -1
  71. package/src/sidecar/fanout.js +11 -9
  72. package/src/sidecar/list-limit.js +50 -0
  73. package/src/sidecar/list-search.js +69 -0
  74. package/src/sidecar/read.js +90 -5
  75. package/src/sidecar/start-metadata.js +58 -0
  76. package/src/sidecar/start.js +8 -43
  77. package/src/sidecar/workspace-auto-open.js +2 -2
  78. package/src/spend-query.js +2 -1
  79. package/src/template/apply.js +7 -4
  80. package/src/template/render.js +6 -2
  81. package/src/template/store.js +1 -1
  82. package/src/utils/alias-audit.js +19 -0
  83. package/src/utils/cli-preflight.js +27 -1
  84. package/src/utils/config.js +15 -0
  85. package/src/utils/curated-models.js +43 -7
  86. package/src/utils/gateway-route-audit.js +16 -3
  87. package/src/utils/model-fetcher.js +8 -6
  88. package/src/utils/remediation-hints.js +14 -0
  89. package/src/utils/result-schema-rebuild.js +1 -0
  90. package/src/utils/result-schema.js +6 -1
  91. package/src/utils/session-index-tmp-sweep.js +18 -3
  92. package/src/utils/session-index.js +1 -0
  93. package/src/utils/session-metadata-tmp-sweep.js +156 -0
  94. package/src/utils/spend-ledger.js +11 -4
  95. package/src/utils/validators.js +16 -0
@@ -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,
package/src/mcp-server.js CHANGED
@@ -423,6 +423,10 @@ const handlers = {
423
423
  // gate / template pre-render) for parity between the two paths.
424
424
  if (packForward.maxCost !== undefined) { args.push('--max-cost', String(packForward.maxCost)); }
425
425
  if (packForward.template !== undefined) { args.push('--template', packForward.template); }
426
+ // v4.7 F8 (D13, errata E-PR3-2): forwarded for the spawn-fallback path
427
+ // below — DEAD on the shared-server branch (args is never read there);
428
+ // that branch stamps input.tag directly into its own metadata write instead.
429
+ if (input.tag) { args.push('--tag', input.tag); }
426
430
  args.push('--cwd', cwd);
427
431
 
428
432
  if (sharedServer.enabled && input.noUi) {
@@ -435,28 +439,30 @@ const handlers = {
435
439
  // a fall-through to the spawn-fallback catch must never leak one.
436
440
  const inProcessNotices = [...fwd.notices];
437
441
  const renderedPrompt = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
438
- if (packForward.maxCost !== undefined) {
439
- // fwd.maxCost is already validated (I2); the ceiling gate itself
440
- // stays here (needs resolvedModel/pricing, unavailable to pack-forward.js).
441
- const { lookupPricing } = require('./utils/pricing');
442
- const { checkBudget, formatBudgetError } = require('./sidecar/budget');
443
- const { loadConfig } = require('./utils/config');
444
- const cfg = loadConfig() || {};
445
- const soloLeg = { modelInput: input.model || resolvedModel, model: resolvedModel, pricing: lookupPricing(resolvedModel) };
446
- const budget = checkBudget([soloLeg], {
447
- maxCostPerMtok: cfg.maxCostPerMtok, maxCost: fwd.maxCost,
448
- promptChars: (renderedPrompt && renderedPrompt.length) || 0,
449
- });
450
- if (!budget.ok) {
451
- const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
452
- return {
453
- isError: true,
454
- content: [{ type: 'text', text: JSON.stringify(buildErrorDoc({
455
- code: ERROR_CODES.BUDGET_EXCEEDED, message: 'Error: budget gate refused the run',
456
- hint: formatBudgetError(budget),
457
- })) }],
458
- };
459
- }
442
+ // v4.7 PR6: the gate used to hang off `packForward.maxCost !== undefined`,
443
+ // so a no-pack MCP start skipped it while the CLI (cli-handlers-run.js:90)
444
+ // gated unconditionally with a cfg.maxCost fallback. Same guard, both doors.
445
+ // fwd.maxCost is already validated (I2); the ceiling gate itself
446
+ // stays here (needs resolvedModel/pricing, unavailable to pack-forward.js).
447
+ const { lookupPricing } = require('./utils/pricing');
448
+ const { checkBudget, formatBudgetError } = require('./sidecar/budget');
449
+ const { loadConfig } = require('./utils/config');
450
+ const cfg = loadConfig() || {};
451
+ const soloLeg = { modelInput: input.model || resolvedModel, model: resolvedModel, pricing: lookupPricing(resolvedModel) };
452
+ const budget = checkBudget([soloLeg], {
453
+ maxCostPerMtok: cfg.maxCostPerMtok,
454
+ maxCost: fwd.maxCost !== undefined ? fwd.maxCost : cfg.maxCost,
455
+ promptChars: (renderedPrompt && renderedPrompt.length) || 0,
456
+ });
457
+ if (!budget.ok) {
458
+ const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
459
+ return {
460
+ isError: true,
461
+ content: [{ type: 'text', text: JSON.stringify(buildErrorDoc({
462
+ code: ERROR_CODES.BUDGET_EXCEEDED, message: 'Error: budget gate refused the run',
463
+ hint: formatBudgetError(budget, { kind: 'mcp' }),
464
+ })) }],
465
+ };
460
466
  }
461
467
 
462
468
  const { server, client } = await sharedServer.ensureServer();
@@ -500,6 +506,10 @@ const handlers = {
500
506
  briefing: renderedPrompt,
501
507
  // v4.5 Task 15: additive-only — absent (not null) without a pack.
502
508
  ...(packRecord ? { pack: packRecord } : {}),
509
+ // v4.7 F8 (D13, errata E-PR3-2): THE critical site — this shared-server
510
+ // branch never spawns a CLI child, so the --tag argv forward above
511
+ // (dead here) can never reach it. Same additive-only idiom as pack.
512
+ ...(input.tag ? { tag: input.tag } : {}),
503
513
  }, null, 2), { mode: 0o600 });
504
514
 
505
515
  // Build context from parent conversation (unless --no-context)
@@ -693,6 +703,19 @@ const handlers = {
693
703
  // resolved the route in-process above, so surface its notice here.
694
704
  const spawnContent = [{ type: 'text', text: body }];
695
705
  if (routeResult.notice) { spawnContent.push({ type: 'text', text: routeResult.notice }); }
706
+ // W1-M6/M7: NOT dead code. This is the same idiom applyPackToMcpInput's
707
+ // orphan-knob notices always use — amicus_fanout genuinely reaches it today
708
+ // (FANOUT_PACK_PARAM_MAP has no contextTurns/contextMaxTokens destination;
709
+ // the fanout handler's own push into waveContent below is covered by
710
+ // mcp-pack-params.test.js's "notice wording (decision 1b)" test — named,
711
+ // not line-numbered, because a line citation goes stale the moment anything
712
+ // is inserted above it, including by its own commit). On THIS solo surface it is
713
+ // unreachable only while KIND_OPTIONS.solo stays fully covered by
714
+ // SOLO_PACK_PARAM_MAP destinations + pack-resolve.js's FORWARDABLE_ARG_KEYS
715
+ // — the invariant tests/pack/mcp-pack-params.test.js guards (the
716
+ // KIND_OPTIONS.solo round-trip test, mutation-proven against a synthetic
717
+ // orphaned knob). If that invariant ever breaks, this loop is what turns a
718
+ // silent drop into a visible notice — never delete it as unreachable.
696
719
  for (const n of packNotices) { spawnContent.push({ type: 'text', text: n }); }
697
720
  if (isHeadless) {
698
721
  spawnContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
@@ -984,37 +1007,11 @@ const handlers = {
984
1007
  const root = path.join(cwd, '.claude', SESSIONS_DIR);
985
1008
  if (!fs.existsSync(root)) { return textResult('No amicus sessions found.'); }
986
1009
 
1010
+ // v4.7 F8 (D14): one enumeration behind both the CLI and MCP list surfaces
1011
+ // — enumerateSessions is the shared core (src/sidecar/read.js).
1012
+ const { enumerateSessions, searchSessions } = require('./sidecar/read');
987
1013
  const byId = new Map();
988
- for (const d of fs.readdirSync(root)) {
989
- if (!/^[a-zA-Z0-9_-]{1,64}$/.test(d)) { continue; }
990
- if (byId.has(d)) { continue; }
991
- const metaPath = path.join(root, d, 'metadata.json');
992
- if (!fs.existsSync(metaPath)) { continue; }
993
- try {
994
- const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
995
- const entry = {
996
- id: d, model: meta.model, status: meta.status, agent: meta.agent,
997
- briefing: sanitizePreview(String(meta.briefing || ''), 80),
998
- createdAt: meta.createdAt,
999
- mode: meta.mode
1000
- || (meta.headless === undefined ? undefined : (meta.headless ? 'headless' : 'interactive')),
1001
- };
1002
- // Live-progress enrichment for RUNNING sessions only — readProgress
1003
- // parses conversation.jsonl, so terminal rows stay cheap.
1004
- if (meta.status === 'running') {
1005
- try {
1006
- const p = readProgress(path.join(root, d));
1007
- entry.phase = deriveStage(meta.status, p.stage);
1008
- entry.messageCount = p.messages;
1009
- entry.lastActivityAt = p.lastActivityAt;
1010
- entry.latestPreview = p.latestPreview;
1011
- } catch { /* progress optional */ }
1012
- }
1013
- byId.set(d, entry);
1014
- } catch {
1015
- // Skip unreadable metadata
1016
- }
1017
- }
1014
+ for (const row of enumerateSessions(cwd, {})) { byId.set(row.id, row); }
1018
1015
 
1019
1016
  // v4.0 §8: council runs are pointer files in the same sessions root — merge
1020
1017
  // them as first-class rows (type 'council-run') before sorting/filtering.
@@ -1025,8 +1022,34 @@ const handlers = {
1025
1022
  if (input.status && input.status !== 'all') {
1026
1023
  sessions = sessions.filter(s => s.status === input.status);
1027
1024
  }
1025
+ // v4.7 F8 (D15, errata E-PR3-5): search runs on RAW briefing material —
1026
+ // BEFORE the sanitize/enrich pass below truncates row.briefing to an
1027
+ // 80-char preview. Council rows' material is read straight off disk
1028
+ // inside searchSessions; the `briefing` merged onto them here is already
1029
+ // listCouncilRuns' own sanitized preview and is never used for matching.
1030
+ if (input.search) { sessions = searchSessions(sessions, input.search, { project: cwd }); }
1028
1031
  if (sessions.length === 0) { return textResult('No amicus sessions found.'); }
1029
1032
 
1033
+ // Rows are re-grafted here with the EXACT decorations this handler always
1034
+ // applied: sanitized (not raw) briefing, and running-only live-progress
1035
+ // enrichment. Council rows already carry a sanitized briefing from
1036
+ // listCouncilRuns, so they're left alone.
1037
+ for (const row of sessions) {
1038
+ if (row.type === 'council-run') { continue; }
1039
+ row.briefing = sanitizePreview(String(row.briefing || ''), 80);
1040
+ // Live-progress enrichment for RUNNING sessions only — readProgress
1041
+ // parses conversation.jsonl, so terminal rows stay cheap.
1042
+ if (row.status === 'running') {
1043
+ try {
1044
+ const p = readProgress(path.join(root, row.id));
1045
+ row.phase = deriveStage(row.status, p.stage);
1046
+ row.messageCount = p.messages;
1047
+ row.lastActivityAt = p.lastActivityAt;
1048
+ row.latestPreview = p.latestPreview;
1049
+ } catch { /* progress optional */ }
1050
+ }
1051
+ }
1052
+
1030
1053
  return textResult(JSON.stringify(sessions, null, 2));
1031
1054
  },
1032
1055
 
@@ -1237,6 +1260,17 @@ const handlers = {
1237
1260
  if (fwd.error) { return textResult(fwd.error.message + (fwd.error.hint ? `\n${fwd.error.hint}` : ''), true); }
1238
1261
  packNotices.push(...fwd.notices);
1239
1262
 
1263
+ // ⚠️ v4.7 PR7: the zod schema closes the TYPED door; a pack can push the same values through
1264
+ // the other one (validatePack checks option KEY names, never value types). Both entrances
1265
+ // reach the same spawn, so the check lives here, after pack merge, before any wave dir.
1266
+ if (typeof input.prompt !== 'string' || !input.prompt.trim()) {
1267
+ return textResult('Error: prompt must not be empty.', true);
1268
+ }
1269
+ if (input.timeout !== undefined
1270
+ && (typeof input.timeout !== 'number' || !Number.isFinite(input.timeout) || input.timeout <= 0)) {
1271
+ return textResult('Error: timeout must be a positive number of minutes.', true);
1272
+ }
1273
+
1240
1274
  // Resolve a single effective models list (council OR models), validated
1241
1275
  // BEFORE any wave dir / metadata is written so a bad request never strands
1242
1276
  // a pid-less 'running' orphan wave.
@@ -1270,15 +1304,34 @@ const handlers = {
1270
1304
  const waveDir = getSessionDir(cwd, waveId);
1271
1305
 
1272
1306
  let briefingPath;
1307
+ let childPromptPath;
1273
1308
  try {
1274
1309
  fs.mkdirSync(waveDir, { recursive: true, mode: 0o700 });
1275
1310
  briefingPath = path.join(waveDir, 'briefing.md');
1311
+ // ⚠️ W1-M4 (v4.7 PR7): this default is LOAD-BEARING. Omit it and every non-template wave
1312
+ // spawns with `--prompt-file undefined`.
1313
+ childPromptPath = briefingPath;
1276
1314
  // The prompt goes via file: the spawned command line must NOT carry it,
1277
1315
  // or it re-hits the ~32KB Windows argument cap (F4 spec §4.2).
1278
- fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
1316
+ // ⚠️ W1-M4: briefing.md is the SEARCH CORPUS — src/sidecar/list-search.js reads it verbatim
1317
+ // — and a child that aborts before fanout.js:145 never re-renders it, leaving the wave
1318
+ // permanently unfindable by the text the user actually sees. Write the RENDERED text here
1319
+ // (parity with the amicus_start path at :441/:506) and hand the child the raw input in a
1320
+ // sibling file, so its own later re-render still produces byte-identical output and
1321
+ // promptMeta.template provenance survives.
1322
+ const briefingText = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
1323
+ fs.writeFileSync(briefingPath, briefingText, { mode: 0o600 });
1324
+ if (fwd.renderedPrompt !== undefined) {
1325
+ childPromptPath = path.join(waveDir, 'briefing-input.md');
1326
+ fs.writeFileSync(childPromptPath, input.prompt, { mode: 0o600 });
1327
+ }
1328
+ // ⚠️ W1-M4 rider (owner-approved fold): the `briefing` key below must stay — without it the
1329
+ // `list` BRIEFING column renders EMPTY for an aborted MCP wave, since only fanout.js:147
1330
+ // ever wrote a briefing key and an aborted child never reaches it.
1279
1331
  writeFileAtomic(path.join(waveDir, 'metadata.json'), JSON.stringify({
1280
1332
  taskId: waveId, type: 'wave', status: 'running', legs: legIds,
1281
1333
  models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
1334
+ briefing: briefingText.slice(0, 200),
1282
1335
  // v4.5 Task 15: additive-only — absent (not null) without a pack.
1283
1336
  ...(packRecord ? { pack: packRecord } : {}),
1284
1337
  }, null, 2), { mode: 0o600 });
@@ -1292,7 +1345,7 @@ const handlers = {
1292
1345
 
1293
1346
  const args = [
1294
1347
  'fanout', '--models', effectiveModels.join(','),
1295
- '--prompt-file', briefingPath, '--wave-id', waveId,
1348
+ '--prompt-file', childPromptPath, '--wave-id', waveId,
1296
1349
  '--json', '--client', detectClient(mcpServer), '--cwd', cwd,
1297
1350
  ];
1298
1351
  const agent = input.agent || 'Build';
@@ -1317,6 +1370,12 @@ const handlers = {
1317
1370
  // itself is never forwarded, only the two knobs it resolved to).
1318
1371
  if (packForward.maxCost !== undefined) { args.push('--max-cost', String(packForward.maxCost)); }
1319
1372
  if (packForward.template !== undefined) { args.push('--template', packForward.template); }
1373
+ // v4.7 F8 (D13, errata E-PR3-2): argv-only forward — unlike pack, this does
1374
+ // NOT pre-seed the wave metadata (no single-resolution rule forces it here);
1375
+ // the spawned CLI child's own cli-handlers-fanout.js stores the tag on wave
1376
+ // metadata itself (Task 3), so fanout.js's metaTag inherit arm has no MCP
1377
+ // producer and is defense-in-depth only.
1378
+ if (input.tag) { args.push('--tag', input.tag); }
1320
1379
 
1321
1380
  let child;
1322
1381
  try { child = spawnSidecarProcess(args, waveDir); } catch (err) {
@@ -1506,4 +1565,5 @@ async function startMcpServer() {
1506
1565
 
1507
1566
  module.exports = {
1508
1567
  handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
1568
+ FANOUT_PACK_PARAM_MAP, SOLO_PACK_PARAM_MAP,
1509
1569
  };