amicus 4.4.1 → 4.5.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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +154 -0
  3. package/README.md +15 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/ROADMAP.md +38 -14
  6. package/docs/configuration.md +24 -0
  7. package/docs/council.md +62 -0
  8. package/docs/schemas.md +1 -0
  9. package/docs/usage.md +151 -1
  10. package/electron/workspace-ui/workspace-app.js +39 -17
  11. package/electron/workspace-ui/workspace-panels.js +76 -18
  12. package/electron/workspace-ui/workspace-render.js +10 -0
  13. package/package.json +1 -1
  14. package/schemas/council-run-live.schema.json +1 -1
  15. package/schemas/council-run.schema.json +14 -0
  16. package/schemas/error.schema.json +1 -1
  17. package/schemas/event.schema.json +1 -1
  18. package/schemas/pack.schema.json +30 -0
  19. package/schemas/progress.schema.json +1 -1
  20. package/schemas/run-live.schema.json +1 -1
  21. package/schemas/run.schema.json +2 -1
  22. package/schemas/wave-live.schema.json +1 -1
  23. package/schemas/wave.schema.json +2 -1
  24. package/skills/second-opinion/SKILL.md +5 -0
  25. package/src/cli-handlers-council-run.js +51 -8
  26. package/src/cli-handlers-doctor.js +10 -0
  27. package/src/cli-handlers-pack.js +238 -0
  28. package/src/cli-handlers-run.js +36 -8
  29. package/src/cli-handlers-template.js +53 -0
  30. package/src/cli.js +64 -3
  31. package/src/council/findings.js +4 -41
  32. package/src/council/presets-cli.js +23 -11
  33. package/src/council/run-stages.js +12 -9
  34. package/src/council/run-state.js +17 -0
  35. package/src/council/run.js +1 -1
  36. package/src/headless.js +18 -14
  37. package/src/mcp-council-run.js +110 -4
  38. package/src/mcp-server.js +203 -7
  39. package/src/mcp-tools.js +15 -5
  40. package/src/pack/pack-cli.js +38 -0
  41. package/src/pack/pack-forward.js +96 -0
  42. package/src/pack/pack-resolve.js +297 -0
  43. package/src/pack/pack-store.js +130 -0
  44. package/src/pack/pack-validate.js +113 -0
  45. package/src/sidecar/electron-state.js +61 -0
  46. package/src/sidecar/fanout.js +21 -4
  47. package/src/sidecar/progress.js +34 -0
  48. package/src/sidecar/start.js +5 -4
  49. package/src/sidecar/workspace-auto-open.js +83 -0
  50. package/src/sidecar/workspace-window.js +46 -1
  51. package/src/template/apply.js +88 -0
  52. package/src/template/render.js +86 -0
  53. package/src/template/store.js +106 -0
  54. package/src/utils/config.js +65 -25
  55. package/src/utils/doctor-electron-mcp-check.js +150 -0
  56. package/src/utils/error-doc.js +5 -0
  57. package/src/utils/result-schema-rebuild.js +1 -0
  58. package/src/utils/result-schema.js +8 -2
  59. package/src/workspace/artifact-guard.js +44 -6
  60. package/src/workspace/run-detail.js +6 -0
package/src/cli.js CHANGED
@@ -44,6 +44,7 @@ function parseArgs(argv) {
44
44
  _: [],
45
45
  ...DEFAULTS
46
46
  };
47
+ result.__explicit = new Set();
47
48
 
48
49
  for (let i = 0; i < argv.length; i++) {
49
50
  const arg = argv[i];
@@ -63,19 +64,22 @@ function parseArgs(argv) {
63
64
  // Boolean flags (no value expected)
64
65
  if (isBooleanFlag(key)) {
65
66
  result[key] = true;
67
+ result.__explicit.add(key);
66
68
  continue;
67
69
  }
68
70
 
69
71
  // If --key=value was used, use the inline value directly
70
72
  if (inlineValue !== undefined) {
71
73
  result[key] = parseValue(key, inlineValue);
74
+ result.__explicit.add(key);
72
75
  continue;
73
76
  }
74
77
 
75
78
  // Array accumulation flags
76
- if (key === 'exclude-mcp' && next && !next.startsWith('--')) {
77
- result['exclude-mcp'] = result['exclude-mcp'] || [];
78
- result['exclude-mcp'].push(next);
79
+ if ((key === 'exclude-mcp' || key === 'var') && next && !next.startsWith('--')) {
80
+ result[key] = result[key] || [];
81
+ result[key].push(next);
82
+ result.__explicit.add(key);
79
83
  i++;
80
84
  continue;
81
85
  }
@@ -85,6 +89,7 @@ function parseArgs(argv) {
85
89
  // boolean so it can never swallow the following positional as a value.
86
90
  if (key.startsWith('no-')) {
87
91
  result[key] = true;
92
+ result.__explicit.add(key);
88
93
  continue;
89
94
  }
90
95
 
@@ -95,6 +100,7 @@ function parseArgs(argv) {
95
100
  } else {
96
101
  result[key] = true;
97
102
  }
103
+ result.__explicit.add(key);
98
104
  } else if (arg === '-o') {
99
105
  // Single short-flag alias, scoped to exactly '-o' (council verdict's
100
106
  // --out shorthand). No general short-flag support is implemented —
@@ -106,6 +112,7 @@ function parseArgs(argv) {
106
112
  } else {
107
113
  result.out = true;
108
114
  }
115
+ result.__explicit.add('out');
109
116
  } else {
110
117
  result._.push(arg);
111
118
  }
@@ -428,6 +435,10 @@ Options for 'start':
428
435
  --no-validate-model Skip model-catalog validation before launch
429
436
  --gateway <mode> Routing: auto (direct-first), direct, or openrouter
430
437
  --position <pos> Window position: right (default), left, center
438
+ --template <name|path> Render a briefing template ({{prompt}}, {{artifact}}, {{var.*}})
439
+ --artifact <file> File whose content fills {{artifact}} (256 KB cap; needs --template)
440
+ --var <k=v> Template variable, repeatable (needs --template)
441
+ --pack <name|path> Load a saved pack (model/options/template); explicit flags override it
431
442
  `,
432
443
  fanout: `
433
444
  Options for 'fanout':
@@ -459,6 +470,10 @@ Options for 'fanout':
459
470
  RESULT_FILE/EVENTS_FILE/COST/PROJECT), never model
460
471
  text. Child stdout/stderr go to amicus stderr.
461
472
  Never changes the wave's exit code, docs, or events.
473
+ --template <name|path> Render a briefing template ({{prompt}}, {{artifact}}, {{var.*}})
474
+ --artifact <file> File whose content fills {{artifact}} (256 KB cap; needs --template)
475
+ --var <k=v> Template variable, repeatable (needs --template)
476
+ --pack <name|path> Load a saved pack (bench/options/template); explicit flags override it
462
477
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
463
478
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
464
479
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
@@ -545,6 +560,8 @@ Subcommands for 'council':
545
560
  [--gateway auto|direct|openrouter] [--no-validate-model]
546
561
  [--debate] [--claude-review <file>] [--no-cost-gate] [--follow]
547
562
  [--fallback] [--no-fallback] [--on-complete <cmd>]
563
+ [--template <name|path>] [--artifact <file>] [--var <k=v>]
564
+ [--pack <name|path>]
548
565
  Run the full headless council engine (v4.0).
549
566
  Chair default: deepseek (must NOT be a bench seat).
550
567
  --critic and --lenses are mutually exclusive.
@@ -567,6 +584,13 @@ Subcommands for 'council':
567
584
  EVENTS_FILE/COST/PROJECT), never model text. Child
568
585
  stdout/stderr go to amicus stderr. Never changes
569
586
  the run's exit code, docs, or events.
587
+ --template <name|path> renders a briefing from
588
+ {{prompt}}, {{artifact}}, {{var.*}}; --artifact
589
+ fills {{artifact}} (256 KB cap); --var sets
590
+ {{var.*}} (repeatable). Both require --template.
591
+ --pack <name|path> loads a saved pack (bench,
592
+ chair, critic/lenses, options, template);
593
+ explicit flags always override the pack's values.
570
594
  Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
571
595
  save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
572
596
  --json Machine-readable output
@@ -639,6 +663,43 @@ Options for 'init':
639
663
  Runs skill install + MCP registration on demand (for plugin-channel /
640
664
  --ignore-scripts installs, a failed postinstall, or repairing deleted
641
665
  ~/.claude state). No flags registers both Claude Code and Claude Desktop.
666
+ `,
667
+ template: `
668
+ Options for 'template':
669
+ amicus template list [--json] List templates (built-ins marked)
670
+ amicus template show <name|path> [--json] Print a template
671
+ `,
672
+ pack: `
673
+ Options for 'pack':
674
+ amicus pack save <name> --kind council|fanout|solo [flags]
675
+ Save a pack built from flags:
676
+ --bench <a,b,c|name> council/fanout: comma-
677
+ separated members, or a
678
+ saved council name
679
+ --model <model> solo kind only
680
+ --chair/--critic/--lenses council kind only
681
+ --timeout/--max-cost/--gateway shared run
682
+ options
683
+ --agent/--thinking/--summary-length fanout/
684
+ solo kind only
685
+ --debate / --no-debate council kind only
686
+ --template <name|path> briefing template
687
+ reference (not rendered)
688
+ --version <semver> default 1.0.0 (an
689
+ unchanged re-save is a
690
+ no-op; a changed one
691
+ auto-bumps the patch)
692
+ --description <text>
693
+ amicus pack save <name> --from-run <id>
694
+ Build a pack from an existing council run /
695
+ fanout wave / solo session instead of flags
696
+ (models, options, and a template REFERENCE only
697
+ — briefing text is never captured)
698
+ amicus pack list [--json] List saved packs
699
+ amicus pack show <name|path> [--json]
700
+ Print a pack plus its validation report (never
701
+ fails on an invalid pack — see 'validation')
702
+ amicus pack rm <name> [--json] Remove a saved pack
642
703
  `
643
704
  };
644
705
 
@@ -95,7 +95,7 @@ function bodyReadings(rest) {
95
95
  * it has one) body is returned rather than null — deliberately. A malformed emit must
96
96
  * not look like an absent one: callers key off that distinction (validateFindings
97
97
  * reports NOT_PARSEABLE with a real body instead of NO_FENCED_BLOCK;
98
- * countAttemptedFindings returns null vs 0, which repairCanHonorContract then reads).
98
+ * countAttemptedFindings returns null vs 0, a distinction downstream callers key off).
99
99
  *
100
100
  * Every Stage-1/Stage-2 extractor funnels through here — validateFindings,
101
101
  * countAttemptedFindings, and parse-stage2's parseJudgeOutput / parseDebateDefense
@@ -156,9 +156,9 @@ function validateFindings(jsonText) {
156
156
  //
157
157
  // NOT_PARSEABLE, not NO_FENCED_BLOCK: the distinction is load-bearing. The model
158
158
  // emitted something broken, not nothing — and countAttemptedFindings must keep
159
- // answering null (unverifiable) rather than 0 (a declared empty set), because
160
- // repairCanHonorContract reads exactly that difference. It does: its own JSON.parse
161
- // succeeds on `null`, and `Array.isArray(null.findings)` throws into its catch.
159
+ // answering null (unverifiable) rather than 0 (a declared empty set): its own
160
+ // JSON.parse succeeds on `null`, and `Array.isArray(null.findings)` throws into
161
+ // its catch.
162
162
  if (!parsed) {
163
163
  return { ok: false, findings: [], errors: [{ code: 'NOT_PARSEABLE',
164
164
  detail: `block body is ${JSON.stringify(parsed)}, not an object` }] };
@@ -233,42 +233,6 @@ function countAttemptedFindings(text) {
233
233
  } catch { return null; }
234
234
  }
235
235
 
236
- /**
237
- * A canonical repair that HONORS the contract for a review which declared zero
238
- * findings: the same (empty) set, with a real `overall`. Probe only — it is never
239
- * sent to a model.
240
- */
241
- const EMPTY_SET_REPAIR_PROBE =
242
- '```json\n{"overall":"I read the material and found nothing to report.","findings":[]}\n```';
243
-
244
- /**
245
- * Can a repair that honors the count contract pass validation at all, given the
246
- * count the ORIGINAL declared?
247
- *
248
- * ⚠️ v4.4.1 review F2. The repair prompt's contract is "the same findings, fixed —
249
- * do not add or remove findings", and run-stages.js refuses a repair that changed
250
- * the count. When the original declared ZERO findings, the only contract-honoring
251
- * repair is another empty set — so if the validator rejects an empty set, every
252
- * outcome of that repair wave is predetermined: a compliant repair fails
253
- * validation, a non-compliant one is refused on the count. Up to two PAID solo
254
- * legs whose only reachable end state is 'unstructured'. Don't buy it.
255
- *
256
- * The answer is ASKED of the validator instead of hard-coded so the two rules can
257
- * never drift. Task 3 (LC-10) makes a well-formed empty set valid; the day it
258
- * lands this predicate starts returning true on its own, and the malformed empty
259
- * original (blank or missing `overall`) enters the repair loop again — where a
260
- * repair can now succeed by re-emitting zero findings with a real `overall`.
261
- *
262
- * @param {number|null} attemptedCount countAttemptedFindings(originalText)
263
- * @returns {boolean} false ⇒ skip the repair loop; the spend cannot buy an outcome.
264
- * null (nothing to compare) is always repairable — that is the wave's main
265
- * legitimate use.
266
- */
267
- function repairCanHonorContract(attemptedCount) {
268
- if (attemptedCount !== 0) { return true; }
269
- return validateFindings(EMPTY_SET_REPAIR_PROBE).ok;
270
- }
271
-
272
236
  /**
273
237
  * v4.0 §7: stamp the council v2 envelope onto a validateFindings result
274
238
  * (additive — ok/findings/errors stay top-level; existing key-readers keep
@@ -283,5 +247,4 @@ function buildValidateDoc(result) {
283
247
 
284
248
  module.exports = {
285
249
  validateFindings, buildValidateDoc, SEVERITIES, lastJsonBlock, countAttemptedFindings,
286
- repairCanHonorContract,
287
250
  };
@@ -56,7 +56,8 @@ function runSave(name, modelsArg, useJson) {
56
56
 
57
57
  function renderSave(doc) {
58
58
  const notice = doc.overwritten ? ' (overwritten)' : '';
59
- return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n`;
59
+ return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n` +
60
+ " for full run configuration — chair, options, templates — see 'amicus pack'\n";
60
61
  }
61
62
 
62
63
  /**
@@ -102,6 +103,16 @@ function renderList(entries) {
102
103
  * which fails outright below 2 usable members), `show` is diagnostic-only:
103
104
  * it always reports the full resolved/dropped split, even for a council
104
105
  * that currently has fewer than 2 usable members.
106
+ *
107
+ * v4.5 Wave 2 (post-HOLD chip, task-23-report.md Anomaly 1): the resolved/
108
+ * dropped split now REUSES `classifyCouncilMembers` — the exact alias +
109
+ * catalog-membership + local-provider-tri-state check `resolveCouncilMembers`
110
+ * applies on every real run — instead of a parallel check that only asked
111
+ * "does the alias map to SOME id?" and never consulted the catalog at all
112
+ * (so a member whose alias resolved to a catalog-absent id read as healthy
113
+ * here while every real run silently dropped it). `droppedMembers` carries
114
+ * each dropped member's reason, distinguishing an unresolvable alias from a
115
+ * catalog-absent id — the same distinction `resolveCouncilMembers` computes.
105
116
  * @param {string|undefined} name
106
117
  * @param {boolean} useJson
107
118
  * @returns {number} exit code
@@ -111,7 +122,7 @@ function runShow(name, useJson) {
111
122
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council show needs a <name>',
112
123
  hint: 'amicus council show <name> [--json]' });
113
124
  }
114
- const { getCouncilWithSource, getEffectiveAliases } = require('../utils/config');
125
+ const { getCouncilWithSource, classifyCouncilMembers } = require('../utils/config');
115
126
  const { readCache } = require('../utils/model-catalog');
116
127
  const catalog = (readCache() || {}).models || [];
117
128
  const { members, builtin } = getCouncilWithSource(name, catalog);
@@ -119,14 +130,8 @@ function runShow(name, useJson) {
119
130
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Unknown council '${name}'`,
120
131
  hint: "'amicus council list' shows available councils, or 'amicus council save' to create one" });
121
132
  }
122
- const aliases = getEffectiveAliases();
123
- const resolved = [];
124
- const dropped = [];
125
- for (const member of members) {
126
- const id = member.includes('/') ? member : aliases[member];
127
- (id ? resolved : dropped).push(member);
128
- }
129
- const doc = { name, builtin, members, resolved, dropped };
133
+ const { models, dropped, droppedMembers } = classifyCouncilMembers(members, catalog);
134
+ const doc = { name, builtin, members, resolved: models, dropped, droppedMembers };
130
135
  process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderShow(doc));
131
136
  return 0;
132
137
  }
@@ -134,7 +139,14 @@ function runShow(name, useJson) {
134
139
  function renderShow(doc) {
135
140
  const tag = doc.builtin ? ' [built-in]' : '';
136
141
  let out = `Council '${doc.name}'${tag}\n members: ${doc.members.join(', ')}\n resolved: ${doc.resolved.join(', ')}\n`;
137
- if (doc.dropped.length) { out += ` dropped: ${doc.dropped.join(', ')}\n`; }
142
+ if (doc.dropped.length) {
143
+ // Per-member reason (v4.5 Wave 2) when available; falls back to the bare
144
+ // ref list so this never throws on a hand-built doc missing the new field.
145
+ const detail = (doc.droppedMembers && doc.droppedMembers.length)
146
+ ? doc.droppedMembers.map(d => `${d.member} (${d.reason})`).join(', ')
147
+ : doc.dropped.join(', ');
148
+ out += ` dropped: ${detail}\n`;
149
+ }
138
150
  return out;
139
151
  }
140
152
 
@@ -17,7 +17,7 @@
17
17
  * review still gets ranked in Stage 2).
18
18
  */
19
19
 
20
- const { validateFindings, countAttemptedFindings, repairCanHonorContract } = require('./findings');
20
+ const { validateFindings, countAttemptedFindings } = require('./findings');
21
21
  const briefings = require('./briefings');
22
22
  const { materializeReviews, isAbortExit } = require('./run-launch');
23
23
  const runState = require('./run-state');
@@ -179,14 +179,17 @@ async function runStage1(ctx) {
179
179
  // prose actually narrates. null = absent/unparseable, so unverifiable — see
180
180
  // the push below.
181
181
  const attemptedCount = countAttemptedFindings(m.text);
182
- // ⚠️ Review F2: never pay for a repair whose every outcome is already decided.
183
- // An original declaring ZERO findings can only honor the contract by returning
184
- // zero so while the validator rejects an empty set (EMPTY_FINDINGS), a
185
- // compliant repair fails validation and a non-compliant one is refused on the
186
- // count below. Predicate, not a constant: Task 3 (LC-10) flips that validator
187
- // rule, and this guard stops firing on its own when it does.
188
- const repairable = repairCanHonorContract(attemptedCount);
189
- while (!res.ok && repairable && attempts < 2 && !ctx.overBudget()) {
182
+ // ⚠️ Review F2 (RESOLVED deleted v4.5, owner-ruled 2026-07-27): a
183
+ // repairCanHonorContract predicate used to sit here so a review declaring
184
+ // ZERO findings never paid for a repair while EMPTY_FINDINGS rejected empty
185
+ // sets every outcome of that wave was predetermined. LC-10 (v4.4.1) made a
186
+ // well-formed empty set VALID, which flipped the predicate constant-true by
187
+ // its own design, so it was removed rather than left as a dead guard someone
188
+ // deletes silently later. ⚠️ If you ever make validateFindings reject empty
189
+ // sets again, you are re-arming the F2 deadlock: restore a repairability
190
+ // check here first. tests/council/run-stages.test.js "never pays for a
191
+ // repair" pins the observable behavior.
192
+ while (!res.ok && attempts < 2 && !ctx.overBudget()) {
190
193
  attempts += 1;
191
194
  repairSeq += 1;
192
195
  const waveId = `${o.runId}-p${repairSeq}`;
@@ -101,6 +101,23 @@ function initCouncilRun(o) {
101
101
  // "no debate key" contract and fail the object-typed schema), and with a VALID
102
102
  // outcome from the first write so a run killed mid-debate stays schema-valid.
103
103
  ...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
104
+ // F9 (v4.5): additive-only — absent (not null) without --template; the MCP
105
+ // seed (mcp-council-run.js, initRun directly) never sets this in v4.5.
106
+ ...(o.template ? { template: o.template } : {}),
107
+ // v4.5 Task 12 (B7/F5): additive-only — absent (not null) without --pack.
108
+ // Preserved across a later MCP-child initRun whose own seed omits it —
109
+ // mergeRun's plain shallow merge never drops a key patch doesn't mention.
110
+ ...(o.pack ? { pack: o.pack } : {}),
111
+ // v4.5 Wave 2 (post-HOLD chip, task-23-report.md Anomaly 1): additive-only
112
+ // — absent (never an empty array) when nothing was dropped. Handler-computed
113
+ // (cli-handlers-council-run.js's resolveBench, via resolveCouncilMembers) so
114
+ // a scripted/--json/MCP caller has a signal a bench member vanished without
115
+ // diffing `bench` against the preset's nominal member list. Same
116
+ // preserved-across-a-later-MCP-child-seed precedent as `pack` above — the
117
+ // MCP handler pre-seeds this directly (mcp-council-run.js) before spawning
118
+ // the CLI child, whose own seed (bench already expanded to --models) never
119
+ // recomputes it and so never mentions the key.
120
+ ...(o.droppedMembers && o.droppedMembers.length ? { droppedMembers: o.droppedMembers } : {}),
104
121
  options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
105
122
  usage: null, pid: process.pid, createdAt: new Date().toISOString(),
106
123
  });
@@ -49,7 +49,7 @@ const { writeRunTerminal, resolveTerminalExit, SIGNAL_EXIT } = require('./run-fi
49
49
  */
50
50
  async function runCouncil(options, deps = {}) {
51
51
  const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
52
- noCostGate: false, councilName: null, ...options };
52
+ noCostGate: false, councilName: null, template: null, pack: null, ...options };
53
53
  o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
54
54
  const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
55
55
  const statsFn = deps.statsFn || require('./ledger').deriveReliability;
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');
@@ -289,6 +289,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
289
289
  logger.debug('Server started', { url: server.url });
290
290
  } catch (error) {
291
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
+ }
292
297
  return {
293
298
  summary: '',
294
299
  completed: false,
@@ -314,6 +319,10 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
314
319
 
315
320
  if (!serverReady) {
316
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
+ }
317
326
  return {
318
327
  summary: '',
319
328
  completed: false,
@@ -351,6 +360,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
351
360
  } catch (error) {
352
361
  if (watchdog) { watchdog.cancel(); }
353
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
+ }
354
369
  return {
355
370
  summary: '',
356
371
  completed: false,
@@ -1192,19 +1207,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1192
1207
  // assistant entries directly and only falls back to `messagesReceived` when there are none, so
1193
1208
  // restating a stale count on an exception — where conversation.jsonl is the more truthful,
1194
1209
  // already-mirrored source — could only disagree with the file it exists to summarize.
1195
- try {
1196
- let priorUsage = null;
1197
- try {
1198
- const prior = JSON.parse(fs.readFileSync(path.join(sessionDir, 'progress.json'), 'utf-8'));
1199
- if (prior && prior.usage) { priorUsage = prior.usage; }
1200
- } catch { /* no readable prior record: write the terminal stage without usage */ }
1201
- const { resolveTerminalState } = require('./sidecar/session-finalize');
1202
- const stage = resolveTerminalState({ error: error.message }).status;
1203
- writeProgress(sessionDir, stage, priorUsage ? { usage: priorUsage } : {});
1204
- } catch (progressErr) {
1205
- logger.debug('terminal progress write failed after exception (best-effort)', {
1206
- taskId, error: progressErr.message,
1207
- });
1210
+ if (!writeTerminalProgressSafe(sessionDir, error.message)) {
1211
+ logger.debug('terminal progress write failed after exception (best-effort)', { taskId });
1208
1212
  }
1209
1213
  const { emptyUsageTotals } = require('./utils/pricing');
1210
1214
  return {
@@ -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,58 @@ 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 { probeElectronState } = require('./sidecar/electron-state');
245
+ const { getWorkspaceAutoOpen } = require('./utils/config');
246
+ const es = probeElectronState(); // 3-state (#76): splits absent vs broken
247
+ const decision = ao.decide({
248
+ client: helpers.clientName,
249
+ electronState: es.state,
250
+ electronDir: es.electronDir,
251
+ platform: process.platform,
252
+ env: process.env,
253
+ autoOpenConfig: getWorkspaceAutoOpen(),
254
+ uiParam: input.ui,
255
+ });
256
+ if (decision.open) {
257
+ const r = ao.launch({ project, runId });
258
+ if (r && r.launched === false) {
259
+ workspaceOpenReason = r.reason;
260
+ } else {
261
+ workspaceOpened = true;
262
+ }
263
+ } else {
264
+ workspaceOpenReason = decision.reason;
265
+ }
266
+ } catch (err) {
267
+ workspaceOpenReason = `auto-open-failed: ${err.message}`;
268
+ }
269
+
173
270
  const body = JSON.stringify({
174
271
  schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
175
272
  message: 'Council run started. Preferred: call amicus_wait with the runId — one blocking ' +
176
273
  'call replaces polling; re-call it while it returns timedOut: true. Fallback: poll ' +
177
274
  'amicus_status with the runId. Artifacts land in runDir (verdict.json, report.html).',
275
+ workspaceOpened,
276
+ ...(workspaceOpenReason ? { workspaceOpenReason } : {}),
277
+ // v4.5 Wave 2: otherwise invisible here short of separately reading run.json.
278
+ ...(droppedMembers.length ? { droppedMembers } : {}),
178
279
  });
179
280
  // Born-fenced (spec §8): council MCP tool text is wrapped like amicus_read.
180
- return textResult(fenceSidecarOutput(body));
281
+ const content = [{ type: 'text', text: fenceSidecarOutput(body) }];
282
+ // v4.5 Task 15: pack/template notices (e.g. a bench-override) are non-fatal —
283
+ // surfaced as extra unfenced content blocks, same precedent as
284
+ // mcp-server.js's routeResult.notice (amicus_start).
285
+ for (const n of notices) { content.push({ type: 'text', text: n }); }
286
+ return { content };
181
287
  }
182
288
 
183
289
  // The council-awareness helpers live in their own module; re-exported here so