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/headless.js CHANGED
@@ -11,7 +11,7 @@ const { logger } = require('./utils/logger');
11
11
  const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
12
12
  const { ensurePortAvailable } = require('./utils/server-setup');
13
13
  const { mapAgentToOpenCode } = require('./utils/agent-mapping');
14
- const { writeProgress } = require('./sidecar/progress');
14
+ const { writeProgress, writeTerminalProgressSafe } = require('./sidecar/progress');
15
15
  const { writeFileAtomic } = require('./utils/atomic-write');
16
16
  const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls,
17
17
  getLiveToolCalls, mirrorUsageOnly, allAssistantUsagePresent } = require('./sidecar/conversation-mirror');
@@ -122,8 +122,22 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
122
122
  * ON EXCEEDING IT the leg COMPLETES anyway — never fails — carrying
123
123
  * `toolSettleTimedOut` on the result, the terminal progress record and the
124
124
  * error log channel. Owner's standing ruling: fail LOUD, not fail CLOSED.
125
+ *
126
+ * v4.4.1 LC-2 (owner ruling, 2026-07-26): the leg's completion and its partial
127
+ * output are unchanged, but its OpenCode session is now ABORTED at the ceiling
128
+ * (see the finalization block) so it stops billing for work nobody will read.
125
129
  */
126
130
  const TOOL_SETTLE_GRACE_MS = envNumber('AMICUS_TOOL_SETTLE_GRACE_MS', 300000);
131
+ /**
132
+ * v4.4.1 LC-2 — how long the ceiling's abort call may take before we stop
133
+ * waiting on it. A hard constant rather than an env knob (the same disposition
134
+ * as src/sidecar/child-sessions.js's bounds): it exists to stop a pathological
135
+ * hang, not to be tuned. The leg is already complete and already paid for when
136
+ * this runs, so an unbounded wait here would hold a finished answer hostage to a
137
+ * best-effort cost optimization — exactly the trade A-8 forbids. Injectable via
138
+ * `options.toolSettleAbortTimeoutMs` so the bound itself is testable.
139
+ */
140
+ const TOOL_SETTLE_ABORT_TIMEOUT_MS = 5000;
127
141
 
128
142
  /**
129
143
  * Race a promise against a timeout. Returns the promise's result, or rejects with
@@ -258,12 +272,28 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
258
272
  if (options.mcp) {
259
273
  serverOptions.mcp = options.mcp;
260
274
  }
261
- const result = await startServer(serverOptions);
275
+ // v4.4.1 fix wave (F5): this is the OTHER server-start site. It calls
276
+ // startServer directly rather than going through startOpenCodeServer, so
277
+ // the lock-class retry added for the concurrent-start race never covered
278
+ // it — a plain `amicus start` that lost the race still died on the first
279
+ // `database is locked`. "Two separate amicus processes contending" is half
280
+ // that retry's stated justification, and this is one of the two processes.
281
+ // Same bounded, narrow policy: 5 attempts (Step 10.5 widened it from 3),
282
+ // lock-class messages only, final failure rethrown unchanged into the
283
+ // degrade path below.
284
+ const { retryOnLockRace } = require('./utils/server-setup');
285
+ const result = await retryOnLockRace(() => startServer(serverOptions),
286
+ { retryDelayMs: options.retryDelayMs });
262
287
  client = result.client;
263
288
  server = result.server;
264
289
  logger.debug('Server started', { url: server.url });
265
290
  } catch (error) {
266
291
  logger.error('Failed to start OpenCode server', { error: error.message });
292
+ // FR-1: this return predates the outer try — A3's terminal write never ran
293
+ // for it, leaving 'initializing' on disk while metadata said 'error'.
294
+ if (!writeTerminalProgressSafe(sessionDir, `Failed to start server: ${error.message}`)) {
295
+ logger.debug('terminal progress write failed on server-start failure (best-effort)', { taskId });
296
+ }
267
297
  return {
268
298
  summary: '',
269
299
  completed: false,
@@ -289,6 +319,10 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
289
319
 
290
320
  if (!serverReady) {
291
321
  await server.close();
322
+ // FR-1: returns out of the outer try — the A3 catch never sees it.
323
+ if (!writeTerminalProgressSafe(sessionDir, 'OpenCode server failed to start')) {
324
+ logger.debug('terminal progress write failed on server-not-ready (best-effort)', { taskId });
325
+ }
292
326
  return {
293
327
  summary: '',
294
328
  completed: false,
@@ -326,6 +360,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
326
360
  } catch (error) {
327
361
  if (watchdog) { watchdog.cancel(); }
328
362
  if (!externalServer) { await server.close(); }
363
+ // FR-1: the one path a council leg can hit under T0.5's shared server —
364
+ // pre-fix, the Workspace showed the seat perpetually live off a
365
+ // non-terminal 'server_ready' while metadata.json said 'error'.
366
+ if (!writeTerminalProgressSafe(sessionDir, error.message)) {
367
+ logger.debug('terminal progress write failed on createSession failure (best-effort)', { taskId });
368
+ }
329
369
  return {
330
370
  summary: '',
331
371
  completed: false,
@@ -457,6 +497,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
457
497
  // `=== undefined` rather than `||`: 0 is meaningful (disable the deferral).
458
498
  const toolSettleGraceMs = options.toolSettleGraceMs === undefined
459
499
  ? TOOL_SETTLE_GRACE_MS : options.toolSettleGraceMs;
500
+ const toolSettleAbortTimeoutMs = options.toolSettleAbortTimeoutMs === undefined
501
+ ? TOOL_SETTLE_ABORT_TIMEOUT_MS : options.toolSettleAbortTimeoutMs;
460
502
  let consecutivePollFailures = 0;
461
503
  let pollFailureBail = false;
462
504
  let lastAssistantMsgId = null;
@@ -478,6 +520,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
478
520
  let toolSettleDeferredSince = null; // ms timestamp of the first deferral, or null
479
521
  let toolSettleTimedOut = false; // the grace ceiling was exceeded
480
522
  let unsettledAtCeiling = []; // what was still live when it was exceeded
523
+ let toolSettleAborted = false; // LC-2: the ceiling's abort landed (see finalization)
481
524
 
482
525
  /**
483
526
  * Should this poll's completion signal be DEFERRED because a tool call has
@@ -917,6 +960,54 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
917
960
  }
918
961
  }
919
962
 
963
+ // ---- v4.4.1 LC-2: stop paying for a session nobody will read -------------
964
+ // OWNER RULING (2026-07-26). The leg is complete and runHeadless is returning,
965
+ // so nothing will ever read further session output — the outcome was already
966
+ // discarded by completing. Aborting here does not truncate an answer that
967
+ // would have been used; it stops paying for work nobody will read. The
968
+ // original objection ("stops the bleeding at the cost of truncating a
969
+ // possibly-healthy call") applied to aborting ON the completion route, where
970
+ // the call might still have mattered. Here it cannot.
971
+ //
972
+ // ORDER IS LOAD-BEARING, on BOTH sides:
973
+ // AFTER the child-session walk above — aborting first risks losing the
974
+ // subtree cost data v4.4.0 exists to capture, trading one silent
975
+ // under-report for another.
976
+ // BEFORE server.close() below — the abort is an SDK call and needs a live
977
+ // server. It is not redundant with close(): on a SHARED server (every
978
+ // council run) close() is never called here, and the session would go on
979
+ // billing against a server that outlives this leg.
980
+ //
981
+ // A-8 APPLIES: "never lose the answer" outranks "never report inaccurate
982
+ // usage". This is an optimization layered on an already-successful,
983
+ // already-paid-for leg, so every failure — rejection, hang, or a missing
984
+ // session id — is logged and dropped. Nothing here may alter `completed`,
985
+ // `summary`, `usage` or `error`. `toolSettleAborted: false` is the honest
986
+ // record of "we tried and could not; it may still be billing".
987
+ if (toolSettleTimedOut && sessionId) {
988
+ try {
989
+ const { abortSession } = require('./opencode-client');
990
+ await withTimeout(
991
+ abortSession(client, sessionId, ...dirArgs),
992
+ toolSettleAbortTimeoutMs,
993
+ 'abortSession(tool-settle)',
994
+ );
995
+ toolSettleAborted = true;
996
+ // Task 6 review X2: a LANDED abort is the good outcome of a condition
997
+ // that is already logged at `error` (the ceiling itself). Logging the
998
+ // remedy at `warn` reads as a second problem; `info` reads honestly.
999
+ // The FAILED abort below stays at `warn` — that one really is a problem
1000
+ // ("it may still be billing").
1001
+ logger.info('Aborted the OpenCode session after the tool-settle ceiling', {
1002
+ taskId, sessionId, unsettled: unsettledAtCeiling.length,
1003
+ });
1004
+ } catch (abortErr) {
1005
+ toolSettleAborted = false;
1006
+ logger.warn('Could not abort the session after the tool-settle ceiling — it may '
1007
+ + 'still be billing', { taskId, sessionId, error: abortErr.message });
1008
+ }
1009
+ }
1010
+
920
1011
  if (!externalServer) { await server.close(); }
921
1012
  if (mirror.toolCalls.length > 0) {
922
1013
  logger.info('Tool calls summary', {
@@ -958,8 +1049,13 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
958
1049
  // live GUI reads this file (src/observe/live-doc.js). `unsettledToolCalls` is
959
1050
  // a COUNT here (progress.json is a compact snapshot); the full list is on the
960
1051
  // returned result for the caller's metadata.
1052
+ //
1053
+ // v4.4.1 LC-2: `toolSettleAborted` rides alongside it — `true` = the session
1054
+ // was told to stop, `false` = it may still be billing. Both are meaningful
1055
+ // ONLY when the ceiling was hit, so neither appears on a clean leg.
961
1056
  const settleFlags = toolSettleTimedOut
962
- ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length }
1057
+ ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length,
1058
+ toolSettleAborted }
963
1059
  : {};
964
1060
  // v4.4 B4 (Task 2) + v4.4.1 CA-1: a leg that made a SUBAGENT call has spend
965
1061
  // in a CHILD OpenCode session that is billed separately and is NOT rolled
@@ -981,15 +1077,46 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
981
1077
  : {}),
982
1078
  ...(subtree.unknown ? { subtreeUnknown: true } : {}) }
983
1079
  : (subagentToolCalls.length > 0 ? { subtreeUnknown: true } : {});
984
- try { writeProgress(sessionDir, 'complete', { usage: { ...usage, ...subtreeProgress }, ...settleFlags }); }
1080
+ // ---- v4.4.1 LC-3: the terminal stage is DERIVED, never hardcoded ---------
1081
+ // This write sits above BOTH returns below, so EVERY terminal path reaches
1082
+ // it — the external-abort break, the --timeout, the poll-failure bail, the
1083
+ // tool-call wedge — and it used to stamp 'complete' on all of them. The live
1084
+ // workspace reads progress.json directly (src/observe/live-doc.js
1085
+ // enrichLegUsage), so an aborted or errored leg rendered with a green check
1086
+ // until metadata.json landed. Fix the WRITER: src/observe/council-legs.js
1087
+ // already prefers metadata.json for a terminal leg and is NOT the problem.
1088
+ //
1089
+ // resolveTerminalState is the codebase's single source of truth for the
1090
+ // (completed, timedOut, aborted, error) → status mapping, and it is what
1091
+ // start.js / continue.js / resume.js / finalizeHeadlessResult will run on
1092
+ // THIS function's return value to stamp metadata.json. Deriving the stage
1093
+ // from it — rather than re-deriving a second, hand-rolled expression here —
1094
+ // is what guarantees progress.json's stage and metadata.json's status cannot
1095
+ // disagree. It also covers the two cases a hand-rolled `aborted ? … :
1096
+ // sessionError ? …` would get wrong: a TIMED-OUT leg (which would still have
1097
+ // read 'complete'), and the F1 case where a session error arrived alongside
1098
+ // usable output and the leg legitimately returns completed (which would have
1099
+ // read a false 'error').
1100
+ //
1101
+ // `failedWithNoUsableOutput` is hoisted out of the `if` below so the stage
1102
+ // and the returned shape are decided by ONE predicate and cannot drift.
1103
+ const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled));
1104
+ const { resolveTerminalState } = require('./sidecar/session-finalize');
1105
+ const terminalStage = resolveTerminalState({
1106
+ completed,
1107
+ timedOut,
1108
+ aborted,
1109
+ error: failedWithNoUsableOutput ? sessionError : null,
1110
+ }).status;
1111
+ try { writeProgress(sessionDir, terminalStage, { usage: { ...usage, ...subtreeProgress }, ...settleFlags }); }
985
1112
  catch (progressErr) {
986
1113
  logger.debug('terminal progress write failed (best-effort)', { taskId, error: progressErr.message });
987
1114
  }
988
1115
  const settleResult = toolSettleTimedOut
989
- ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling }
1116
+ ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling, toolSettleAborted }
990
1117
  : {};
991
1118
 
992
- if (sessionError && (!mirror.output || pollFailureBail || toolStalled)) {
1119
+ if (failedWithNoUsableOutput) {
993
1120
  return {
994
1121
  summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
995
1122
  completed: false,
@@ -1036,7 +1163,53 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1036
1163
  }
1037
1164
  if (watchdog) { watchdog.cancel(); }
1038
1165
  if (uninstallSignals) { uninstallSignals(); }
1039
- if (!externalServer) { await server.close(); }
1166
+ // ⚠️ v4.4.1 M2: guarded — this used to be a bare `await server.close()` sitting directly
1167
+ // above A3's terminal-write block, OUTSIDE any try, in the one place a close failure is
1168
+ // least affordable: the error handler. A rejection here would have skipped the terminal
1169
+ // progress write below entirely AND replaced the original `error` (the one A3 exists to
1170
+ // preserve) with this close failure instead. `close()` has already done its job of freeing
1171
+ // the port by the time we get here; a failure to close cleanly is not this handler's
1172
+ // problem to propagate, so it is logged and swallowed, same discipline as every other
1173
+ // best-effort close in this file (see the signal handler above).
1174
+ if (!externalServer) {
1175
+ try { await server.close(); } catch (closeErr) {
1176
+ logger.debug('server.close() failed in the outer exception handler (best-effort)', {
1177
+ taskId, error: closeErr.message,
1178
+ });
1179
+ }
1180
+ }
1181
+ // ⚠️ v4.4.1 A3 — the last hole in LC-3's story. LC-3 made the SUCCESS path's terminal
1182
+ // progress write derive its stage from resolveTerminalState instead of hardcoding 'complete',
1183
+ // so an aborted/errored/timed-out leg stopped rendering with a green check. This path — the
1184
+ // outer exception handler — wrote NO terminal progress record at all, so progress.json kept
1185
+ // whatever non-terminal stage the last flush left on it (usually 'receiving') while the
1186
+ // caller's finalizeHeadlessResult stamped metadata.json 'error' off the return value below.
1187
+ // The live workspace and `amicus watch` read progress.json DIRECTLY (live-doc.js
1188
+ // enrichLegUsage, council-legs.js), so a leg that exploded rendered as still-streaming
1189
+ // forever: exactly the stale-state class LC-3 closed one path over.
1190
+ //
1191
+ // ⚠️ This runs INSIDE an error handler: it must never throw and must never mask the original
1192
+ // error, so the whole thing sits in its own try and its failure is a debug line, exactly like
1193
+ // the success path's write. The stage comes from the same single source of truth that path
1194
+ // uses, so progress.json's stage and metadata.json's status still cannot disagree.
1195
+ //
1196
+ // ⚠️ The prior `usage` is READ BACK and re-attached deliberately. writeProgress REBUILDS
1197
+ // progress.json from `{stage, stageLabel, updatedAt, ...extra}` — it does not merge — so a
1198
+ // bare terminal write would silently delete whatever real spend the last 'receiving' flush had
1199
+ // already recorded, trading a stale-stage bug for a cost-under-report on exactly the legs that
1200
+ // failed. There are no settled totals on this path (that is what the exception cost us), so
1201
+ // carrying the last known usage forward unchanged is the honest maximum.
1202
+ //
1203
+ // v4.4.1 M3 — scope of "the last known ones": `usage` ONLY. That same 'receiving' flush also
1204
+ // wrote `p.extra` (e.g. `messagesReceived`), and that is deliberately left to drop here, not
1205
+ // carried forward too — the same call LC-3's success-path terminal write already made (see
1206
+ // that block's comment above): readProgress() derives `messages` from conversation.jsonl's
1207
+ // assistant entries directly and only falls back to `messagesReceived` when there are none, so
1208
+ // restating a stale count on an exception — where conversation.jsonl is the more truthful,
1209
+ // already-mirrored source — could only disagree with the file it exists to summarize.
1210
+ if (!writeTerminalProgressSafe(sessionDir, error.message)) {
1211
+ logger.debug('terminal progress write failed after exception (best-effort)', { taskId });
1212
+ }
1040
1213
  const { emptyUsageTotals } = require('./utils/pricing');
1041
1214
  return {
1042
1215
  summary: '',
@@ -24,6 +24,22 @@ function textResult(text, isError) {
24
24
  return result;
25
25
  }
26
26
 
27
+ /**
28
+ * v4.5 Task 15 (B7/F5): maps amicus_council_run's MCP input keys to the CLI
29
+ * arg-key names applyPackToArgs's knob tables use (pack-resolve.js), so
30
+ * applyPackToMcpInput can reuse those tables unchanged. `template` has no
31
+ * Zod-declared counterpart on this tool (MCP has no template param of its
32
+ * own — template/apply.js's own docblock: "MCP has no template params of its
33
+ * own") — a pack's briefing.template is the ONLY way a template reaches this
34
+ * handler, carried through as a plain (non-schema) `input.template` property
35
+ * consumed by the render step below.
36
+ */
37
+ const COUNCIL_PACK_PARAM_MAP = {
38
+ models: 'models', council: 'council', chair: 'chair', critic: 'critic', lenses: 'lenses',
39
+ debate: 'debate', timeoutMinutes: 'timeout', maxCost: 'max-cost', gateway: 'gateway',
40
+ template: 'template',
41
+ };
42
+
27
43
  /**
28
44
  * Resolve the bench: models XOR council preset (amicus_fanout parity).
29
45
  * Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
@@ -45,9 +61,11 @@ function resolveBenchInput(input) {
45
61
  const presetName = input.council.trim();
46
62
  const expanded = resolveCouncilMembers(presetName, catalog);
47
63
  if (expanded.error) { return { error: expanded.error }; }
48
- return { bench: expanded.models, presetName };
64
+ // v4.5 Wave 2: the child never re-resolves (bench is spawned pre-expanded
65
+ // to --models) — the pre-seed below is the only place this is recorded.
66
+ return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
49
67
  }
50
- return { bench: inputModels, presetName: null };
68
+ return { bench: inputModels, presetName: null, droppedMembers: [] };
51
69
  }
52
70
 
53
71
  /**
@@ -55,7 +73,8 @@ function resolveBenchInput(input) {
55
73
  * {runId, runDir} immediately (fenced).
56
74
  * @param {object} input tool input
57
75
  * @param {string} project resolved project dir
58
- * @param {{spawnFn: Function, clientName: string}} helpers injected by mcp-server
76
+ * @param {{spawnFn: Function, clientName: string, autoOpen?: {decide: Function, launch: Function}}} helpers
77
+ * injected by mcp-server; `autoOpen` is a v4.5 test seam (real modules used when absent)
59
78
  */
60
79
  async function handleCouncilRunTool(input, project, helpers) {
61
80
  // Task 15 (spec §5.3): validate onComplete FIRST, before any run dir is
@@ -74,10 +93,45 @@ async function handleCouncilRunTool(input, project, helpers) {
74
93
  if (briefing.charCodeAt(0) === 0xFEFF) { briefing = briefing.slice(1); }
75
94
  if (!briefing.trim()) { return textResult(`briefingFile ${input.briefingFile} is empty.`, true); }
76
95
 
96
+ // v4.5 Task 15 (B7/F5): resolve `pack` IN-PROCESS, before bench/chair/etc
97
+ // resolution, so a pack-filled input.models/council/chair/critic/lenses/
98
+ // timeoutMinutes/maxCost/gateway/debate flows through the SAME validation
99
+ // below a typed value would (single-resolution rule: never spawn --pack —
100
+ // this is the only place the pack is resolved).
101
+ let packRecord = null;
102
+ const notices = [];
103
+ if (input.pack !== undefined) {
104
+ const { applyPackToMcpInput } = require('./pack/pack-resolve');
105
+ const pr = applyPackToMcpInput({
106
+ packRef: input.pack, expectedKind: 'council', input, paramMap: COUNCIL_PACK_PARAM_MAP,
107
+ });
108
+ // v4.5 final-review T15-m1: amicus_start's own pack-error branch
109
+ // (mcp-server.js) keeps code+hint via buildErrorDoc's JSON envelope; this
110
+ // handler's error surface is plain text (born-fenced, not JSON), so the
111
+ // hint (e.g. PACK_NOT_FOUND's 'amicus pack list') is appended to the
112
+ // message instead of being converted into a JSON envelope, which would
113
+ // change this tool's established response shape.
114
+ if (pr.error) { return textResult(pr.error.message + (pr.error.hint ? `\n${pr.error.hint}` : ''), true); }
115
+ packRecord = pr.packRecord;
116
+ notices.push(...pr.notices);
117
+ }
118
+ // MCP has no template param of its own — a pack's briefing.template (merged
119
+ // onto input.template above) is the only way one reaches this handler.
120
+ // {{prompt}} = the briefingFile content; the RENDERED text is what lands in
121
+ // briefing.md below (mirrors the CLI's single template-application point).
122
+ if (input.template !== undefined) {
123
+ const { applyTemplate } = require('./template/apply');
124
+ const t = applyTemplate({ templateRef: input.template, prompt: briefing, project });
125
+ if (t.error) { return textResult(t.error.message, true); }
126
+ briefing = t.prompt;
127
+ notices.push(...t.notices);
128
+ }
129
+
77
130
  const benchRes = resolveBenchInput(input);
78
131
  if (benchRes.error) { return textResult(benchRes.error, true); }
79
132
  const bench = benchRes.bench;
80
133
  const presetName = benchRes.presetName;
134
+ const droppedMembers = benchRes.droppedMembers || [];
81
135
  if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
82
136
  const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
83
137
  if (bench.includes(chair)) {
@@ -121,6 +175,14 @@ async function handleCouncilRunTool(input, project, helpers) {
121
175
  maxCost: (typeof input.maxCost === 'number') ? input.maxCost : null,
122
176
  gateway: input.gateway || 'auto', outDir: runDir,
123
177
  },
178
+ // v4.5 Task 15: additive-only — absent (not null) without a pack. The
179
+ // spawned child's own seed omits `pack` (never passed --pack); initRun's
180
+ // plain shallow merge (run-state.js) preserves this pre-seeded value —
181
+ // pinned behavior, Task 12.
182
+ ...(packRecord ? { pack: packRecord } : {}),
183
+ // v4.5 Wave 2: additive, same preserved-across-the-child's-own-initRun
184
+ // precedent as `pack` above — absent (never []) when nothing dropped.
185
+ ...(droppedMembers.length ? { droppedMembers } : {}),
124
186
  usage: null, createdAt: new Date().toISOString(),
125
187
  });
126
188
  runState.writePointer(project, runId, runDir);
@@ -170,14 +232,56 @@ async function handleCouncilRunTool(input, project, helpers) {
170
232
  // the only code that later sees this council run reach terminal state.
171
233
  if (oc.mode === 'mcp-notify') { requestMcpNotify(runId); }
172
234
 
235
+ // ★ v4.5 auto-open (spec §6): decide via the pure helper, launch detached,
236
+ // never await, never fail the run. helpers.autoOpen is a test seam.
237
+ const ao = helpers.autoOpen || {
238
+ decide: (ctx) => require('./sidecar/workspace-auto-open').shouldAutoOpenWorkspace(ctx),
239
+ launch: (opts) => require('./sidecar/workspace-window').launchWorkspaceWindowDetached(opts),
240
+ };
241
+ let workspaceOpened = false;
242
+ let workspaceOpenReason = null;
243
+ try {
244
+ const { isElectronUsable } = require('./sidecar/electron-install');
245
+ const { getWorkspaceAutoOpen } = require('./utils/config');
246
+ const decision = ao.decide({
247
+ client: helpers.clientName,
248
+ electronUsable: isElectronUsable(),
249
+ platform: process.platform,
250
+ env: process.env,
251
+ autoOpenConfig: getWorkspaceAutoOpen(),
252
+ uiParam: input.ui,
253
+ });
254
+ if (decision.open) {
255
+ const r = ao.launch({ project, runId });
256
+ if (r && r.launched === false) {
257
+ workspaceOpenReason = r.reason;
258
+ } else {
259
+ workspaceOpened = true;
260
+ }
261
+ } else {
262
+ workspaceOpenReason = decision.reason;
263
+ }
264
+ } catch (err) {
265
+ workspaceOpenReason = `auto-open-failed: ${err.message}`;
266
+ }
267
+
173
268
  const body = JSON.stringify({
174
269
  schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
175
270
  message: 'Council run started. Preferred: call amicus_wait with the runId — one blocking ' +
176
271
  'call replaces polling; re-call it while it returns timedOut: true. Fallback: poll ' +
177
272
  'amicus_status with the runId. Artifacts land in runDir (verdict.json, report.html).',
273
+ workspaceOpened,
274
+ ...(workspaceOpenReason ? { workspaceOpenReason } : {}),
275
+ // v4.5 Wave 2: otherwise invisible here short of separately reading run.json.
276
+ ...(droppedMembers.length ? { droppedMembers } : {}),
178
277
  });
179
278
  // Born-fenced (spec §8): council MCP tool text is wrapped like amicus_read.
180
- return textResult(fenceSidecarOutput(body));
279
+ const content = [{ type: 'text', text: fenceSidecarOutput(body) }];
280
+ // v4.5 Task 15: pack/template notices (e.g. a bench-override) are non-fatal —
281
+ // surfaced as extra unfenced content blocks, same precedent as
282
+ // mcp-server.js's routeResult.notice (amicus_start).
283
+ for (const n of notices) { content.push({ type: 'text', text: n }); }
284
+ return { content };
181
285
  }
182
286
 
183
287
  // The council-awareness helpers live in their own module; re-exported here so