amicus 4.6.3 → 4.7.1

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 (99) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -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 +52 -4
  7. package/docs/council.md +65 -12
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/testing.md +1 -1
  11. package/docs/troubleshooting.md +33 -5
  12. package/docs/usage.md +64 -14
  13. package/electron/workspace-ui/index.html +3 -0
  14. package/electron/workspace-ui/live-model.js +52 -14
  15. package/electron/workspace-ui/workspace-app.js +14 -3
  16. package/electron/workspace-ui/workspace-lazy.js +233 -0
  17. package/electron/workspace-ui/workspace-matrix.js +12 -1
  18. package/electron/workspace-ui/workspace-panels.js +24 -171
  19. package/electron/workspace-ui/workspace-render.js +6 -2
  20. package/electron/workspace-ui/workspace-seats.js +68 -0
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +8 -4
  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/scripts/postinstall.js +6 -3
  29. package/scripts/setup-hooks.js +49 -3
  30. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  31. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  32. package/skills/sidecar/SKILL.md +9 -2
  33. package/src/cli-council-run-bench.js +86 -0
  34. package/src/cli-handlers-council-run.js +65 -81
  35. package/src/cli-handlers-council.js +17 -5
  36. package/src/cli-handlers-fanout.js +179 -0
  37. package/src/cli-handlers-pack.js +24 -10
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/cli-handlers-run.js +19 -161
  40. package/src/cli-template-args.js +48 -0
  41. package/src/cli.js +39 -46
  42. package/src/council/debate.js +89 -10
  43. package/src/council/ledger.js +72 -11
  44. package/src/council/report.js +17 -6
  45. package/src/council/run-assemble.js +15 -3
  46. package/src/council/run-budget.js +2 -2
  47. package/src/council/run-chair.js +61 -5
  48. package/src/council/run-debate.js +51 -67
  49. package/src/council/run-launch.js +20 -2
  50. package/src/council/run-retry.js +17 -2
  51. package/src/council/run-stage1-launch.js +94 -0
  52. package/src/council/run-stage2.js +25 -4
  53. package/src/council/run-stages.js +79 -86
  54. package/src/council/run-state.js +10 -2
  55. package/src/council/run.js +26 -2
  56. package/src/council/tally.js +6 -2
  57. package/src/headless.js +69 -6
  58. package/src/mcp-council-awareness.js +1 -0
  59. package/src/mcp-council-bench.js +4 -0
  60. package/src/mcp-council-run.js +10 -0
  61. package/src/mcp-server.js +114 -54
  62. package/src/mcp-tools.js +12 -5
  63. package/src/pack/pack-cli.js +1 -1
  64. package/src/pack/pack-forward.js +12 -4
  65. package/src/pack/pack-resolve.js +3 -0
  66. package/src/pack/pack-store.js +20 -3
  67. package/src/pack/pack-validate.js +5 -1
  68. package/src/sidecar/budget.js +38 -4
  69. package/src/sidecar/continue.js +8 -23
  70. package/src/sidecar/fanout-budget.js +1 -2
  71. package/src/sidecar/fanout-leg-fallback.js +7 -3
  72. package/src/sidecar/fanout-retry.js +15 -3
  73. package/src/sidecar/fanout-wave-io.js +13 -1
  74. package/src/sidecar/fanout.js +11 -9
  75. package/src/sidecar/list-limit.js +50 -0
  76. package/src/sidecar/list-search.js +69 -0
  77. package/src/sidecar/read.js +90 -5
  78. package/src/sidecar/reopen-spend.js +32 -0
  79. package/src/sidecar/resume.js +1 -1
  80. package/src/sidecar/start-metadata.js +58 -0
  81. package/src/sidecar/start.js +8 -43
  82. package/src/sidecar/workspace-auto-open.js +2 -2
  83. package/src/spend-query.js +2 -1
  84. package/src/template/apply.js +7 -4
  85. package/src/template/render.js +6 -2
  86. package/src/template/store.js +1 -1
  87. package/src/utils/cli-preflight.js +27 -1
  88. package/src/utils/config.js +15 -0
  89. package/src/utils/doctor-engine-check.js +32 -0
  90. package/src/utils/engine-install-scan.js +98 -15
  91. package/src/utils/engine-repair.js +96 -2
  92. package/src/utils/remediation-hints.js +29 -0
  93. package/src/utils/result-schema-rebuild.js +1 -0
  94. package/src/utils/result-schema.js +6 -1
  95. package/src/utils/session-index-tmp-sweep.js +18 -3
  96. package/src/utils/session-index.js +1 -0
  97. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  98. package/src/utils/spend-ledger.js +11 -4
  99. 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
 
@@ -88,7 +88,7 @@ Build on the previous sidecar's findings. The user wants to continue or extend t
88
88
 
89
89
  /** Create session metadata for continuation */
90
90
  function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
91
- const { model, briefing, headless, agent, gateway, resolutionVersion } = options;
91
+ const { model, briefing, headless, agent, gateway, resolutionVersion, tag } = options;
92
92
 
93
93
  const sessionDir = SessionPaths.sessionDir(project, taskId);
94
94
  fs.mkdirSync(sessionDir, { recursive: true });
@@ -102,7 +102,11 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
102
102
  agent: agent || (headless ? 'build' : 'chat'),
103
103
  status: 'running',
104
104
  createdAt: new Date().toISOString(),
105
- continuesFrom: oldTaskId
105
+ continuesFrom: oldTaskId,
106
+ // v4.7.1 Task 7 (D13): absent-not-null, same idiom as start-metadata.js:50
107
+ // — a continuation inherits the parent's tag so a continue chain never
108
+ // scatters into `(unattributed)`.
109
+ ...(tag ? { tag } : {}),
106
110
  };
107
111
  // #61 Task 5.2 (best-effort provenance): only present when THIS continue
108
112
  // call freshly routed an explicit --model through the gateway router — the
@@ -116,26 +120,6 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
116
120
  return sessionDir;
117
121
  }
118
122
 
119
- /**
120
- * Resolve a reopened session's usage, write it onto metadata, and append one
121
- * attributed ledger row. Mirrors start.js's finalize (the only sites that
122
- * dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
123
- * @returns {{usage: object|null}}
124
- */
125
- function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
126
- const { resolveUsage } = require('../utils/pricing');
127
- const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
128
- if (usage) {
129
- metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
130
- try {
131
- const { appendSpend } = require('../utils/spend-ledger');
132
- const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
133
- appendSpend({ taskId, model, mode, usage, op, status, project, gateway }, ctx);
134
- } catch { /* best-effort */ }
135
- }
136
- return { usage };
137
- }
138
-
139
123
  /**
140
124
  * Continue from a previous sidecar session - Spec Reference: §4.4, §8.5
141
125
  * @returns {Promise<number>} process exit code
@@ -196,6 +180,7 @@ async function continueSidecar(options) {
196
180
 
197
181
  const sessionDir = createContinueSessionMetadata(newTaskId, project, {
198
182
  model, briefing, headless, agent: effectiveAgent, gateway, resolutionVersion,
183
+ tag: oldMetadata.tag, // v4.7.1 Task 7: inherit the parent's tag (absent if the parent had none).
199
184
  }, oldTaskId);
200
185
 
201
186
  // Lock the NEW continuation session dir too — not just the previous one — so a
@@ -270,6 +255,7 @@ async function continueSidecar(options) {
270
255
  // ledger row (status: statusFromResult, matching start.js — not terminal.status).
271
256
  {
272
257
  const { statusFromResult } = require('../utils/result-schema');
258
+ const { finalizeSpendForReopen } = require('./reopen-spend');
273
259
  const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
274
260
  const { usage } = finalizeSpendForReopen({
275
261
  taskId: newTaskId, model, mode: headless ? 'headless' : 'interactive',
@@ -292,6 +278,5 @@ module.exports = {
292
278
  loadPreviousSession,
293
279
  buildContinuationContext,
294
280
  createContinueSessionMetadata,
295
- finalizeSpendForReopen,
296
281
  continueSidecar
297
282
  };
@@ -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);
@@ -73,7 +73,10 @@ function buildRetryPlan(origWaveId, project, { models } = {}) {
73
73
  } catch { /* legacy leg — fall back to briefing.md at launch time */ }
74
74
  eligible.push({ legId, model, systemPrompt, userMessage, hadSavedContext });
75
75
  }
76
- return { eligible };
76
+ // waveMeta has been read since :51 but was discarded; retryFailedWave needs
77
+ // the tag BEFORE it builds fanoutOpts, and its own origMeta re-read at :176
78
+ // runs after runFanoutImpl at :153 — too late to influence the launch.
79
+ return { eligible, tag: waveMeta.tag };
77
80
  }
78
81
 
79
82
  /**
@@ -121,7 +124,7 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
121
124
  // --json caller's stdout stays machine-parseable either way.
122
125
  const noopDoc = {
123
126
  ...buildWaveResult({ waveId: origWaveId, legs: [], status: 'complete' }),
124
- retryOf: origWaveId, effective: [], note: 'no failed legs',
127
+ retryOf: origWaveId, effective: [], note: 'no failed legs', tag: plan.tag,
125
128
  };
126
129
  process.stdout.write(JSON.stringify(noopDoc, null, 2) + '\n');
127
130
  } else {
@@ -148,7 +151,16 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
148
151
  // expects (parseModelsList/validateFanoutModels) — an array silently fails
149
152
  // every leg pre-flight (BAD_ARGS), matching run-launch.js:41's precedent.
150
153
  // Strip our own injection key so it is never forwarded.
151
- const fanoutOpts = { ...opts, models: models.join(','), prompt: briefing, project, waveId: newWaveId, retryContexts, retryOfWaveId: origWaveId };
154
+ const fanoutOpts = {
155
+ ...opts, models: models.join(','), prompt: briefing, project, waveId: newWaveId, retryContexts, retryOfWaveId: origWaveId,
156
+ // v4.7.1 Task 8: inherit the ORIGINAL wave's tag — sourced from disk
157
+ // (plan.tag, off waveMeta) never from opts.tag/args.tag. This is placed
158
+ // AFTER the `...opts` spread so it wins even if a caller's opts somehow
159
+ // carried a tag (the CLI itself already rejects --tag + --retry-failed
160
+ // upstream in cli-handlers-fanout.js). Absent-not-null idiom, matching
161
+ // fanout.js:152's `...(options.pack ? {...} : {})`.
162
+ ...(plan.tag ? { tag: plan.tag } : {}),
163
+ };
152
164
  delete fanoutOpts.runFanout;
153
165
  const { wave, exitCode } = await runFanoutImpl(fanoutOpts);
154
166