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
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
  };
package/src/mcp-tools.js CHANGED
@@ -121,6 +121,7 @@ function getTools() {
121
121
  'left: flush against the left edge. center: centered.'
122
122
  ),
123
123
  pack: z.string().optional().describe('Policy pack name or path — bench/chair/options/template defaults for this run; explicit params override pack values (recorded either way).'),
124
+ tag: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, '1-64 chars, letters/digits/_/- only').optional().describe('Label this session for list/search/spend grouping'),
124
125
  project: z.string().optional().describe(
125
126
  'Optional project directory path. Auto-detected from working directory if omitted.'
126
127
  ),
@@ -211,10 +212,14 @@ function getTools() {
211
212
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
212
213
  description:
213
214
  'List all Amicus sessions for the current project. Shows task ID, ' +
214
- 'model, status, age, and briefing excerpt.',
215
+ 'model, status, tag, createdAt, and briefing excerpt. Use `search` to ' +
216
+ 'filter by id/tag/briefing substring.',
215
217
  inputSchema: {
216
- status: z.enum(['all', 'running', 'complete']).optional().describe(
217
- 'Filter by status. Default: show all.'
218
+ status: z.string().optional().describe(
219
+ "Filter by status ('all', 'running', 'complete', 'error', 'aborted', …). Default: show all."
220
+ ),
221
+ search: z.string().optional().describe(
222
+ 'Case-insensitive substring filter over id, tag, and briefing material (F8 D15).'
218
223
  ),
219
224
  project: z.string().optional().describe(
220
225
  'Optional project directory path. Auto-detected from working directory if omitted.'
@@ -336,7 +341,7 @@ function getTools() {
336
341
  'Routing preference: auto (direct-first, default), direct (require a ' +
337
342
  'direct provider key), or openrouter (force OpenRouter).'
338
343
  ),
339
- prompt: z.string().describe(
344
+ prompt: z.string().min(1, 'prompt must not be empty').describe(
340
345
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
341
346
  ),
342
347
  agent: z.enum(['Plan', 'Build']).optional().describe(
@@ -345,7 +350,7 @@ function getTools() {
345
350
  thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).optional().describe(
346
351
  'Reasoning effort for every leg. Default: medium.'
347
352
  ),
348
- timeout: z.number().optional().describe(
353
+ timeout: z.number().positive('timeout must be a positive number of minutes').optional().describe(
349
354
  'Per-leg timeout in minutes (wall-clock ≈ slowest leg). Default: 15.'
350
355
  ),
351
356
  summaryLength: z.enum(['brief', 'normal', 'verbose']).optional().describe(
@@ -369,6 +374,7 @@ function getTools() {
369
374
  'commands are NOT accepted over MCP.'
370
375
  ),
371
376
  pack: z.string().optional().describe('Policy pack name or path — bench/chair/options/template defaults for this run; explicit params override pack values (recorded either way).'),
377
+ tag: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, '1-64 chars, letters/digits/_/- only').optional().describe('Label this session for list/search/spend grouping'),
372
378
  project: z.string().optional().describe(
373
379
  'Optional project directory path. Auto-detected from working directory if omitted.'
374
380
  ),
@@ -509,6 +515,7 @@ function getTools() {
509
515
  'commands are NOT accepted over MCP.'
510
516
  ),
511
517
  pack: z.string().optional().describe('Policy pack name or path — bench/chair/options/template defaults for this run; explicit params override pack values (recorded either way).'),
518
+ tag: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, '1-64 chars, letters/digits/_/- only').optional().describe('Label this session for list/search/spend grouping'),
512
519
  ui: z.boolean().optional().describe(
513
520
  'Auto-open the Council Workspace window on this run. Default: opens when the client is ' +
514
521
  'Claude Code (local), Electron is installed, a display exists, and config workspace.autoOpen is ' +
@@ -30,7 +30,7 @@ function applyPackOrExit(args, expectedKind, useJson) {
30
30
  if (args.pack === undefined) { return null; }
31
31
  const explicit = args.__explicit || new Set();
32
32
  const pr = applyPackToArgs({ packRef: args.pack, expectedKind, args, explicit, useJson });
33
- if (pr.error) { process.exit(failJson(useJson, pr.error)); }
33
+ if (pr.error) { return process.exit(failJson(useJson, pr.error)); }
34
34
  for (const n of pr.notices) { process.stderr.write(n + '\n'); }
35
35
  return pr.packRecord;
36
36
  }
@@ -36,10 +36,18 @@
36
36
  * render, so template provenance (`promptMeta.template`) is still
37
37
  * recorded exactly as Wave 1 built it.
38
38
  *
39
- * Callers that only need the pre-spend validation (the two spawn paths)
40
- * ignore the returned `renderedPrompt`; amicus_start's in-process path
41
- * (which never spawns a child, so nothing else would render it) reuses it
42
- * as the actual prompt instead of rendering a second time.
39
+ * `renderedPrompt` has two different consumers now (W1-M4, v4.7 PR7):
40
+ * amicus_start's in-process path (which never spawns a child, so nothing
41
+ * else would render it) reuses it as the actual prompt instead of rendering
42
+ * a second time; amicus_fanout's spawn path writes it to the wave's
43
+ * on-disk `briefing.md` — the `--search` corpus (src/sidecar/list-search.js)
44
+ * — instead of the raw prompt, so a wave whose spawned child aborts before
45
+ * its own render (src/sidecar/fanout.js) stays findable by the text the
46
+ * user actually sees. The spawned child itself still gets the RAW prompt
47
+ * (via a sibling `briefing-input.md`), so its own later render remains the
48
+ * provenance source for `promptMeta.template`. amicus_start's
49
+ * spawn-fallback path is the one remaining caller that still only needs the
50
+ * pre-spend validation and ignores `renderedPrompt`.
43
51
  */
44
52
 
45
53
  const { ERROR_CODES } = require('../utils/error-doc');
@@ -54,6 +54,9 @@ function resolveBenchKnob(pack, args, explicit) {
54
54
  else if (Array.isArray(bench)) { args.models = bench.join(','); }
55
55
  return null;
56
56
  }
57
+ // Both-typed names --models here, but every consuming surface — fanout (cli-handlers-fanout.js:77),
58
+ // council run (cli-council-run-bench.js:45), MCP (mcp-council-bench.js:29) — rejects
59
+ // --models+--council with BAD_ARGS after pack apply, so a both-typed notice is never actionable.
57
60
  const flag = modelsExplicit ? '--models' : '--council';
58
61
  return `Notice: ${flag} overrides the bench from pack '${pack.name}'`;
59
62
  }
@@ -52,7 +52,21 @@ function resolvePackRef(ref) {
52
52
 
53
53
  function stripBom(text) { return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; }
54
54
 
55
- /** @returns {{pack, path, source:'dir'|'path', hash}|{error}} */
55
+ /**
56
+ * @returns {{pack, path, source:'dir'|'path', hash}|{error}}
57
+ * All SIX `{error}` returns below — malformed name, name-form unreadable,
58
+ * path-form unreadable, invalid JSON, non-object body, name/filename mismatch —
59
+ * are mapped to PACK_NOT_FOUND by both callers (pack-resolve.js:76,
60
+ * cli-handlers-pack.js:225). A new `{error}` added here inherits that code
61
+ * silently: re-code it deliberately or it lands as "not found".
62
+ * PACK_NOT_FOUND has a THIRD emit site that never calls readPack — `pack rm`
63
+ * (cli-handlers-pack.js:242, via rmPack) — unified there by the v4.5 HOLD-gate
64
+ * decision 3 and pinned by the "rm nonexistent pack -> PACK_NOT_FOUND" describe
65
+ * in tests/pack/cli-pack-cmd.test.js.
66
+ * PACK_INVALID is NOT readPack's: it belongs to validatePack
67
+ * (pack-resolve.js:94, cli-handlers-pack.js:199) and to prepareForward's
68
+ * maxCost guard (pack-forward.js:68-76), which is not a validatePack call.
69
+ */
56
70
  function readPack(ref) {
57
71
  const r = resolvePackRef(ref);
58
72
  if (r.error) { return r; }
@@ -66,8 +80,11 @@ function readPack(ref) {
66
80
  let pack;
67
81
  try { pack = JSON.parse(raw); }
68
82
  catch (err) { return { error: `Error: pack ${file} is not valid JSON: ${err.message}` }; }
69
- if (r.kind === 'name' && pack && pack.name !== r.name) {
70
- return { error: `Error: pack file ${file} declares name '${pack && pack.name}' which does not match its filename rename one of them` };
83
+ if (pack === null || typeof pack !== 'object' || Array.isArray(pack)) {
84
+ return { error: `Error: pack ${file} is not a pack object (found ${pack === null ? 'null' : Array.isArray(pack) ? 'an array' : typeof pack})` };
85
+ }
86
+ if (r.kind === 'name' && pack.name !== r.name) {
87
+ return { error: `Error: pack file ${file} declares name '${pack.name}' which does not match its filename — rename one of them` };
71
88
  }
72
89
  return { pack, path: file, source: r.kind === 'name' ? 'dir' : 'path', hash: canonicalHash(pack) };
73
90
  }
@@ -74,6 +74,11 @@ function validatePack(pack, { mode } = { mode: 'run' }) {
74
74
  if (typeof pack.model !== 'string' || !pack.model.trim()) { errors.push('solo pack requires model'); }
75
75
  else if (!seatOk(pack.model)) { errors.push(`unresolvable model '${pack.model}'`); }
76
76
  } else {
77
+ // T11-d: bench-independent — a by-name bench used to skip this entirely, so a pack
78
+ // could carry both and only fail (mis-attributed) at handler time.
79
+ if (pack.kind === 'council' && pack.critic && pack.lenses) {
80
+ errors.push('critic and lenses are mutually exclusive');
81
+ }
77
82
  const bench = pack.bench;
78
83
  if (typeof bench === 'string') {
79
84
  const { members } = getCouncilWithSource(bench, []);
@@ -87,7 +92,6 @@ function validatePack(pack, { mode } = { mode: 'run' }) {
87
92
  if (pack.kind === 'council') {
88
93
  if (pack.chair && bench.includes(pack.chair)) { errors.push(`chair '${pack.chair}' is a bench seat — the chair must not review`); }
89
94
  if (pack.critic && !bench.includes(pack.critic)) { errors.push(`critic '${pack.critic}' must be one of the bench seats`); }
90
- if (pack.critic && pack.lenses) { errors.push('critic and lenses are mutually exclusive'); }
91
95
  if (Array.isArray(pack.lenses) && pack.lenses.length !== bench.length) {
92
96
  errors.push(`lenses needs exactly one lens per seat (${bench.length} seats, got ${pack.lenses.length})`);
93
97
  }
@@ -63,20 +63,54 @@ function checkBudget(legs, opts = {}) {
63
63
  return { ok, offending, overCeiling, breakdown: { legs: breakdownLegs, totalEstCost, unpricedCount, maxCostPerMtok: cap, maxCost: opts.maxCost || null } };
64
64
  }
65
65
 
66
- /** Human-readable refusal text (also used as the error envelope `hint`). */
67
- function formatBudgetError(result) {
66
+ /**
67
+ * Human-readable refusal text (also used as the error envelope `hint`).
68
+ * @param {object} result checkBudget's return value
69
+ * @param {{kind:'cli'|'mcp'}} [surface] where the text will be read. Remedies are
70
+ * surface-specific: the MCP tool surface has no --flags, and (since v4.7 PR6's
71
+ * gate hoist) no per-call override at all. Defaults to 'cli' so the two CLI
72
+ * callers stay byte-identical.
73
+ */
74
+ function formatBudgetError(result, surface = { kind: 'cli' }) {
68
75
  const lines = [];
76
+ const isMcp = surface && surface.kind === 'mcp';
69
77
  if (result.offending.length > 0) {
70
78
  lines.push('Budget gate: model(s) over the per-$/Mtok threshold:');
71
79
  for (const o of result.offending) { lines.push(` - ${o.modelInput} (${o.model}): ${o.reason}`); }
72
80
  }
73
81
  if (result.overCeiling) {
74
- lines.push(`Budget gate: estimated total $${result.breakdown.totalEstCost.toFixed(4)} exceeds --max-cost $${result.breakdown.maxCost.toFixed(4)} (estimate, not guaranteed).`);
82
+ // On MCP the ceiling can come from EITHER a pack's `maxCost` option or the
83
+ // config, and the pack wins (mcp-server.js's fwd.maxCost ?? cfg.maxCost).
84
+ // Naming only one of them would send the caller to edit the loser — the very
85
+ // "remedy that cannot work" class this surface split exists to end.
86
+ lines.push(`Budget gate: estimated total $${result.breakdown.totalEstCost.toFixed(4)} exceeds ${isMcp ? 'the effective maxCost' : '--max-cost'} $${result.breakdown.maxCost.toFixed(4)} (estimate, not guaranteed).`);
75
87
  }
76
88
  if (result.breakdown.unpricedCount > 0) {
77
89
  lines.push(`(${result.breakdown.unpricedCount} unpriced leg(s) — direct provider; cost unknown, not included in the estimate.)`);
78
90
  }
79
- lines.push('Override: --max-cost <$> to raise the ceiling, or --no-cost-gate to disable both guards (e.g. an intentional o3 run).');
91
+ // offending and overCeiling are independent (unrelated inputs see module
92
+ // header), and either or both may have fired since formatBudgetError is only
93
+ // called when !ok. Only name a remedy that actually clears every branch that
94
+ // fired; when both fired, raising just one lever will not clear the run.
95
+ const hasOffending = result.offending.length > 0;
96
+ const hasCeiling = result.overCeiling;
97
+ if (isMcp) {
98
+ if (hasOffending && hasCeiling) {
99
+ lines.push("Override: raise maxCostPerMtok in the amicus config AND the effective maxCost (the pack's `maxCost` option if this run used a pack, otherwise the config's), or choose a cheaper model — raising just one will not clear this run.");
100
+ } else if (hasOffending) {
101
+ lines.push('Override: raise maxCostPerMtok in the amicus config, or choose a cheaper model.');
102
+ } else {
103
+ lines.push("Override: raise the effective maxCost — the pack's `maxCost` option if this run used a pack, otherwise the config's — or choose a cheaper model.");
104
+ }
105
+ } else {
106
+ if (hasOffending && hasCeiling) {
107
+ lines.push('Override: --no-cost-gate to disable both guards (e.g. an intentional o3 run) — raising just one of maxCostPerMtok or --max-cost will not clear this run.');
108
+ } else if (hasOffending) {
109
+ lines.push('Override: --no-cost-gate to disable both guards (e.g. an intentional o3 run), or raise maxCostPerMtok in config.');
110
+ } else {
111
+ lines.push('Override: --max-cost <$> to raise the ceiling, or --no-cost-gate to disable both guards (e.g. an intentional o3 run).');
112
+ }
113
+ }
80
114
  return lines.join('\n');
81
115
  }
82
116
 
@@ -61,8 +61,7 @@ function preflightBudget(okLegs, options) {
61
61
  message: 'Error: budget gate refused the wave',
62
62
  hint: `Budget gate: estimated total $${estimate.toFixed(4)} does not fit the --max-cost `
63
63
  + 'allowance still unclaimed by concurrently launching waves (estimate, not guaranteed).\n'
64
- + 'The run continues with the waves that did launch. Override: --max-cost <$> to raise the '
65
- + 'ceiling, or --no-cost-gate to disable both guards.',
64
+ + 'The run continues with the waves that did launch.',
66
65
  };
67
66
  }
68
67
  return { ok: true, estimate };
@@ -18,9 +18,12 @@ const { deriveChain } = require('./fallback-chains');
18
18
 
19
19
  /**
20
20
  * Append ONE attributed ledger row for a single attempt (spec 6.2/7.1). At
21
- * `attempt:0` this is byte-identical to today's pre-fallback appendSpend row
22
- * (same fields, sourced from `leg.attempt`/`leg.substitutedFor`/
23
- * `leg.retryOfWaveId`, all undefined -> omitted for a plain leg). A
21
+ * `attempt:0` this row's LINKAGE fields (attempt/substitutedFor/retryOfWaveId,
22
+ * all undefined -> omitted for a plain leg) are byte-identical to today's
23
+ * pre-fallback appendSpend row. v4.7 F8 D16 adds `tag` a NULLABLE dim, not a
24
+ * linkage field, so every row now carries it (null-default when the leg was
25
+ * never tagged) rather than omitting it; the byte-identity claim above applies
26
+ * only to the linkage-field convention, not to the full row shape. A
24
27
  * substitution (`attempt` param > 0, the LOOP's attempt index) overrides
25
28
  * `row.attempt`/`row.substitutedFor` with the substitution's own values.
26
29
  * Best-effort — never throws, never affects the leg. `deps.spendDir` (tests)
@@ -42,6 +45,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
42
45
  councilRunId: leg && leg.councilRunId, councilName: leg && leg.councilName,
43
46
  project, attempt: leg && leg.attempt, substitutedFor: leg && leg.substitutedFor,
44
47
  retryOfWaveId: leg && leg.retryOfWaveId,
48
+ tag: (leg && leg.tag) || null,
45
49
  };
46
50
  if (attempt > 0) { row.attempt = attempt; row.substitutedFor = originalModel; }
47
51
  appendSpend(row, deps.spendDir ? { dir: deps.spendDir } : undefined);
@@ -72,4 +72,16 @@ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedA
72
72
  return { wave, exitCode };
73
73
  }
74
74
 
75
- module.exports = { writeWaveMetadata, writeWaveDoc, finishWave };
75
+ /**
76
+ * v4.3 §7.2 (moved here v4.7 PR3 Task 1): stamp council attribution onto every
77
+ * leg — fanout-leg's appendSpend reads it; no-op for every non-council caller.
78
+ * v4.7 F8 (Task 7) adds tag stamping in the same pass.
79
+ */
80
+ function stampLegAttribution(legs, options) {
81
+ if (options.councilRunId || options.councilName) {
82
+ legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
83
+ }
84
+ if (options.tag) { legs.forEach(l => { l.tag = options.tag; }); }
85
+ }
86
+
87
+ module.exports = { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution };
@@ -19,7 +19,7 @@ const { ERROR_CODES } = require('../utils/error-doc');
19
19
  // Wave-document persistence lives in ./fanout-wave-io (size-gate split, v4.4.1
20
20
  // Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
21
21
  // fanout tests import it from here.
22
- const { writeWaveMetadata, writeWaveDoc, finishWave } = require('./fanout-wave-io');
22
+ const { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution } = require('./fanout-wave-io');
23
23
 
24
24
  /**
25
25
  * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
@@ -86,7 +86,11 @@ async function runFanout(options) {
86
86
  // `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
87
87
  // waveDir is optional (only the post-creation caller has one).
88
88
  const errorWave = (waveId, message, waveDir) => {
89
- const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack, createdAt, completedAt: new Date().toISOString(), status: 'error' });
89
+ // v4.7 F8 (D13, T3 review): tag: options.tag || metaTag, TDZ-safe (sole call site runs after `const metaTag` below).
90
+ // v4.7 PR3 rider: pack got the same `|| metaPack` inherit the other two
91
+ // buildWaveResult sites have — it was the lone holdout, so an MCP-spawned
92
+ // wave whose server died dropped its pre-seeded pack while keeping its tag.
93
+ const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt: new Date().toISOString(), status: 'error' });
90
94
  doc.error = message;
91
95
  doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
92
96
  // best-effort: an unwritable wave dir must not mask the real error
@@ -119,11 +123,7 @@ async function runFanout(options) {
119
123
  });
120
124
  if (validated.error) { return failPre(validated.code || 'BAD_ARGS', validated.error); }
121
125
  const legs = validated.legs;
122
- // v4.3 §7.2: stamp council attribution onto every leg (fanout-leg.js's
123
- // existing appendSpend reads it); no-op for every non-council caller.
124
- if (options.councilRunId || options.councilName) {
125
- legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
126
- }
126
+ stampLegAttribution(legs, options);
127
127
  const okLegs = legs.filter(l => l.ok);
128
128
  // FIX 2 (#61 whole-branch review): a leg's migration notice has no CLI
129
129
  // stderr to land on — surface it on the wave doc instead, deduped in case
@@ -149,6 +149,7 @@ async function runFanout(options) {
149
149
  briefing: String(options.prompt).slice(0, 200),
150
150
  promptMeta: options.promptMeta || null,
151
151
  ...(options.pack ? { pack: options.pack } : {}), // v4.5 Task 13: absent-not-null.
152
+ ...(options.tag ? { tag: options.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
152
153
  pid: process.pid, project, createdAt,
153
154
  });
154
155
  // v4.5 final-review F2: an MCP-spawned child never gets --pack (single-
@@ -161,6 +162,7 @@ async function runFanout(options) {
161
162
  // result-schema-rebuild.js:93, which reads meta.pack off a metadata.json
162
163
  // it loaded for an unrelated reason).
163
164
  const metaPack = waveMeta.pack;
165
+ const metaTag = waveMeta.tag; // v4.7 F8 (D13): same pre-seed inherit mechanism as metaPack above.
164
166
  emitWaveStarted(waveDir, waveId, legs.map(l => (l.ok ? l.model : l.modelInput)), legIds, follow);
165
167
 
166
168
  // 2b. All legs failed to route (#61 perf): no leg will ever touch the
@@ -171,7 +173,7 @@ async function runFanout(options) {
171
173
  const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
172
174
  const completedAt = new Date().toISOString();
173
175
  const wave = buildWaveResult({
174
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt, notices,
176
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt, notices,
175
177
  });
176
178
  return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
177
179
  exitCode: waveExitCode(wave.status),
@@ -282,7 +284,7 @@ async function runFanout(options) {
282
284
  const completedAt = new Date().toISOString();
283
285
  const signalled = waveAbort.signal();
284
286
  const wave = buildWaveResult({
285
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt,
287
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt,
286
288
  status: signalled ? 'aborted' : null, notices,
287
289
  });
288
290
  const exitCode = signalled