amicus 4.6.3 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +19 -7
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +143 -36
  6. package/docs/configuration.md +52 -4
  7. package/docs/council.md +65 -12
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/testing.md +1 -1
  11. package/docs/troubleshooting.md +33 -5
  12. package/docs/usage.md +64 -14
  13. package/electron/workspace-ui/index.html +3 -0
  14. package/electron/workspace-ui/live-model.js +52 -14
  15. package/electron/workspace-ui/workspace-app.js +14 -3
  16. package/electron/workspace-ui/workspace-lazy.js +233 -0
  17. package/electron/workspace-ui/workspace-matrix.js +12 -1
  18. package/electron/workspace-ui/workspace-panels.js +24 -171
  19. package/electron/workspace-ui/workspace-render.js +6 -2
  20. package/electron/workspace-ui/workspace-seats.js +68 -0
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +8 -4
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/scripts/postinstall.js +6 -3
  29. package/scripts/setup-hooks.js +49 -3
  30. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  31. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  32. package/skills/sidecar/SKILL.md +9 -2
  33. package/src/cli-council-run-bench.js +86 -0
  34. package/src/cli-handlers-council-run.js +65 -81
  35. package/src/cli-handlers-council.js +17 -5
  36. package/src/cli-handlers-fanout.js +179 -0
  37. package/src/cli-handlers-pack.js +24 -10
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/cli-handlers-run.js +19 -161
  40. package/src/cli-template-args.js +48 -0
  41. package/src/cli.js +39 -46
  42. package/src/council/debate.js +89 -10
  43. package/src/council/ledger.js +72 -11
  44. package/src/council/report.js +17 -6
  45. package/src/council/run-assemble.js +15 -3
  46. package/src/council/run-budget.js +2 -2
  47. package/src/council/run-chair.js +61 -5
  48. package/src/council/run-debate.js +51 -67
  49. package/src/council/run-launch.js +20 -2
  50. package/src/council/run-retry.js +17 -2
  51. package/src/council/run-stage1-launch.js +94 -0
  52. package/src/council/run-stage2.js +25 -4
  53. package/src/council/run-stages.js +79 -86
  54. package/src/council/run-state.js +10 -2
  55. package/src/council/run.js +26 -2
  56. package/src/council/tally.js +6 -2
  57. package/src/headless.js +69 -6
  58. package/src/mcp-council-awareness.js +1 -0
  59. package/src/mcp-council-bench.js +4 -0
  60. package/src/mcp-council-run.js +10 -0
  61. package/src/mcp-server.js +114 -54
  62. package/src/mcp-tools.js +12 -5
  63. package/src/pack/pack-cli.js +1 -1
  64. package/src/pack/pack-forward.js +12 -4
  65. package/src/pack/pack-resolve.js +3 -0
  66. package/src/pack/pack-store.js +20 -3
  67. package/src/pack/pack-validate.js +5 -1
  68. package/src/sidecar/budget.js +38 -4
  69. package/src/sidecar/continue.js +8 -23
  70. package/src/sidecar/fanout-budget.js +1 -2
  71. package/src/sidecar/fanout-leg-fallback.js +7 -3
  72. package/src/sidecar/fanout-retry.js +15 -3
  73. package/src/sidecar/fanout-wave-io.js +13 -1
  74. package/src/sidecar/fanout.js +11 -9
  75. package/src/sidecar/list-limit.js +50 -0
  76. package/src/sidecar/list-search.js +69 -0
  77. package/src/sidecar/read.js +90 -5
  78. package/src/sidecar/reopen-spend.js +32 -0
  79. package/src/sidecar/resume.js +1 -1
  80. package/src/sidecar/start-metadata.js +58 -0
  81. package/src/sidecar/start.js +8 -43
  82. package/src/sidecar/workspace-auto-open.js +2 -2
  83. package/src/spend-query.js +2 -1
  84. package/src/template/apply.js +7 -4
  85. package/src/template/render.js +6 -2
  86. package/src/template/store.js +1 -1
  87. package/src/utils/cli-preflight.js +27 -1
  88. package/src/utils/config.js +15 -0
  89. package/src/utils/doctor-engine-check.js +32 -0
  90. package/src/utils/engine-install-scan.js +98 -15
  91. package/src/utils/engine-repair.js +96 -2
  92. package/src/utils/remediation-hints.js +29 -0
  93. package/src/utils/result-schema-rebuild.js +1 -0
  94. package/src/utils/result-schema.js +6 -1
  95. package/src/utils/session-index-tmp-sweep.js +18 -3
  96. package/src/utils/session-index.js +1 -0
  97. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  98. package/src/utils/spend-ledger.js +11 -4
  99. package/src/utils/validators.js +16 -0
@@ -13,11 +13,12 @@
13
13
  'use strict';
14
14
 
15
15
  const { validateStartArgs } = require('./cli');
16
- const { validateTaskId } = require('./utils/validators');
16
+ const { validateTaskId, validateTag } = require('./utils/validators');
17
17
  const { resolveLaunchModel, maybeOfferProviderDefaults } = require('./utils/start-helpers');
18
18
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
19
19
  const { requireNoUiForJson } = require('./utils/cli-preflight');
20
- const { GATEWAY_MODES } = require('./utils/model-descriptor');
20
+ const { handleFanout } = require('./cli-handlers-fanout');
21
+ const { applyTemplateForArgs } = require('./cli-template-args');
21
22
 
22
23
  /**
23
24
  * Handle 'sidecar start' command
@@ -37,17 +38,9 @@ async function handleStart(args) {
37
38
  // Drop --prompt-file post-resolve or validateStartArgs re-trips its XOR guard.
38
39
  delete args['prompt-file'];
39
40
  }
40
- if (args.template !== undefined) {
41
- const { applyTemplate } = require('./template/apply');
42
- const t = applyTemplate({ templateRef: args.template, prompt: args.prompt,
43
- artifactFile: args.artifact, varList: args.var, project: args.cwd || process.cwd() });
44
- if (t.error) { process.exit(failJson(useJson, t.error)); }
45
- for (const n of t.notices) { process.stderr.write(n + '\n'); }
46
- args.prompt = t.prompt;
47
- templateMeta = t.promptMeta.template;
48
- } else if (args.artifact !== undefined || args.var !== undefined) {
49
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --artifact/--var require --template (expansion happens only in template files)' }));
50
- }
41
+ const tpl = applyTemplateForArgs(args, args.prompt, useJson);
42
+ if (tpl.fail !== undefined) { process.exit(tpl.fail); }
43
+ if (tpl.applied) { args.prompt = tpl.prompt; templateMeta = tpl.templateMeta; }
51
44
  requireNoUiForJson(args, useJson);
52
45
 
53
46
  const mc = args['max-cost'];
@@ -55,6 +48,18 @@ async function handleStart(args) {
55
48
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
56
49
  }
57
50
 
51
+ // v4.7 F8 (D13): --tag rejects (unlike sanitizeCouncilName, which cleans) —
52
+ // a stored tag is a user-chosen search key, so silent truncation/stripping
53
+ // would make --search/--group-by tag miss it. Checked here, beside the other
54
+ // pre-flight arg checks, so a bad tag exits before model resolution / the
55
+ // one-time onboarding tip (T2-m1: was previously after resolveLaunchModel).
56
+ if (args.tag !== undefined) {
57
+ const tagCheck = validateTag(args.tag);
58
+ if (!tagCheck.ok) {
59
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: tagCheck.error }));
60
+ }
61
+ }
62
+
58
63
  const { model, alias } = await resolveLaunchModel(args);
59
64
  args.model = model;
60
65
 
@@ -118,157 +123,10 @@ async function handleStart(args) {
118
123
  modelInput: alias || null,
119
124
  template: templateMeta, // F9 (v4.5): startSidecar ignores unknown keys; inert until a future task reads it.
120
125
  pack: packRecord, // v4.5 Task 13: null when no --pack; additively recorded on solo session metadata.
126
+ tag: args.tag, // v4.7 F8: undefined when no --tag; Task 3 stores it on session metadata.
121
127
  });
122
128
  }
123
129
 
124
- /**
125
- * Handle 'amicus fanout' command (F4).
126
- * Returns the wave exit code: 0 all complete, 2 partial, 1 none/hard failure,
127
- * 130/143 when the wave was signal-aborted.
128
- */
129
- async function handleFanout(args) {
130
- const useJson = !!args.json;
131
-
132
- // --retry-failed <waveId> (v4.3 Task 19, spec 6.1): a completely different
133
- // path from the --prompt/--models launch below (no briefing, no required
134
- // --models — the original wave's failed legs supply their own saved
135
- // context) — dispatch BEFORE any of that validation runs. --models here is
136
- // optional and, when present, filters which failed legs get retried.
137
- if (args['retry-failed']) {
138
- const { retryFailedWave } = require('./sidecar/fanout-retry');
139
- const { parseModelsList } = require('./sidecar/fanout-validate');
140
- const { exitCode, errorDoc } = await retryFailedWave(String(args['retry-failed']), args.cwd || process.cwd(), {
141
- models: parseModelsList(args.models), json: useJson,
142
- });
143
- if (errorDoc && useJson) { process.stdout.write(JSON.stringify(errorDoc) + '\n'); }
144
- return exitCode;
145
- }
146
- const packRecord = require('./pack/pack-cli').applyPackOrExit(args, 'fanout', useJson);
147
-
148
- // FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
149
- // --gateway via validateStartArgs (cli.js) — fanout never did, so a typo'd
150
- // value silently fell through to resolveGatewayMode's pass-through instead
151
- // of failing fast with a clear error.
152
- if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
153
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` }));
154
- }
155
-
156
- const { resolvePromptSource } = require('./utils/prompt-source');
157
- let promptRes;
158
- if (args.prompt !== undefined || args['prompt-file'] !== undefined || args.template === undefined) {
159
- promptRes = resolvePromptSource(args);
160
- if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
161
- } else {
162
- promptRes = { prompt: undefined, promptMeta: null };
163
- }
164
- if (args.template !== undefined) {
165
- const { applyTemplate } = require('./template/apply');
166
- const t = applyTemplate({ templateRef: args.template, prompt: promptRes.prompt,
167
- artifactFile: args.artifact, varList: args.var, project: args.cwd || process.cwd() });
168
- if (t.error) { process.exit(failJson(useJson, t.error)); }
169
- for (const n of t.notices) { process.stderr.write(n + '\n'); }
170
- promptRes = { prompt: t.prompt, promptMeta: t.promptMeta };
171
- } else if (args.artifact !== undefined || args.var !== undefined) {
172
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --artifact/--var require --template (expansion happens only in template files)' }));
173
- }
174
- // Council preset: expand a saved council into args.models (mutually exclusive with --models).
175
- const hasModels = typeof args.models === 'string' && args.models.trim();
176
- const hasCouncil = args.council !== undefined && args.council !== false;
177
- if (hasModels && hasCouncil) {
178
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pass exactly one of --models / --council, not both' }));
179
- }
180
- if (!hasModels && !hasCouncil) {
181
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models is required (comma-separated aliases or provider/model IDs), or use --council <name>' }));
182
- }
183
- if (hasCouncil) {
184
- if (typeof args.council !== 'string' || !args.council.trim()) {
185
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --council requires a council name (e.g. --council free)' }));
186
- }
187
- const { resolveCouncilMembers } = require('./utils/config');
188
- const { readCache } = require('./utils/model-catalog');
189
- const catalog = (readCache() || {}).models || [];
190
- const expanded = resolveCouncilMembers(args.council.trim(), catalog);
191
- if (expanded.error) {
192
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }));
193
- }
194
- if (expanded.dropped && expanded.dropped.length && !useJson) {
195
- process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
196
- }
197
- args.models = expanded.models.join(',');
198
- }
199
- if (args['wave-id']) {
200
- const check = validateTaskId(String(args['wave-id']));
201
- if (!check.valid) {
202
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error }));
203
- }
204
- }
205
- if (args.agent && String(args.agent).toLowerCase() === 'chat') {
206
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --agent chat is interactive-only; fanout is headless' }));
207
- }
208
- if (args.timeout !== undefined && args.timeout <= 0) {
209
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' }));
210
- }
211
- const mc = args['max-cost'];
212
- if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
213
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
214
- }
215
- const { parseModelsList } = require('./sidecar/fanout');
216
- if (parseModelsList(args.models).length === 0) {
217
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models must contain at least one non-empty entry' }));
218
- }
219
-
220
- // Direct require (fanout stays internal — no src/index.js public re-export).
221
- const { runFanout } = require('./sidecar/fanout');
222
- const { loadConfig, resolveGatewayMode } = require('./utils/config');
223
- const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
224
- const { readCache } = require('./utils/model-catalog');
225
- const cfg = loadConfig() || {};
226
- const { exitCode } = await runFanout({
227
- models: args.models,
228
- prompt: promptRes.prompt,
229
- promptMeta: promptRes.promptMeta,
230
- waveId: args['wave-id'],
231
- project: args.cwd || process.cwd(),
232
- agent: args.agent || args.mode,
233
- thinking: args.thinking,
234
- timeout: args.timeout,
235
- summaryLength: args['summary-length'],
236
- includeContext: !args['no-context'],
237
- sessionId: args['session-id'],
238
- contextTurns: args['context-turns'],
239
- contextSince: args['context-since'],
240
- contextMaxTokens: args['context-max-tokens'],
241
- // #10: forward the Cowork parent so MCP-spawned fanout legs pin the right
242
- // session (mirrors handleStart's coworkProcess plumbing). Without this the
243
- // spawned `--cowork-process` flag is dropped and buildContext gets null.
244
- coworkProcess: args['cowork-process'],
245
- mcp: args.mcp,
246
- mcpConfig: args['mcp-config'],
247
- noMcp: args['no-mcp'],
248
- excludeMcp: args['exclude-mcp'],
249
- noValidateModel: args['no-validate-model'],
250
- // #61 Task 7.3: --gateway merged with routing.prefer, applied per leg
251
- // by validateFanoutModels' router call.
252
- gatewayMode: resolveGatewayMode(args.gateway),
253
- json: !!args.json,
254
- client: args.client,
255
- maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,
256
- noCostGate: !!args['no-cost-gate'],
257
- maxCostPerMtok: cfg.maxCostPerMtok,
258
- follow: !!args.follow,
259
- onComplete: args['on-complete'],
260
- // v4.3 Task 18 (spec §6.2): opt-in cheaper-model substitution. --fallback
261
- // forces on, --no-fallback forces off; unset defers to config `fallbacks.enabled`.
262
- fallback: resolveFallbackConfig({
263
- flagFallback: args.fallback === true ? true : (args['no-fallback'] ? false : undefined),
264
- config: cfg,
265
- }),
266
- catalog: (readCache() || {}).models || [],
267
- pack: packRecord, // v4.5 Task 13: null when no --pack; additive on wave metadata.json + wave.json.
268
- });
269
- return exitCode;
270
- }
271
-
272
130
  /**
273
131
  * Handle 'sidecar read' command
274
132
  * Spec Reference: §4.5
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module cli-template-args
5
+ * v4.7 PR6 sweep: the single application point for --template/--artifact/--var,
6
+ * shared by the three CLI handlers (handleStart, handleFanout, handleCouncilRun)
7
+ * that used to carry this block verbatim-triplicated.
8
+ *
9
+ * NEVER calls process.exit: handleStart and handleFanout exit on failure, but
10
+ * handleCouncilRun RETURNS its exit code (its whole handler contract is
11
+ * return-the-code) — so the decision belongs to the caller, not this helper.
12
+ */
13
+
14
+ const { failJson, ERROR_CODES } = require('./utils/error-doc');
15
+
16
+ const NEEDS_TEMPLATE_MSG =
17
+ 'Error: --artifact/--var require --template (expansion happens only in template files)';
18
+
19
+ /**
20
+ * @param {object} args parsed argv
21
+ * @param {string|undefined} prompt pre-template prompt text
22
+ * @param {boolean} useJson
23
+ * @returns {{applied:false}
24
+ * | {applied:true, prompt:string, promptMeta:object, templateMeta:object}
25
+ * | {fail:number}}
26
+ */
27
+ function applyTemplateForArgs(args, prompt, useJson) {
28
+ if (args.template !== undefined) {
29
+ const { applyTemplate } = require('./template/apply');
30
+ const t = applyTemplate({
31
+ templateRef: args.template, prompt,
32
+ artifactFile: args.artifact, varList: args.var,
33
+ project: args.cwd || process.cwd(),
34
+ });
35
+ if (t.error) { return { fail: failJson(useJson, t.error) }; }
36
+ for (const n of t.notices) { process.stderr.write(n + '\n'); }
37
+ return {
38
+ applied: true, prompt: t.prompt, promptMeta: t.promptMeta,
39
+ templateMeta: t.promptMeta.template,
40
+ };
41
+ }
42
+ if (args.artifact !== undefined || args.var !== undefined) {
43
+ return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: NEEDS_TEMPLATE_MSG }) };
44
+ }
45
+ return { applied: false };
46
+ }
47
+
48
+ module.exports = { applyTemplateForArgs, NEEDS_TEMPLATE_MSG };
package/src/cli.js CHANGED
@@ -105,8 +105,12 @@ function parseArgs(argv) {
105
105
  // Single short-flag alias, scoped to exactly '-o' (council verdict's
106
106
  // --out shorthand). No general short-flag support is implemented —
107
107
  // any other leading-dash token still falls through to positionals.
108
+ // R5: deliberately lockstep with the long-option branch above
109
+ // (`!next.startsWith('--')`) — '-o' and '--out' must consume values
110
+ // identically. A dash-leading value is still accepted here (parsing
111
+ // only normalizes); cli-handlers-council.js's R1 guard rejects it.
108
112
  const next = argv[i + 1];
109
- if (next && !next.startsWith('-')) {
113
+ if (next && !next.startsWith('--')) {
110
114
  result.out = next;
111
115
  i++;
112
116
  } else {
@@ -455,6 +459,7 @@ Options for 'start':
455
459
  --artifact <file> File whose content fills {{artifact}} (256 KB cap; needs --template)
456
460
  --var <k=v> Template variable, repeatable (needs --template)
457
461
  --pack <name|path> Load a saved pack (model/options/template); explicit flags override it
462
+ --tag <t> Label this session for list/search/spend grouping (1-64 chars, [A-Za-z0-9_-])
458
463
  `,
459
464
  fanout: `
460
465
  Options for 'fanout':
@@ -465,31 +470,34 @@ Options for 'fanout':
465
470
  ~32KB Windows argument cap). Mutually exclusive
466
471
  with --prompt. Also works with 'start'.
467
472
  --wave-id <id> Explicit wave ID (leg IDs become <id>-1..N)
468
- --retry-failed <waveId> Relaunch ONLY that wave's failed/timed-out/crashed/
469
- aborted legs as a NEW linked wave, using each leg's
470
- own saved context (byte-identical retry). Skips
471
- --prompt/--models; --models filters which failed
472
- legs to retry. wave.json is never modified.
473
+ --retry-failed <waveId> Relaunch ONLY that wave's failed/timed-out/crashed/
474
+ aborted legs as a NEW linked wave, using each leg's
475
+ own saved context (byte-identical retry). Skips
476
+ --prompt/--models; --models filters which failed
477
+ legs to retry. wave.json is never modified.
473
478
  --json Emit the wave result as stable JSON on stdout
479
+ --quiet Suppress per-leg progress ticks, the wave-result summary, and
480
+ preflight errors
474
481
  --max-cost <$> Refuse the wave if the estimated total exceeds $ (soft ceiling)
475
482
  --no-cost-gate Disable the budget gate (per-$/Mtok threshold + ceiling) for this run
476
483
  --fallback / --no-fallback Opt-in cheaper-model substitution on a classified
477
484
  rate-limit/overload leg failure (spec 6.2). Overrides
478
485
  config fallbacks.enabled when passed; default: config, else off.
479
- --gateway <mode> Routing: auto (direct-first), direct, or openrouter
480
- --follow Stream this run's events to stderr as they happen (--json -> NDJSON)
481
- --on-complete <cmd> Run a shell command once, at terminal state, after
482
- wave.json is durable. The command is user-authored
483
- on THIS command line (CLI-only — never sourced from
484
- config/briefings/model output); payload rides via
485
- env only (AMICUS_TASK_ID/TYPE/STATUS/EXIT_CODE/
486
- RESULT_FILE/EVENTS_FILE/COST/PROJECT), never model
487
- text. Child stdout/stderr go to amicus stderr.
488
- Never changes the wave's exit code, docs, or events.
486
+ --gateway <mode> Routing: auto (direct-first), direct, or openrouter
487
+ --follow Stream this run's events to stderr as they happen (--json -> NDJSON)
488
+ --on-complete <cmd> Run a shell command once, at terminal state, after
489
+ wave.json is durable. The command is user-authored
490
+ on THIS command line (CLI-only — never sourced from
491
+ config/briefings/model output); payload rides via
492
+ env only (AMICUS_TASK_ID/TYPE/STATUS/EXIT_CODE/
493
+ RESULT_FILE/EVENTS_FILE/COST/PROJECT), never model
494
+ text. Child stdout/stderr go to amicus stderr.
495
+ Never changes the wave's exit code, docs, or events.
489
496
  --template <name|path> Render a briefing template ({{prompt}}, {{artifact}}, {{var.*}})
490
497
  --artifact <file> File whose content fills {{artifact}} (256 KB cap; needs --template)
491
498
  --var <k=v> Template variable, repeatable (needs --template)
492
499
  --pack <name|path> Load a saved pack (bench/options/template); explicit flags override it
500
+ --tag <t> Label this wave for list/search/spend grouping (1-64 chars, [A-Za-z0-9_-])
493
501
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
494
502
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
495
503
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
@@ -511,6 +519,8 @@ Options for 'models':
511
519
  Options for 'list':
512
520
  --status <filter> Filter by status (running, complete)
513
521
  --all Show all projects
522
+ --search <q> Case-insensitive substring filter over id, tag, and briefing
523
+ --limit <n> Show only the n newest rows (0 = unlimited)
514
524
  --json Output as JSON
515
525
  `,
516
526
  status: `
@@ -580,7 +590,7 @@ Subcommands for 'council':
580
590
  [--debate] [--claude-review <file>] [--no-cost-gate] [--follow]
581
591
  [--fallback] [--no-fallback] [--on-complete <cmd>]
582
592
  [--template <name|path>] [--artifact <file>] [--var <k=v>]
583
- [--pack <name|path>]
593
+ [--pack <name|path>] [--tag <t>]
584
594
  Run the full headless council engine (v4.0).
585
595
  Chair default: deepseek (must NOT be a bench seat).
586
596
  --critic and --lenses are mutually exclusive.
@@ -633,7 +643,7 @@ Options for 'spend':
633
643
  --model <id-or-prefix> Only rows whose model starts with this
634
644
  --op <start|continue|resume|leg> Only rows with this operation
635
645
  --failed Only non-complete (wasted) rows
636
- --group-by <model|wave|council|project|op|day> Rollup dimension (default model)
646
+ --group-by <model|wave|council|project|op|day|tag> Rollup dimension (default model)
637
647
  --rows Include matching raw rows (capped at 1000)
638
648
  --json Machine-readable output (versioned spend doc)
639
649
  Reads ~/.config/amicus/spend-ledger.jsonl (one row per completed run/leg).
@@ -691,34 +701,17 @@ Options for 'template':
691
701
  pack: `
692
702
  Options for 'pack':
693
703
  amicus pack save <name> --kind council|fanout|solo [flags]
694
- Save a pack built from flags:
695
- --bench <a,b,c|name> council/fanout: comma-
696
- separated members, or a
697
- saved council name
698
- --model <model> solo kind only
699
- --chair/--critic/--lenses council kind only
700
- --timeout/--max-cost/--gateway shared run
701
- options
702
- --agent/--thinking/--summary-length fanout/
703
- solo kind only
704
- --debate / --no-debate council kind only
705
- --template <name|path> briefing template
706
- reference (not rendered)
707
- --version <semver> default 1.0.0 (an
708
- unchanged re-save is a
709
- no-op; a changed one
710
- auto-bumps the patch)
711
- --description <text>
712
- amicus pack save <name> --from-run <id>
713
- Build a pack from an existing council run /
714
- fanout wave / solo session instead of flags
715
- (models, options, and a template REFERENCE only
716
- — briefing text is never captured)
717
- amicus pack list [--json] List saved packs
718
- amicus pack show <name|path> [--json]
719
- Print a pack plus its validation report (never
720
- fails on an invalid pack — see 'validation')
721
- amicus pack rm <name> [--json] Remove a saved pack
704
+ amicus pack save <name> --from-run <id> Build from an existing run (models/
705
+ options/template reference only — briefing never captured)
706
+ amicus pack list [--json] List saved packs
707
+ amicus pack show <name|path> [--json] Print a pack + validation report
708
+ amicus pack rm <name> [--json] Remove a saved pack
709
+ Flags: --bench <a,b,c|name> (council/fanout) · --model (solo) · --chair/--critic/--lenses
710
+ (council) · --debate/--no-debate (council) · --timeout/--max-cost/--gateway (shared) ·
711
+ --agent/--thinking/--summary-length (fanout/solo) · --template <name|path> (reference only,
712
+ not rendered) · --pack-version <semver> (the SAVED PACK's version, default 1.0.0; unchanged
713
+ re-save is a no-op, changed auto-bumps patch — NOT --version, which is amicus's own global
714
+ flag) · --description <text>
722
715
  `
723
716
  };
724
717
 
@@ -8,12 +8,17 @@
8
8
  * new tally input plus the debate.json findings rows. run.js then re-runs
9
9
  * tally() on the returned input and decorates the record. Keeps run.js under the
10
10
  * line gate.
11
+ * Also holds the pure pre-debate helpers (target selection, dispute detection, re-vote bundling) moved from run-debate.js (v4.7 PR0).
11
12
  */
12
13
 
13
14
  const PAST_TENSE = { defend: 'defended', amend: 'amended', withdraw: 'withdrawn', 'no-response': 'no-response' };
14
15
 
15
- // runStats roles the ledger join must skip (ledger.js): a debate leg is an extra leg by an
16
- // already-benched model, never an extra ledger row and never that model's ledger identity.
16
+ // The debate-role vocabulary: a debate leg is an extra leg by an already-benched model,
17
+ // never an extra ledger row and never that model's ledger identity. Through v4.6 this Set
18
+ // ALSO drove ledger.js's join skip-set directly; Task 7 (v4.7 D4) replaced that with
19
+ // ledger.js's own LEDGER_JOIN_ROLES allowlist (fail-closed: everything not named there is
20
+ // excluded, not just DEBATE_ROLES), so this Set no longer has any runtime consumer outside
21
+ // this module — kept exported because debate.test.js pins its exact contents.
17
22
  const DEBATE_ROLES = new Set(['rebuttal', 'revote']);
18
23
 
19
24
  /**
@@ -78,21 +83,95 @@ function decorateRecord(record, debateFindings) {
78
83
  }
79
84
 
80
85
  /**
81
- * runStats rows for the debate legs (spec §5.5). role is 'rebuttal' | 'revote';
82
- * these legs never enter meta.models, so the ledger stays one row per (run×model),
83
- * and ledger.js skips DEBATE_ROLES when joining runStats so a debate leg cannot
84
- * overwrite the bench row's role/wasChair/conformance on that model's ledger row.
85
- * @param {{defenseLegs: Array, revoteLegs: Array}} args leg metadata
86
+ * runStats rows for the debate legs (spec §5.5), plus v4.7 D2/E4's row-per-launch
87
+ * extras: role is 'rebuttal' | 'revote' for the primary defense/re-vote legs,
88
+ * 'superseded' for an original leg a successful repair replaced, and 'repair' for
89
+ * a repair attempt that itself never became usable (error status rides naturally
90
+ * off the raw leg). The rebuttal/revote legs never enter meta.models, so the
91
+ * ledger stays one row per (run×model). DEBATE_ROLES remains the debate-role
92
+ * vocabulary (rebuttal/revote); the ledger's overwrite protection for ALL FOUR
93
+ * of these row-per-launch roles — rebuttal, revote, superseded AND repair —
94
+ * lives in ledger.js's own LEDGER_JOIN_ROLES allowlist (v4.7 D4, Task 7):
95
+ * a role not named there never joins, full stop, regardless of which module
96
+ * produced the row or whether it is even in DEBATE_ROLES.
97
+ * @param {{defenseLegs: Array, revoteLegs: Array, supersededLegs?: Array,
98
+ * repairLegs?: Array}} args leg metadata
86
99
  * @returns {Array<object>}
87
100
  */
88
- function debateRunStatsRows({ defenseLegs, revoteLegs }) {
101
+ function debateRunStatsRows({ defenseLegs, revoteLegs, supersededLegs, repairLegs }) {
89
102
  const mk = (role) => (l) => ({
90
103
  model: l.model, role, wasChair: false, conformance: l.conformance || 'clean',
91
104
  status: l.status || 'unknown',
92
105
  durationMs: typeof l.durationMs === 'number' ? l.durationMs : null,
93
106
  usage: l.usage || null,
107
+ ...(l.waveId ? { waveId: l.waveId } : {}),
108
+ ...(l.resolvedModel ? { resolvedModel: l.resolvedModel } : {}),
94
109
  });
95
- return [...(defenseLegs || []).map(mk('rebuttal')), ...(revoteLegs || []).map(mk('revote'))];
110
+ return [
111
+ ...(defenseLegs || []).map(mk('rebuttal')),
112
+ ...(revoteLegs || []).map(mk('revote')),
113
+ ...(supersededLegs || []).map(mk('superseded')),
114
+ ...(repairLegs || []).map(mk('repair')),
115
+ ];
96
116
  }
97
117
 
98
- module.exports = { applyDebate, decorateRecord, debateRunStatsRows, PAST_TENSE, DEBATE_ROLES };
118
+ /** Spec §5.7 fallback: a dead/unparseable defense means every bundled id's original stands. */
119
+ function allNoResponse(ids) {
120
+ const byId = {};
121
+ for (const id of ids) { byId[id] = { action: 'no-response' }; }
122
+ return byId;
123
+ }
124
+
125
+ /** True when there is nothing to challenge (spec §5.1). */
126
+ function nothingToDebate(provisionalRecord) {
127
+ if (!provisionalRecord || provisionalRecord.judged === false) { return true; }
128
+ const n = provisionalRecord.findings.filter(f => f.tier === 'Contested' || f.tier === 'Disputed').length;
129
+ return n === 0;
130
+ }
131
+
132
+ /** Judges whose provisional adjudications dispute at least one bundled id. */
133
+ function disputingJudges(provisionalRecord, bundledIds) {
134
+ const ids = new Set(bundledIds);
135
+ const judges = new Set();
136
+ for (const f of provisionalRecord.findings) {
137
+ if (!ids.has(f.id)) { continue; }
138
+ for (const adj of f.adjudications || []) {
139
+ if (adj.verdict === 'dispute') { judges.add(adj.judge); }
140
+ }
141
+ }
142
+ return [...judges];
143
+ }
144
+
145
+ /** Group Contested+Disputed findings by raiser (defense targets). */
146
+ function debateTargets(provisionalRecord, tallyInput) {
147
+ const claimById = new Map(tallyInput.findings.map(f => [f.id, f]));
148
+ const byRaiser = {};
149
+ const previousTier = {};
150
+ for (const f of provisionalRecord.findings) {
151
+ if (f.tier !== 'Contested' && f.tier !== 'Disputed') { continue; }
152
+ previousTier[f.id] = f.tier;
153
+ const src = claimById.get(f.id) || {};
154
+ const peerVerdicts = (f.adjudications || []).filter(a => a.judge !== f.raiser).map(a => a.verdict);
155
+ (byRaiser[f.raiser] = byRaiser[f.raiser] || []).push({ id: f.id, claim: src.claim,
156
+ severity: f.severity, location: src.location, peerVerdicts, disputeReasons: [] });
157
+ }
158
+ return { byRaiser, previousTier };
159
+ }
160
+
161
+ /** The re-vote bundle: defended-or-amended findings ONLY (spec §5.1 — withdrawn never appear). */
162
+ function bundleFor(defenseResults, tallyInput) {
163
+ const out = [];
164
+ for (const dr of defenseResults) {
165
+ for (const [id, resp] of Object.entries(dr.byId)) {
166
+ if (resp.action !== 'defend' && resp.action !== 'amend') { continue; }
167
+ const src = tallyInput.findings.find(f => f.id === id) || {};
168
+ out.push({ id, severity: src.severity, amended: resp.action === 'amend',
169
+ claim: resp.action === 'amend' ? resp.claim : src.claim,
170
+ argument: resp.argument || 'defended without extra argument' });
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ module.exports = { applyDebate, decorateRecord, debateRunStatsRows, PAST_TENSE, DEBATE_ROLES,
177
+ allNoResponse, nothingToDebate, disputingJudges, debateTargets, bundleFor };