amicus 4.4.0 → 4.5.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 (109) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +162 -0
  3. package/README.md +17 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/DISTRIBUTION.md +234 -0
  6. package/docs/ROADMAP.md +226 -0
  7. package/docs/SHIMS.md +62 -0
  8. package/docs/architecture.md +104 -0
  9. package/docs/configuration.md +395 -0
  10. package/docs/council.md +970 -0
  11. package/docs/doc-system.md +92 -0
  12. package/docs/electron-testing.md +471 -0
  13. package/docs/jsdoc-setup.md +75 -0
  14. package/docs/opencode-integration.md +114 -0
  15. package/docs/publishing.md +60 -0
  16. package/docs/schemas.md +56 -0
  17. package/docs/testing.md +589 -0
  18. package/docs/troubleshooting.md +298 -0
  19. package/docs/usage.md +849 -0
  20. package/electron/fold.js +1 -1
  21. package/electron/main.js +4 -1
  22. package/electron/setup-ui-aliases.js +6 -6
  23. package/electron/workspace-ui/live-model.js +12 -1
  24. package/electron/workspace-ui/md-lite.js +52 -8
  25. package/electron/workspace-ui/workspace-app.js +39 -17
  26. package/electron/workspace-ui/workspace-matrix.js +46 -9
  27. package/electron/workspace-ui/workspace-panels.js +88 -19
  28. package/electron/workspace-ui/workspace-render.js +17 -1
  29. package/electron/workspace-ui/workspace-verbs.js +48 -2
  30. package/package.json +8 -3
  31. package/schemas/council-run-live.schema.json +1 -1
  32. package/schemas/council-run.schema.json +34 -0
  33. package/schemas/error.schema.json +1 -1
  34. package/schemas/event.schema.json +1 -1
  35. package/schemas/pack.schema.json +30 -0
  36. package/schemas/progress.schema.json +13 -1
  37. package/schemas/run-live.schema.json +1 -1
  38. package/schemas/run.schema.json +2 -1
  39. package/schemas/spend.schema.json +52 -4
  40. package/schemas/wave-live.schema.json +1 -1
  41. package/schemas/wave.schema.json +2 -1
  42. package/skills/second-opinion/SKILL.md +5 -0
  43. package/src/cli-handlers-council-run.js +51 -8
  44. package/src/cli-handlers-pack.js +238 -0
  45. package/src/cli-handlers-run.js +36 -8
  46. package/src/cli-handlers-spend.js +20 -2
  47. package/src/cli-handlers-template.js +53 -0
  48. package/src/cli-handlers-watch.js +11 -0
  49. package/src/cli.js +68 -5
  50. package/src/council/briefings-debate.js +27 -7
  51. package/src/council/briefings-stage2.js +155 -25
  52. package/src/council/briefings.js +24 -1
  53. package/src/council/findings.js +199 -9
  54. package/src/council/parse-stage2.js +10 -2
  55. package/src/council/presets-cli.js +23 -11
  56. package/src/council/report.js +19 -8
  57. package/src/council/run-assemble.js +42 -1
  58. package/src/council/run-budget.js +64 -11
  59. package/src/council/run-chair.js +4 -1
  60. package/src/council/run-debate.js +4 -2
  61. package/src/council/run-finalize.js +102 -0
  62. package/src/council/run-launch.js +29 -1
  63. package/src/council/run-server.js +248 -0
  64. package/src/council/run-stage2.js +118 -0
  65. package/src/council/run-stages.js +134 -110
  66. package/src/council/run-state.js +40 -1
  67. package/src/council/run.js +45 -47
  68. package/src/council/tally.js +10 -0
  69. package/src/headless.js +180 -7
  70. package/src/mcp-council-run.js +108 -4
  71. package/src/mcp-server.js +203 -7
  72. package/src/mcp-tools.js +15 -5
  73. package/src/observe/council-legs.js +60 -3
  74. package/src/observe/live-doc.js +18 -1
  75. package/src/observe/watch-render.js +4 -1
  76. package/src/pack/pack-cli.js +38 -0
  77. package/src/pack/pack-forward.js +96 -0
  78. package/src/pack/pack-resolve.js +297 -0
  79. package/src/pack/pack-store.js +130 -0
  80. package/src/pack/pack-validate.js +113 -0
  81. package/src/sidecar/child-sessions.js +1 -2
  82. package/src/sidecar/fanout-leg-fallback.js +69 -21
  83. package/src/sidecar/fanout-leg.js +6 -0
  84. package/src/sidecar/fanout-signals.js +61 -0
  85. package/src/sidecar/fanout-wave-io.js +75 -0
  86. package/src/sidecar/fanout.js +82 -74
  87. package/src/sidecar/progress-fields.js +26 -4
  88. package/src/sidecar/progress.js +42 -1
  89. package/src/sidecar/session-utils.js +23 -14
  90. package/src/sidecar/start.js +5 -4
  91. package/src/sidecar/workspace-auto-open.js +69 -0
  92. package/src/sidecar/workspace-window.js +46 -1
  93. package/src/spend-query.js +17 -5
  94. package/src/template/apply.js +88 -0
  95. package/src/template/render.js +86 -0
  96. package/src/template/store.js +106 -0
  97. package/src/utils/config.js +65 -25
  98. package/src/utils/error-doc.js +5 -0
  99. package/src/utils/lifecycle.js +37 -1
  100. package/src/utils/path-fence.js +39 -1
  101. package/src/utils/pricing.js +26 -10
  102. package/src/utils/result-schema-rebuild.js +1 -0
  103. package/src/utils/result-schema.js +8 -2
  104. package/src/utils/server-setup.js +79 -1
  105. package/src/utils/spend-ledger.js +24 -3
  106. package/src/workspace/artifact-guard.js +66 -7
  107. package/src/workspace/fold-format.js +33 -4
  108. package/src/workspace/live-normalize.js +28 -15
  109. package/src/workspace/run-detail.js +13 -1
package/src/mcp-server.js CHANGED
@@ -225,6 +225,27 @@ function computeNextPoll() {
225
225
  const HEADLESS_START_REMINDER = '<system-reminder>Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true. Fallback (no amicus_wait tool available): before calling amicus_status, you MUST run `sleep 25` in your shell first. This enforces the polling interval and prevents token waste. Do other useful work while waiting, or run `sleep 25` to block until the next poll window.</system-reminder>';
226
226
  const HEADLESS_STATUS_REMINDER = '<system-reminder>Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true. Fallback (no amicus_wait tool available): this session is still running. Before calling amicus_status again, you MUST run `sleep 25` in your shell first. Each premature poll wastes context tokens for zero benefit. Run `sleep 25` now, then check again.</system-reminder>';
227
227
 
228
+ /**
229
+ * v4.5 Task 15 (B7/F5): map amicus_fanout / amicus_start's MCP input keys to
230
+ * the CLI arg-key names applyPackToArgs's knob tables use (pack-resolve.js),
231
+ * so applyPackToMcpInput can reuse those tables unchanged — see
232
+ * src/mcp-council-run.js's COUNCIL_PACK_PARAM_MAP for the sibling map and its
233
+ * fuller docblock. `includeContext` is the one inverted-polarity knob: the
234
+ * pack/CLI side is `no-context` (true = drop context), the MCP side is
235
+ * `includeContext` (true = keep context, default true).
236
+ */
237
+ const FANOUT_PACK_PARAM_MAP = {
238
+ models: 'models', council: 'council', gateway: 'gateway', agent: 'agent', thinking: 'thinking',
239
+ timeout: 'timeout', summaryLength: 'summary-length',
240
+ includeContext: { argKey: 'no-context', invert: true },
241
+ };
242
+ const SOLO_PACK_PARAM_MAP = {
243
+ model: 'model', gateway: 'gateway', agent: 'agent', noUi: 'no-ui', thinking: 'thinking',
244
+ timeout: 'timeout', contextTurns: 'context-turns', contextMaxTokens: 'context-max-tokens',
245
+ summaryLength: 'summary-length',
246
+ includeContext: { argKey: 'no-context', invert: true },
247
+ };
248
+
228
249
  /** Spawn an Amicus CLI process (fire-and-forget) */
229
250
  function spawnSidecarProcess(args, sessionDir) {
230
251
  const sidecarBin = path.join(__dirname, '..', 'bin', 'amicus.js');
@@ -250,9 +271,49 @@ function spawnSidecarProcess(args, sessionDir) {
250
271
  return child;
251
272
  }
252
273
 
274
+ /** v4.5 Wave-1 review fix (I1/I2): validates/dry-runs a pack-forwarded
275
+ * maxCost/template (see src/pack/pack-forward.js) — a no-op when the pack
276
+ * forwarded neither knob. Shared by amicus_fanout and amicus_start (both
277
+ * build their OWN error response below; the two tools use different shapes). */
278
+ function checkPackForward(packForward, packRecord, prompt, project) {
279
+ if (packForward.maxCost === undefined && packForward.template === undefined) {
280
+ return { notices: [] };
281
+ }
282
+ return require('./pack/pack-forward').prepareForward({
283
+ forward: packForward, packRef: packRecord.name, prompt, project,
284
+ });
285
+ }
286
+
253
287
  /** Tool handler implementations */
254
288
  const handlers = {
255
289
  async amicus_start(input, project, mcpServer) {
290
+ // v4.5 Task 15 (B7/F5): resolve `pack` IN-PROCESS before any validation —
291
+ // mirrors handleStart's own ordering (cli-handlers-run.js resolves --pack
292
+ // before validateStartArgs), so a pack-filled value gets the SAME
293
+ // validation pass a typed one does. Single-resolution rule: never spawn
294
+ // --pack — this is the only place the pack is resolved.
295
+ let packRecord = null;
296
+ const packNotices = [];
297
+ // v4.5 HOLD-gate decision 1: pack-filled maxCost/template have no MCP
298
+ // schema param of their own (see SOLO_PACK_PARAM_MAP above) but must still
299
+ // apply for CLI parity, on BOTH of this handler's paths (spawn-fallback
300
+ // below; the in-process shared-server branch further down) — a pack must
301
+ // not behave differently depending on which path fired.
302
+ let packForward = {};
303
+ if (input.pack !== undefined) {
304
+ const { applyPackToMcpInput } = require('./pack/pack-resolve');
305
+ const pr = applyPackToMcpInput({
306
+ packRef: input.pack, expectedKind: 'solo', input, paramMap: SOLO_PACK_PARAM_MAP,
307
+ });
308
+ if (pr.error) {
309
+ const { buildErrorDoc } = require('./utils/error-doc');
310
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(buildErrorDoc(pr.error)) }] };
311
+ }
312
+ packRecord = pr.packRecord;
313
+ packNotices.push(...pr.notices);
314
+ packForward = pr.forward;
315
+ }
316
+
256
317
  // Validate non-model inputs (prompt/timeout/agent) before any session creation.
257
318
  const { validateStartInputs } = require('./utils/input-validators');
258
319
  const validation = validateStartInputs(input);
@@ -307,6 +368,15 @@ const handlers = {
307
368
  const resolvedModel = routeResult.executableId;
308
369
 
309
370
  const cwd = project || getProjectDir(input.project);
371
+
372
+ // v4.5 Wave-1 review fix (I1/I2): validated ONCE, before EITHER downstream
373
+ // path does anything spend-adjacent — parity holds BY CONSTRUCTION.
374
+ const fwd = checkPackForward(packForward, packRecord, input.prompt, cwd);
375
+ if (fwd.error) {
376
+ const { buildErrorDoc } = require('./utils/error-doc');
377
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(buildErrorDoc(fwd.error)) }] };
378
+ }
379
+
310
380
  const { generateTaskId } = require('./sidecar/start');
311
381
  const taskId = generateTaskId();
312
382
 
@@ -325,8 +395,14 @@ const handlers = {
325
395
  // resolvedModel is always defined here — a routing failure already
326
396
  // returned above — and is the router's executableId, not the raw alias.
327
397
  args.push('--model', resolvedModel);
328
- const agent = (input.noUi && (!input.agent || input.agent.toLowerCase() === 'chat'))
329
- ? 'build' : input.agent;
398
+ // v4.5 Task 15 fix wave 2 (Finding 1): agent/noUi no longer carry a Zod
399
+ // .default() (see mcp-tools.js) — an omitted key is now genuinely absent
400
+ // here, so a pack can fill it. Preserve today's effective default ('Chat')
401
+ // at this READ site instead: the resolved value is identical whether the
402
+ // key was never set, or a caller/pack explicitly wrote 'Chat'.
403
+ const agentInput = input.agent || 'Chat';
404
+ const agent = (input.noUi && agentInput.toLowerCase() === 'chat')
405
+ ? 'build' : agentInput;
330
406
  if (agent) { args.push('--agent', agent); }
331
407
  if (input.noUi) { args.push('--no-ui'); }
332
408
  if (input.thinking) { args.push('--thinking', input.thinking); }
@@ -339,12 +415,50 @@ const handlers = {
339
415
  if (input.coworkProcess) { args.push('--cowork-process', input.coworkProcess); }
340
416
  if (input.parentSession) { args.push('--session-id', input.parentSession); }
341
417
  if (input.windowPosition) { args.push('--position', input.windowPosition); }
418
+ // v4.5 HOLD-gate decision 1: forward a pack-filled maxCost/template as
419
+ // plain CLI flags on the spawn-fallback path — neither has an MCP schema
420
+ // param of its own, but CLI parity requires them to apply anyway. Never
421
+ // pushed when the pack didn't fill them. The in-process shared-server
422
+ // branch below applies the SAME two knobs via its own mechanism (budget
423
+ // gate / template pre-render) for parity between the two paths.
424
+ if (packForward.maxCost !== undefined) { args.push('--max-cost', String(packForward.maxCost)); }
425
+ if (packForward.template !== undefined) { args.push('--template', packForward.template); }
342
426
  args.push('--cwd', cwd);
343
427
 
344
428
  if (sharedServer.enabled && input.noUi) {
345
429
  // Shared server path: headless only, delegates to runHeadless()
346
430
  let sessionId;
347
431
  try {
432
+ // v4.5 decision 1 + Wave-1 fix (I1/I2): the template was already
433
+ // rendered by the shared prepareForward call above — reuse its text
434
+ // instead of rendering again. Notices stay LOCAL (not packNotices):
435
+ // a fall-through to the spawn-fallback catch must never leak one.
436
+ const inProcessNotices = [...fwd.notices];
437
+ 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
+ }
460
+ }
461
+
348
462
  const { server, client } = await sharedServer.ensureServer();
349
463
  const { createSession } = require('./opencode-client');
350
464
  const { buildContext } = require('./sidecar/context-builder');
@@ -380,7 +494,12 @@ const handlers = {
380
494
  // so without them status/list/read show a briefing-less, mode-less run.
381
495
  mode: 'headless',
382
496
  agent: agent || 'build',
383
- briefing: input.prompt,
497
+ // v4.5 HOLD-gate decision 1: the RENDERED prompt (byte-identical to
498
+ // input.prompt when no pack template applied) — parity with the CLI,
499
+ // whose briefing.md on disk is always the rendered text (spec §4).
500
+ briefing: renderedPrompt,
501
+ // v4.5 Task 15: additive-only — absent (not null) without a pack.
502
+ ...(packRecord ? { pack: packRecord } : {}),
384
503
  }, null, 2), { mode: 0o600 });
385
504
 
386
505
  // Build context from parent conversation (unless --no-context)
@@ -402,9 +521,13 @@ const handlers = {
402
521
  // 15b.3: one nonce per run, generated before prompt construction.
403
522
  const foldNonce = generateFoldNonce();
404
523
 
405
- // Build prompts (same as CLI path in start.js)
524
+ // Build prompts (same as CLI path in start.js). renderedPrompt is the
525
+ // pack-template-rendered text when packForward.template was set above
526
+ // (byte-identical to input.prompt otherwise) — this is the ONE place
527
+ // the built prompt reaches the model, so rendering has no effect
528
+ // unless it is threaded in here.
406
529
  const { system: systemPrompt, userMessage } = buildPrompts(
407
- input.prompt, context, cwd, true, agent, input.summaryLength, undefined, foldNonce
530
+ renderedPrompt, context, cwd, true, agent, input.summaryLength, undefined, foldNonce
408
531
  );
409
532
 
410
533
  // Register session with idle eviction
@@ -505,6 +628,14 @@ const handlers = {
505
628
  // shared-server path (no CLI stderr exists for an MCP caller).
506
629
  const sharedServerContent = [{ type: 'text', text: body }];
507
630
  if (routeResult.notice) { sharedServerContent.push({ type: 'text', text: routeResult.notice }); }
631
+ // v4.5 Task 15: pack notices (e.g. a bench-override) are non-fatal —
632
+ // surfaced as extra content blocks, same precedent as routeResult.notice above.
633
+ for (const n of packNotices) { sharedServerContent.push({ type: 'text', text: n }); }
634
+ // v4.5 HOLD-gate decision 1: template-render notices (e.g. an unused
635
+ // --var-equivalent), merged only now that the in-process path has
636
+ // actually succeeded — see the try block's opening comment for why
637
+ // these are not in the shared packNotices array.
638
+ for (const n of inProcessNotices) { sharedServerContent.push({ type: 'text', text: n }); }
508
639
  sharedServerContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
509
640
  return { content: sharedServerContent };
510
641
  } catch (err) {
@@ -542,6 +673,8 @@ const handlers = {
542
673
  // CLI child's createSessionMetadata overwrite (or if it crashes first).
543
674
  mode: input.noUi ? 'headless' : 'interactive',
544
675
  briefing: input.prompt,
676
+ // v4.5 Task 15: additive-only — absent (not null) without a pack.
677
+ ...(packRecord ? { pack: packRecord } : {}),
545
678
  }, null, 2), { mode: 0o600 });
546
679
  }
547
680
  }
@@ -560,6 +693,7 @@ const handlers = {
560
693
  // resolved the route in-process above, so surface its notice here.
561
694
  const spawnContent = [{ type: 'text', text: body }];
562
695
  if (routeResult.notice) { spawnContent.push({ type: 'text', text: routeResult.notice }); }
696
+ for (const n of packNotices) { spawnContent.push({ type: 'text', text: n }); }
563
697
  if (isHeadless) {
564
698
  spawnContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
565
699
  return { content: spawnContent };
@@ -1068,6 +1202,41 @@ const handlers = {
1068
1202
  const { generateTaskId } = require('./sidecar/start');
1069
1203
  const { deriveLegIds, DEFAULT_MAX_LEGS } = require('./sidecar/fanout');
1070
1204
 
1205
+ // v4.5 Task 15 (B7/F5): resolve `pack` IN-PROCESS before the models/council
1206
+ // validation below, same single-resolution rule as amicus_start/
1207
+ // amicus_council_run — never spawn --pack; this is the only place the
1208
+ // pack is resolved.
1209
+ let packRecord = null;
1210
+ const packNotices = [];
1211
+ // v4.5 HOLD-gate decision 1: pack-filled maxCost/template have no MCP
1212
+ // schema param of their own (see FANOUT_PACK_PARAM_MAP above) but must
1213
+ // still apply for CLI parity — applyPackToMcpInput hands them back here
1214
+ // instead of turning them into an ignore-notice; this handler forwards
1215
+ // them to the spawned CLI child's argv below.
1216
+ let packForward = {};
1217
+ if (input.pack !== undefined) {
1218
+ const { applyPackToMcpInput } = require('./pack/pack-resolve');
1219
+ const pr = applyPackToMcpInput({
1220
+ packRef: input.pack, expectedKind: 'fanout', input, paramMap: FANOUT_PACK_PARAM_MAP,
1221
+ });
1222
+ // v4.5 final-review T15-m1: amicus_start's own pack-error branch above
1223
+ // keeps code+hint via buildErrorDoc's JSON envelope; amicus_fanout's
1224
+ // error surface is plain text, so the hint (e.g. PACK_NOT_FOUND's
1225
+ // 'amicus pack list') is appended to the message instead of being
1226
+ // converted into a JSON envelope, which would change this tool's
1227
+ // established response shape.
1228
+ if (pr.error) { return textResult(pr.error.message + (pr.error.hint ? `\n${pr.error.hint}` : ''), true); }
1229
+ packRecord = pr.packRecord;
1230
+ packNotices.push(...pr.notices);
1231
+ packForward = pr.forward;
1232
+ }
1233
+ // v4.5 Wave-1 review fix (I1/I2): validated HERE, before any wave dir /
1234
+ // metadata write — never blind. Same posture as the pack-error branch
1235
+ // above (plain text + appended hint; this handler is born-fenced, not JSON).
1236
+ const fwd = checkPackForward(packForward, packRecord, input.prompt, cwd);
1237
+ if (fwd.error) { return textResult(fwd.error.message + (fwd.error.hint ? `\n${fwd.error.hint}` : ''), true); }
1238
+ packNotices.push(...fwd.notices);
1239
+
1071
1240
  // Resolve a single effective models list (council OR models), validated
1072
1241
  // BEFORE any wave dir / metadata is written so a bad request never strands
1073
1242
  // a pid-less 'running' orphan wave.
@@ -1110,6 +1279,8 @@ const handlers = {
1110
1279
  writeFileAtomic(path.join(waveDir, 'metadata.json'), JSON.stringify({
1111
1280
  taskId: waveId, type: 'wave', status: 'running', legs: legIds,
1112
1281
  models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
1282
+ // v4.5 Task 15: additive-only — absent (not null) without a pack.
1283
+ ...(packRecord ? { pack: packRecord } : {}),
1113
1284
  }, null, 2), { mode: 0o600 });
1114
1285
  // #40: index the wave AND each leg so status/read of any leg resolves the
1115
1286
  // project even when the default later defaults to a different one.
@@ -1138,8 +1309,17 @@ const handlers = {
1138
1309
  // so context-inheriting fanout launched from Cowork resolves the right parent.
1139
1310
  if (input.coworkProcess) { args.push('--cowork-process', input.coworkProcess); }
1140
1311
  if (input.parentSession) { args.push('--session-id', input.parentSession); }
1312
+ // v4.5 HOLD-gate decision 1: forward a pack-filled maxCost/template as
1313
+ // plain CLI flags — neither has an MCP schema param of its own, but CLI
1314
+ // parity requires them to apply anyway (a shared pack's spend cap and
1315
+ // briefing template must not silently vanish over MCP). Never pushed when
1316
+ // the pack didn't fill them; single-resolution rule unaffected (--pack
1317
+ // itself is never forwarded, only the two knobs it resolved to).
1318
+ if (packForward.maxCost !== undefined) { args.push('--max-cost', String(packForward.maxCost)); }
1319
+ if (packForward.template !== undefined) { args.push('--template', packForward.template); }
1141
1320
 
1142
- try { spawnSidecarProcess(args, waveDir); } catch (err) {
1321
+ let child;
1322
+ try { child = spawnSidecarProcess(args, waveDir); } catch (err) {
1143
1323
  // Best-effort: never leave a pid-less wave record claiming 'running'
1144
1324
  // forever (crash detection only probes records WITH a pid).
1145
1325
  try {
@@ -1149,6 +1329,16 @@ const handlers = {
1149
1329
  } catch { /* best-effort */ }
1150
1330
  return textResult(`Failed to start fan-out: ${err.message}`, true);
1151
1331
  }
1332
+
1333
+ // v4.5 Wave-1 fix (I4): merge the child pid into the pre-seeded wave metadata
1334
+ // so crash detection can probe pid-bearing records (absent pid = unreapable phantom).
1335
+ if (child && child.pid) {
1336
+ try {
1337
+ const m = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8'));
1338
+ Object.assign(m, { pid: child.pid });
1339
+ writeFileAtomic(path.join(waveDir, 'metadata.json'), JSON.stringify(m, null, 2), { mode: 0o600 });
1340
+ } catch { /* best-effort: metadata already has no pid */ }
1341
+ }
1152
1342
  // Task 15 (spec §5.3): the run is now known-launched under waveId — mark
1153
1343
  // it for a best-effort terminal notify. runWait's poll loop (mcp-wait.js)
1154
1344
  // is the only code that later sees this wave reach terminal state.
@@ -1160,7 +1350,13 @@ const handlers = {
1160
1350
  'replaces polling; re-call it while it returns timedOut: true. Fallback: poll amicus_status ' +
1161
1351
  'with the waveId. Either way, amicus_read the waveId when complete.',
1162
1352
  }));
1163
- return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
1353
+ const waveContent = [{ type: 'text', text: body }];
1354
+ // v4.5 Task 15: pack notices (e.g. a bench-override) are non-fatal —
1355
+ // surfaced as extra content blocks, same precedent as amicus_start's
1356
+ // routeResult.notice handling above.
1357
+ for (const n of packNotices) { waveContent.push({ type: 'text', text: n }); }
1358
+ waveContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
1359
+ return { content: waveContent };
1164
1360
  },
1165
1361
 
1166
1362
  async amicus_council_tally(input) {
package/src/mcp-tools.js CHANGED
@@ -71,12 +71,12 @@ function getTools() {
71
71
  'files of interest, success criteria.'
72
72
  ),
73
73
  agent: z.enum(['Chat', 'Plan', 'Build']).optional()
74
- .default('Chat').describe(
74
+ .describe(
75
75
  'Agent mode. Chat (interactive default; headless runs auto-convert ' +
76
76
  'to Build): reads auto, writes ask permission. Plan: read-only ' +
77
77
  'analysis. Build: full auto (all operations approved).'
78
78
  ),
79
- noUi: z.boolean().optional().default(false).describe(
79
+ noUi: z.boolean().optional().describe(
80
80
  'Run headless without GUI. Default false (opens Electron window).'
81
81
  ),
82
82
  thinking: z.enum([
@@ -101,7 +101,7 @@ function getTools() {
101
101
  'Fold summary verbosity. brief: key findings only. normal (default): full ' +
102
102
  'structured output. verbose: maximum detail.'
103
103
  ),
104
- includeContext: z.boolean().optional().default(true).describe(
104
+ includeContext: z.boolean().optional().describe(
105
105
  'Whether to include parent conversation history as context. '
106
106
  + 'Default: true. Set to false when the briefing is self-contained '
107
107
  + 'and does not depend on prior conversation. See amicus_guide for guidance.'
@@ -120,6 +120,7 @@ function getTools() {
120
120
  'right (default): flush against the right edge. ' +
121
121
  'left: flush against the left edge. center: centered.'
122
122
  ),
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).'),
123
124
  project: z.string().optional().describe(
124
125
  'Optional project directory path. Auto-detected from working directory if omitted.'
125
126
  ),
@@ -350,8 +351,9 @@ function getTools() {
350
351
  summaryLength: z.enum(['brief', 'normal', 'verbose']).optional().describe(
351
352
  'Summary verbosity for every leg.'
352
353
  ),
353
- includeContext: z.boolean().optional().default(true).describe(
354
- 'Include parent conversation context (built once, shared by all legs). Set false for self-contained briefings.'
354
+ includeContext: z.boolean().optional().describe(
355
+ 'Include parent conversation context (built once, shared by all legs). ' +
356
+ 'Default: true. Set false for self-contained briefings.'
355
357
  ),
356
358
  coworkProcess: z.string().optional().describe(
357
359
  'Cowork VM process name (e.g., "modest-laughing-goodall"). ' +
@@ -366,6 +368,7 @@ function getTools() {
366
368
  'finishes (best-effort; amicus_wait remains the reliable completion mechanism). Exec ' +
367
369
  'commands are NOT accepted over MCP.'
368
370
  ),
371
+ 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).'),
369
372
  project: z.string().optional().describe(
370
373
  'Optional project directory path. Auto-detected from working directory if omitted.'
371
374
  ),
@@ -497,6 +500,13 @@ function getTools() {
497
500
  'finishes (best-effort; amicus_wait remains the reliable completion mechanism). Exec ' +
498
501
  'commands are NOT accepted over MCP.'
499
502
  ),
503
+ 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).'),
504
+ ui: z.boolean().optional().describe(
505
+ 'Auto-open the Council Workspace window on this run. Default: opens when the client is ' +
506
+ 'Claude Code (local), Electron is installed, a display exists, and config workspace.autoOpen is ' +
507
+ 'not false. false: never open. true: open even for other clients (still requires Electron + ' +
508
+ 'a display; never installs).'
509
+ ),
500
510
  project: z.string().optional().describe(
501
511
  'Optional project directory path. Auto-detected from working directory if omitted.'
502
512
  ),
@@ -34,6 +34,7 @@ const path = require('path');
34
34
  const { readProgress, isStalled } = require('../sidecar/progress');
35
35
  const { enrichLegUsage, TERMINAL } = require('./live-doc');
36
36
  const { roleFor } = require('../council/run-stages');
37
+ const { logger } = require('../utils/logger');
37
38
 
38
39
  /**
39
40
  * A leg's council role. The chair stage is the one case alias identity
@@ -54,6 +55,37 @@ const { roleFor } = require('../council/run-stages');
54
55
  function legRole({ bench, critic, lenses, stageName, modelInput }) {
55
56
  if (!modelInput) { return null; }
56
57
  if (stageName === 'chair') { return 'chair'; }
58
+ // ⚠️ v4.4.1 LC-4: roleFor's LENS branch does `o.models.indexOf(alias)`, so a
59
+ // run.json carrying truthy `lenses` with a missing or non-array `bench` throws
60
+ // a TypeError. This function runs on every status poll, so one malformed
61
+ // run.json took out three surfaces at once: `amicus status`, the amicus_status
62
+ // MCP tool, and `amicus watch`. It was unreachable only because
63
+ // src/council/run.js:72 happens to write `bench` and `lenses` together — an
64
+ // argument that rests entirely on one writer never changing.
65
+ //
66
+ // A role we cannot compute is `null` — the module's existing, documented
67
+ // degradation (an em-dash in the Role column), never a guess and never a
68
+ // throw. The non-lens branch never touches `models`, so it stays exact.
69
+ //
70
+ // ⚠️ v4.4.1 A2: the guard above was ASYMMETRIC — it validated `bench` and took `lenses` on
71
+ // trust, but roleFor's lens branch indexes BOTH (`o.lenses[o.models.indexOf(alias)]`). Neither
72
+ // remaining shape throws, because slug() coerces with String(), so both produced a confident
73
+ // LIE instead of a crash: `lenses: 'security'` with `bench: ['gpt']` indexes the STRING and
74
+ // yields `lens:s`, and a `lenses` array shorter than `bench` yields `lens:undefined`. The
75
+ // length pairing is a real, enforced invariant, not an assumption — cli-handlers-council-run.js:170
76
+ // refuses `--lenses` unless it has exactly one lens per seat — so a run.json that violates it is
77
+ // malformed, and the honest answer for a malformed pairing is the same `null` (an em-dash in the
78
+ // Role column) that LC-4 established, never a guess.
79
+ if (lenses && (!Array.isArray(bench) || !Array.isArray(lenses) || lenses.length !== bench.length)) {
80
+ logger.debug('leg role unresolved: run.json lenses/bench are not a matched pair', {
81
+ modelInput, stageName,
82
+ benchType: bench === null ? 'null' : typeof bench,
83
+ lensesType: lenses === null ? 'null' : typeof lenses,
84
+ benchLength: Array.isArray(bench) ? bench.length : null,
85
+ lensesLength: Array.isArray(lenses) ? lenses.length : null,
86
+ });
87
+ return null;
88
+ }
57
89
  return roleFor({ models: bench, critic, lenses }, modelInput);
58
90
  }
59
91
 
@@ -71,15 +103,30 @@ function buildLegRow(project, legId, runCtx) {
71
103
  const { getSessionDir } = require('../session-manager');
72
104
  const legDir = getSessionDir(project, legId);
73
105
  let meta = {};
106
+ // ⚠️ v4.4.1 LC-9: the catch stays ALL-OR-NOTHING on purpose (Appendix A-9) —
107
+ // it mirrors the wave branch, and a half-parsed metadata object is worse than
108
+ // an empty one. What was wrong was the SILENCE: a corrupt metadata.json, an
109
+ // EACCES on the leg dir, and "the file doesn't exist yet" were indistinguishable
110
+ // and left no trace anywhere, so a leg with corrupt metadata rendered as a
111
+ // just-started leg forever. `code` is what separates them — ENOENT is the
112
+ // ordinary just-started case, anything else is a real fault. Logging only; the
113
+ // branching is untouched.
74
114
  try { meta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); }
75
- catch { /* leg metadata not written yet — just-started leg */ }
115
+ catch (metaErr) {
116
+ logger.debug('leg metadata.json unreadable — rendering the leg with base fields only', {
117
+ legId, code: metaErr.code || null, error: metaErr.message,
118
+ });
119
+ }
76
120
  // Truthful null, never metadata.model as a fallback: showing the resolved
77
121
  // id where the alias was expected is exactly the F36 bug (blind mode would
78
122
  // leak the real model id instead of degrading to an em-dash).
79
123
  const modelInput = meta.modelInput || null;
124
+ // LC-4: hoisted out of the object literal below so the guard inside legRole is
125
+ // the only thing standing between a malformed run.json and three live surfaces.
126
+ const role = legRole({ ...runCtx, modelInput });
80
127
  const row = {
81
128
  taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
82
- modelInput, role: legRole({ ...runCtx, modelInput }),
129
+ modelInput, role,
83
130
  };
84
131
  let stalledMs = null;
85
132
  let p = null;
@@ -91,7 +138,17 @@ function buildLegRow(project, legId, runCtx) {
91
138
  row.lastActivityAt = p.lastActivityAt;
92
139
  row.stalled = row.status === 'running' && isStalled(p.lastActivityMs);
93
140
  if (row.stalled) { stalledMs = p.lastActivityMs; }
94
- } catch { /* no progress.json yet — a just-started leg; base fields only. */ }
141
+ } catch (progressErr) {
142
+ // ⚠️ v4.4.1 LC-9: same deal as the metadata catch above — all-or-nothing by
143
+ // design (A-9), silent by accident. readProgress swallows a malformed
144
+ // progress.json itself, so reaching here means the leg DIR could not be
145
+ // stat'd/read at all (EACCES, a vanished session dir) or readProgress threw
146
+ // on a shape it could not handle — neither of which is "hasn't started yet",
147
+ // and both of which previously left the row indistinguishable from one.
148
+ logger.debug('leg progress unreadable — rendering the leg with base fields only', {
149
+ legId, code: progressErr.code || null, error: progressErr.message,
150
+ });
151
+ }
95
152
 
96
153
  // council review C3: this is a SEPARATE try from readProgress's above, on
97
154
  // purpose. The old code wrapped both in one try, so a pricing-resolution
@@ -15,7 +15,24 @@
15
15
 
16
16
  const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
17
17
 
18
- const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout']);
18
+ // ⚠️ v4.4.1 A1: 'timeout' AND 'timed-out' the codebase genuinely has two spellings for one
19
+ // state, written by two different producers, and this set has to cover both.
20
+ // 'timeout' — src/utils/result-schema.js:23 statusFromResult, i.e. the LEG/wave-document and
21
+ // `--json` run-document vocabulary. Correct, still emitted, stays.
22
+ // 'timed-out' — src/sidecar/session-finalize.js:21 resolveTerminalState, i.e. what actually
23
+ // lands in a session's metadata.json `status` and (since LC-3) progress.json's
24
+ // terminal stage. It was MISSING here, and this set is the one every observability
25
+ // reader consults, so three real consequences followed: `amicus watch <taskId>` on
26
+ // a timed-out single session never exited (watch-render.js:138 polls until
27
+ // TERMINAL.has(doc.status), and amicus_status stamps metadata.status straight onto
28
+ // the doc — mcp-server.js:687); a timed-out leg skipped the "prefer metadata.usage
29
+ // over the stale progress.json snapshot" branch in council-legs.js:162 and reported
30
+ // an under-counted cost; and markLive kept stamping view:'live' on a finished
31
+ // single-session doc.
32
+ // NOTE this is deliberately NOT the same list as src/utils/result-schema.js:13 TERMINAL_STATUSES
33
+ // (the leg set, no 'partial'). Two mirrors of THIS list exist — src/workspace/run-detail.js:26 and
34
+ // electron/workspace-ui/live-model.js:14 — byte-identical, held by drift pins. Edit all three.
35
+ const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'timed-out', 'idle-timeout']);
19
36
 
20
37
  /**
21
38
  * Attach read-time-resolved usage to a leg from its raw progress usage.
@@ -29,7 +29,10 @@ const legCost = (leg) => (leg.usage && leg.usage.cost ? formatCost(leg.usage.cos
29
29
  const legTokens = (leg) => (leg.usage && leg.usage.tokens ? `${leg.usage.tokens.input || 0}/${leg.usage.tokens.output || 0}` : DASH);
30
30
  const truncate = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
31
31
 
32
- const STAGE_MARK = { complete: '✓', running: '▶', pending: '·' };
32
+ // `partial` (v4.4.1 F6): a stage that FINISHED but lost seats. Without its own
33
+ // mark it fell through to `pending`, so a degraded Stage 1 rendered as if it had
34
+ // not started — the opposite of reporting loudly.
35
+ const STAGE_MARK = { complete: '✓', running: '▶', partial: '⚠', pending: '·' };
33
36
 
34
37
  /** The in-place refresh block for a composed wave/council/solo doc. */
35
38
  function renderTable(doc, width = 100) {
@@ -0,0 +1,38 @@
1
+ // src/pack/pack-cli.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module pack/pack-cli
6
+ * v4.5 Task 13: shared --pack CLI wiring for handleFanout and handleStart
7
+ * (src/cli-handlers-run.js). Task 12 wired handleCouncilRun's pack block
8
+ * inline; fanout/start need the identical resolve-or-exit shape at TWO call
9
+ * sites in the SAME file, which would push cli-handlers-run.js over the
10
+ * 300-line size gate — extracted here instead. Deliberately excludes
11
+ * council's packSuffix helper: neither fanout nor start has a
12
+ * chair/critic/lenses pre-flight to attribute a failure to (Task-12 reviewer
13
+ * confirmed no analog is needed here).
14
+ */
15
+
16
+ const { applyPackToArgs } = require('./pack-resolve');
17
+ const { failJson } = require('../utils/error-doc');
18
+
19
+ /**
20
+ * Resolve `args.pack` (when present) into `args`, in place, using
21
+ * cli-handlers-run.js's own exit-on-error style (`process.exit(failJson(...))`)
22
+ * rather than handleCouncilRun's `return failJson(...)`. A no-op — returns
23
+ * null without touching `args` or exiting — when --pack was not passed.
24
+ * @param {object} args - parsed CLI args; explicit-flag keys read from args.__explicit
25
+ * @param {'fanout'|'solo'} expectedKind
26
+ * @param {boolean} useJson
27
+ * @returns {{name: string, version: string, hash: string, source: string}|null}
28
+ */
29
+ function applyPackOrExit(args, expectedKind, useJson) {
30
+ if (args.pack === undefined) { return null; }
31
+ const explicit = args.__explicit || new Set();
32
+ const pr = applyPackToArgs({ packRef: args.pack, expectedKind, args, explicit, useJson });
33
+ if (pr.error) { process.exit(failJson(useJson, pr.error)); }
34
+ for (const n of pr.notices) { process.stderr.write(n + '\n'); }
35
+ return pr.packRecord;
36
+ }
37
+
38
+ module.exports = { applyPackOrExit };