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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +154 -0
- package/README.md +15 -2
- package/bin/amicus.js +10 -0
- package/docs/ROADMAP.md +38 -14
- package/docs/configuration.md +24 -0
- package/docs/council.md +62 -0
- package/docs/schemas.md +1 -0
- package/docs/usage.md +151 -1
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-panels.js +76 -18
- package/electron/workspace-ui/workspace-render.js +10 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +14 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +1 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-doctor.js +10 -0
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-template.js +53 -0
- package/src/cli.js +64 -3
- package/src/council/findings.js +4 -41
- package/src/council/presets-cli.js +23 -11
- package/src/council/run-stages.js +12 -9
- package/src/council/run-state.js +17 -0
- package/src/council/run.js +1 -1
- package/src/headless.js +18 -14
- package/src/mcp-council-run.js +110 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/electron-state.js +61 -0
- package/src/sidecar/fanout.js +21 -4
- package/src/sidecar/progress.js +34 -0
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +83 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/doctor-electron-mcp-check.js +150 -0
- package/src/utils/error-doc.js +5 -0
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/workspace/artifact-guard.js +44 -6
- 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[
|
|
78
|
-
result[
|
|
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
|
|
package/src/council/findings.js
CHANGED
|
@@ -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,
|
|
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)
|
|
160
|
-
//
|
|
161
|
-
//
|
|
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,
|
|
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
|
|
123
|
-
const
|
|
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) {
|
|
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
|
|
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
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
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}`;
|
package/src/council/run-state.js
CHANGED
|
@@ -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
|
});
|
package/src/council/run.js
CHANGED
|
@@ -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
|
-
|
|
1196
|
-
|
|
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 {
|
package/src/mcp-council-run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|