amicus 4.6.2 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +24 -13
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +172 -36
  6. package/docs/configuration.md +56 -6
  7. package/docs/council.md +63 -10
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/troubleshooting.md +27 -1
  11. package/docs/usage.md +68 -15
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +132 -21
  14. package/electron/workspace-ui/workspace-app.js +20 -4
  15. package/electron/workspace-ui/workspace-lazy.js +233 -0
  16. package/electron/workspace-ui/workspace-matrix.js +12 -1
  17. package/electron/workspace-ui/workspace-panels.js +24 -171
  18. package/electron/workspace-ui/workspace-render.js +15 -5
  19. package/electron/workspace-ui/workspace-seats.js +88 -5
  20. package/electron/workspace-ui/workspace-verbs.js +1 -1
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +5 -2
  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/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  29. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  30. package/skills/sidecar/SKILL.md +7 -2
  31. package/src/cli-council-run-bench.js +86 -0
  32. package/src/cli-handlers-council-run.js +65 -81
  33. package/src/cli-handlers-council.js +24 -3
  34. package/src/cli-handlers-doctor.js +9 -3
  35. package/src/cli-handlers-fanout.js +179 -0
  36. package/src/cli-handlers-pack.js +24 -10
  37. package/src/cli-handlers-run.js +19 -161
  38. package/src/cli-template-args.js +48 -0
  39. package/src/cli.js +39 -46
  40. package/src/council/debate.js +89 -10
  41. package/src/council/ledger.js +72 -11
  42. package/src/council/presets-cli.js +6 -2
  43. package/src/council/report.js +17 -6
  44. package/src/council/run-assemble.js +15 -3
  45. package/src/council/run-budget.js +2 -2
  46. package/src/council/run-chair.js +70 -11
  47. package/src/council/run-debate.js +51 -67
  48. package/src/council/run-launch.js +9 -2
  49. package/src/council/run-retry.js +4 -1
  50. package/src/council/run-stage1-launch.js +94 -0
  51. package/src/council/run-stage2.js +25 -4
  52. package/src/council/run-stages.js +79 -86
  53. package/src/council/run-state.js +10 -2
  54. package/src/council/run.js +26 -2
  55. package/src/council/tally.js +6 -2
  56. package/src/mcp-council-awareness.js +1 -0
  57. package/src/mcp-council-bench.js +4 -0
  58. package/src/mcp-council-run.js +10 -0
  59. package/src/mcp-server.js +114 -54
  60. package/src/mcp-tools.js +12 -5
  61. package/src/pack/pack-cli.js +1 -1
  62. package/src/pack/pack-forward.js +12 -4
  63. package/src/pack/pack-resolve.js +3 -0
  64. package/src/pack/pack-store.js +20 -3
  65. package/src/pack/pack-validate.js +5 -1
  66. package/src/session-manager.js +6 -2
  67. package/src/sidecar/budget.js +38 -4
  68. package/src/sidecar/fanout-budget.js +1 -2
  69. package/src/sidecar/fanout-leg-fallback.js +7 -3
  70. package/src/sidecar/fanout-wave-io.js +13 -1
  71. package/src/sidecar/fanout.js +11 -9
  72. package/src/sidecar/list-limit.js +50 -0
  73. package/src/sidecar/list-search.js +69 -0
  74. package/src/sidecar/read.js +90 -5
  75. package/src/sidecar/start-metadata.js +58 -0
  76. package/src/sidecar/start.js +8 -43
  77. package/src/sidecar/workspace-auto-open.js +2 -2
  78. package/src/spend-query.js +2 -1
  79. package/src/template/apply.js +7 -4
  80. package/src/template/render.js +6 -2
  81. package/src/template/store.js +1 -1
  82. package/src/utils/alias-audit.js +19 -0
  83. package/src/utils/cli-preflight.js +27 -1
  84. package/src/utils/config.js +15 -0
  85. package/src/utils/curated-models.js +43 -7
  86. package/src/utils/gateway-route-audit.js +16 -3
  87. package/src/utils/model-fetcher.js +8 -6
  88. package/src/utils/remediation-hints.js +14 -0
  89. package/src/utils/result-schema-rebuild.js +1 -0
  90. package/src/utils/result-schema.js +6 -1
  91. package/src/utils/session-index-tmp-sweep.js +18 -3
  92. package/src/utils/session-index.js +1 -0
  93. package/src/utils/session-metadata-tmp-sweep.js +156 -0
  94. package/src/utils/spend-ledger.js +11 -4
  95. package/src/utils/validators.js +16 -0
package/src/mcp-tools.js CHANGED
@@ -121,6 +121,7 @@ function getTools() {
121
121
  'left: flush against the left edge. center: centered.'
122
122
  ),
123
123
  pack: z.string().optional().describe('Policy pack name or path — bench/chair/options/template defaults for this run; explicit params override pack values (recorded either way).'),
124
+ tag: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, '1-64 chars, letters/digits/_/- only').optional().describe('Label this session for list/search/spend grouping'),
124
125
  project: z.string().optional().describe(
125
126
  'Optional project directory path. Auto-detected from working directory if omitted.'
126
127
  ),
@@ -211,10 +212,14 @@ function getTools() {
211
212
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
212
213
  description:
213
214
  'List all Amicus sessions for the current project. Shows task ID, ' +
214
- 'model, status, age, and briefing excerpt.',
215
+ 'model, status, tag, createdAt, and briefing excerpt. Use `search` to ' +
216
+ 'filter by id/tag/briefing substring.',
215
217
  inputSchema: {
216
- status: z.enum(['all', 'running', 'complete']).optional().describe(
217
- 'Filter by status. Default: show all.'
218
+ status: z.string().optional().describe(
219
+ "Filter by status ('all', 'running', 'complete', 'error', 'aborted', …). Default: show all."
220
+ ),
221
+ search: z.string().optional().describe(
222
+ 'Case-insensitive substring filter over id, tag, and briefing material (F8 D15).'
218
223
  ),
219
224
  project: z.string().optional().describe(
220
225
  'Optional project directory path. Auto-detected from working directory if omitted.'
@@ -336,7 +341,7 @@ function getTools() {
336
341
  'Routing preference: auto (direct-first, default), direct (require a ' +
337
342
  'direct provider key), or openrouter (force OpenRouter).'
338
343
  ),
339
- prompt: z.string().describe(
344
+ prompt: z.string().min(1, 'prompt must not be empty').describe(
340
345
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
341
346
  ),
342
347
  agent: z.enum(['Plan', 'Build']).optional().describe(
@@ -345,7 +350,7 @@ function getTools() {
345
350
  thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).optional().describe(
346
351
  'Reasoning effort for every leg. Default: medium.'
347
352
  ),
348
- timeout: z.number().optional().describe(
353
+ timeout: z.number().positive('timeout must be a positive number of minutes').optional().describe(
349
354
  'Per-leg timeout in minutes (wall-clock ≈ slowest leg). Default: 15.'
350
355
  ),
351
356
  summaryLength: z.enum(['brief', 'normal', 'verbose']).optional().describe(
@@ -369,6 +374,7 @@ function getTools() {
369
374
  'commands are NOT accepted over MCP.'
370
375
  ),
371
376
  pack: z.string().optional().describe('Policy pack name or path — bench/chair/options/template defaults for this run; explicit params override pack values (recorded either way).'),
377
+ tag: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, '1-64 chars, letters/digits/_/- only').optional().describe('Label this session for list/search/spend grouping'),
372
378
  project: z.string().optional().describe(
373
379
  'Optional project directory path. Auto-detected from working directory if omitted.'
374
380
  ),
@@ -509,6 +515,7 @@ function getTools() {
509
515
  'commands are NOT accepted over MCP.'
510
516
  ),
511
517
  pack: z.string().optional().describe('Policy pack name or path — bench/chair/options/template defaults for this run; explicit params override pack values (recorded either way).'),
518
+ tag: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, '1-64 chars, letters/digits/_/- only').optional().describe('Label this session for list/search/spend grouping'),
512
519
  ui: z.boolean().optional().describe(
513
520
  'Auto-open the Council Workspace window on this run. Default: opens when the client is ' +
514
521
  'Claude Code (local), Electron is installed, a display exists, and config workspace.autoOpen is ' +
@@ -30,7 +30,7 @@ function applyPackOrExit(args, expectedKind, useJson) {
30
30
  if (args.pack === undefined) { return null; }
31
31
  const explicit = args.__explicit || new Set();
32
32
  const pr = applyPackToArgs({ packRef: args.pack, expectedKind, args, explicit, useJson });
33
- if (pr.error) { process.exit(failJson(useJson, pr.error)); }
33
+ if (pr.error) { return process.exit(failJson(useJson, pr.error)); }
34
34
  for (const n of pr.notices) { process.stderr.write(n + '\n'); }
35
35
  return pr.packRecord;
36
36
  }
@@ -36,10 +36,18 @@
36
36
  * render, so template provenance (`promptMeta.template`) is still
37
37
  * recorded exactly as Wave 1 built it.
38
38
  *
39
- * Callers that only need the pre-spend validation (the two spawn paths)
40
- * ignore the returned `renderedPrompt`; amicus_start's in-process path
41
- * (which never spawns a child, so nothing else would render it) reuses it
42
- * as the actual prompt instead of rendering a second time.
39
+ * `renderedPrompt` has two different consumers now (W1-M4, v4.7 PR7):
40
+ * amicus_start's in-process path (which never spawns a child, so nothing
41
+ * else would render it) reuses it as the actual prompt instead of rendering
42
+ * a second time; amicus_fanout's spawn path writes it to the wave's
43
+ * on-disk `briefing.md` — the `--search` corpus (src/sidecar/list-search.js)
44
+ * — instead of the raw prompt, so a wave whose spawned child aborts before
45
+ * its own render (src/sidecar/fanout.js) stays findable by the text the
46
+ * user actually sees. The spawned child itself still gets the RAW prompt
47
+ * (via a sibling `briefing-input.md`), so its own later render remains the
48
+ * provenance source for `promptMeta.template`. amicus_start's
49
+ * spawn-fallback path is the one remaining caller that still only needs the
50
+ * pre-spend validation and ignores `renderedPrompt`.
43
51
  */
44
52
 
45
53
  const { ERROR_CODES } = require('../utils/error-doc');
@@ -54,6 +54,9 @@ function resolveBenchKnob(pack, args, explicit) {
54
54
  else if (Array.isArray(bench)) { args.models = bench.join(','); }
55
55
  return null;
56
56
  }
57
+ // Both-typed names --models here, but every consuming surface — fanout (cli-handlers-fanout.js:77),
58
+ // council run (cli-council-run-bench.js:45), MCP (mcp-council-bench.js:29) — rejects
59
+ // --models+--council with BAD_ARGS after pack apply, so a both-typed notice is never actionable.
57
60
  const flag = modelsExplicit ? '--models' : '--council';
58
61
  return `Notice: ${flag} overrides the bench from pack '${pack.name}'`;
59
62
  }
@@ -52,7 +52,21 @@ function resolvePackRef(ref) {
52
52
 
53
53
  function stripBom(text) { return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; }
54
54
 
55
- /** @returns {{pack, path, source:'dir'|'path', hash}|{error}} */
55
+ /**
56
+ * @returns {{pack, path, source:'dir'|'path', hash}|{error}}
57
+ * All SIX `{error}` returns below — malformed name, name-form unreadable,
58
+ * path-form unreadable, invalid JSON, non-object body, name/filename mismatch —
59
+ * are mapped to PACK_NOT_FOUND by both callers (pack-resolve.js:76,
60
+ * cli-handlers-pack.js:225). A new `{error}` added here inherits that code
61
+ * silently: re-code it deliberately or it lands as "not found".
62
+ * PACK_NOT_FOUND has a THIRD emit site that never calls readPack — `pack rm`
63
+ * (cli-handlers-pack.js:242, via rmPack) — unified there by the v4.5 HOLD-gate
64
+ * decision 3 and pinned by the "rm nonexistent pack -> PACK_NOT_FOUND" describe
65
+ * in tests/pack/cli-pack-cmd.test.js.
66
+ * PACK_INVALID is NOT readPack's: it belongs to validatePack
67
+ * (pack-resolve.js:94, cli-handlers-pack.js:199) and to prepareForward's
68
+ * maxCost guard (pack-forward.js:68-76), which is not a validatePack call.
69
+ */
56
70
  function readPack(ref) {
57
71
  const r = resolvePackRef(ref);
58
72
  if (r.error) { return r; }
@@ -66,8 +80,11 @@ function readPack(ref) {
66
80
  let pack;
67
81
  try { pack = JSON.parse(raw); }
68
82
  catch (err) { return { error: `Error: pack ${file} is not valid JSON: ${err.message}` }; }
69
- if (r.kind === 'name' && pack && pack.name !== r.name) {
70
- return { error: `Error: pack file ${file} declares name '${pack && pack.name}' which does not match its filename rename one of them` };
83
+ if (pack === null || typeof pack !== 'object' || Array.isArray(pack)) {
84
+ return { error: `Error: pack ${file} is not a pack object (found ${pack === null ? 'null' : Array.isArray(pack) ? 'an array' : typeof pack})` };
85
+ }
86
+ if (r.kind === 'name' && pack.name !== r.name) {
87
+ return { error: `Error: pack file ${file} declares name '${pack.name}' which does not match its filename — rename one of them` };
71
88
  }
72
89
  return { pack, path: file, source: r.kind === 'name' ? 'dir' : 'path', hash: canonicalHash(pack) };
73
90
  }
@@ -74,6 +74,11 @@ function validatePack(pack, { mode } = { mode: 'run' }) {
74
74
  if (typeof pack.model !== 'string' || !pack.model.trim()) { errors.push('solo pack requires model'); }
75
75
  else if (!seatOk(pack.model)) { errors.push(`unresolvable model '${pack.model}'`); }
76
76
  } else {
77
+ // T11-d: bench-independent — a by-name bench used to skip this entirely, so a pack
78
+ // could carry both and only fail (mis-attributed) at handler time.
79
+ if (pack.kind === 'council' && pack.critic && pack.lenses) {
80
+ errors.push('critic and lenses are mutually exclusive');
81
+ }
77
82
  const bench = pack.bench;
78
83
  if (typeof bench === 'string') {
79
84
  const { members } = getCouncilWithSource(bench, []);
@@ -87,7 +92,6 @@ function validatePack(pack, { mode } = { mode: 'run' }) {
87
92
  if (pack.kind === 'council') {
88
93
  if (pack.chair && bench.includes(pack.chair)) { errors.push(`chair '${pack.chair}' is a bench seat — the chair must not review`); }
89
94
  if (pack.critic && !bench.includes(pack.critic)) { errors.push(`critic '${pack.critic}' must be one of the bench seats`); }
90
- if (pack.critic && pack.lenses) { errors.push('critic and lenses are mutually exclusive'); }
91
95
  if (Array.isArray(pack.lenses) && pack.lenses.length !== bench.length) {
92
96
  errors.push(`lenses needs exactly one lens per seat (${bench.length} seats, got ${pack.lenses.length})`);
93
97
  }
@@ -22,6 +22,9 @@ const SESSION_STATUS = {
22
22
  /** Canonical session dir name — new sessions are written here. */
23
23
  const SESSIONS_DIR = 'amicus_sessions';
24
24
 
25
+ /** Subagent sessions nest one level under their parent taskId dir. */
26
+ const SUBAGENTS_DIR = 'subagents';
27
+
25
28
  /**
26
29
  * Get the canonical session directory path for a task (used for WRITES).
27
30
  * Spec Reference: §8.1 Session directory structure
@@ -256,7 +259,7 @@ function saveSummary(projectDir, taskId, summary) {
256
259
  * // Returns: '/path/to/project/.claude/amicus_sessions/abc123/subagents/subagent-xyz'
257
260
  */
258
261
  function getSubagentDir(projectDir, parentTaskId, subagentId) {
259
- return path.join(getSessionDir(projectDir, parentTaskId), 'subagents', subagentId);
262
+ return path.join(getSessionDir(projectDir, parentTaskId), SUBAGENTS_DIR, subagentId);
260
263
  }
261
264
 
262
265
  /**
@@ -355,7 +358,7 @@ function getSubagentSession(projectDir, parentTaskId, subagentId) {
355
358
  * @returns {object[]} Array of sub-agent metadata
356
359
  */
357
360
  function listSubagents(projectDir, parentTaskId, filter = {}) {
358
- const subagentsDir = path.join(getSessionDir(projectDir, parentTaskId), 'subagents');
361
+ const subagentsDir = path.join(getSessionDir(projectDir, parentTaskId), SUBAGENTS_DIR);
359
362
 
360
363
  if (!fs.existsSync(subagentsDir)) {
361
364
  return [];
@@ -412,6 +415,7 @@ module.exports = {
412
415
  getSessionDir,
413
416
  resolveExistingSessionDir,
414
417
  SESSIONS_DIR,
418
+ SUBAGENTS_DIR,
415
419
  SESSION_STATUS,
416
420
  // Sub-agent functions
417
421
  getSubagentDir,
@@ -63,20 +63,54 @@ function checkBudget(legs, opts = {}) {
63
63
  return { ok, offending, overCeiling, breakdown: { legs: breakdownLegs, totalEstCost, unpricedCount, maxCostPerMtok: cap, maxCost: opts.maxCost || null } };
64
64
  }
65
65
 
66
- /** Human-readable refusal text (also used as the error envelope `hint`). */
67
- function formatBudgetError(result) {
66
+ /**
67
+ * Human-readable refusal text (also used as the error envelope `hint`).
68
+ * @param {object} result checkBudget's return value
69
+ * @param {{kind:'cli'|'mcp'}} [surface] where the text will be read. Remedies are
70
+ * surface-specific: the MCP tool surface has no --flags, and (since v4.7 PR6's
71
+ * gate hoist) no per-call override at all. Defaults to 'cli' so the two CLI
72
+ * callers stay byte-identical.
73
+ */
74
+ function formatBudgetError(result, surface = { kind: 'cli' }) {
68
75
  const lines = [];
76
+ const isMcp = surface && surface.kind === 'mcp';
69
77
  if (result.offending.length > 0) {
70
78
  lines.push('Budget gate: model(s) over the per-$/Mtok threshold:');
71
79
  for (const o of result.offending) { lines.push(` - ${o.modelInput} (${o.model}): ${o.reason}`); }
72
80
  }
73
81
  if (result.overCeiling) {
74
- lines.push(`Budget gate: estimated total $${result.breakdown.totalEstCost.toFixed(4)} exceeds --max-cost $${result.breakdown.maxCost.toFixed(4)} (estimate, not guaranteed).`);
82
+ // On MCP the ceiling can come from EITHER a pack's `maxCost` option or the
83
+ // config, and the pack wins (mcp-server.js's fwd.maxCost ?? cfg.maxCost).
84
+ // Naming only one of them would send the caller to edit the loser — the very
85
+ // "remedy that cannot work" class this surface split exists to end.
86
+ lines.push(`Budget gate: estimated total $${result.breakdown.totalEstCost.toFixed(4)} exceeds ${isMcp ? 'the effective maxCost' : '--max-cost'} $${result.breakdown.maxCost.toFixed(4)} (estimate, not guaranteed).`);
75
87
  }
76
88
  if (result.breakdown.unpricedCount > 0) {
77
89
  lines.push(`(${result.breakdown.unpricedCount} unpriced leg(s) — direct provider; cost unknown, not included in the estimate.)`);
78
90
  }
79
- lines.push('Override: --max-cost <$> to raise the ceiling, or --no-cost-gate to disable both guards (e.g. an intentional o3 run).');
91
+ // offending and overCeiling are independent (unrelated inputs see module
92
+ // header), and either or both may have fired since formatBudgetError is only
93
+ // called when !ok. Only name a remedy that actually clears every branch that
94
+ // fired; when both fired, raising just one lever will not clear the run.
95
+ const hasOffending = result.offending.length > 0;
96
+ const hasCeiling = result.overCeiling;
97
+ if (isMcp) {
98
+ if (hasOffending && hasCeiling) {
99
+ lines.push("Override: raise maxCostPerMtok in the amicus config AND the effective maxCost (the pack's `maxCost` option if this run used a pack, otherwise the config's), or choose a cheaper model — raising just one will not clear this run.");
100
+ } else if (hasOffending) {
101
+ lines.push('Override: raise maxCostPerMtok in the amicus config, or choose a cheaper model.');
102
+ } else {
103
+ lines.push("Override: raise the effective maxCost — the pack's `maxCost` option if this run used a pack, otherwise the config's — or choose a cheaper model.");
104
+ }
105
+ } else {
106
+ if (hasOffending && hasCeiling) {
107
+ lines.push('Override: --no-cost-gate to disable both guards (e.g. an intentional o3 run) — raising just one of maxCostPerMtok or --max-cost will not clear this run.');
108
+ } else if (hasOffending) {
109
+ lines.push('Override: --no-cost-gate to disable both guards (e.g. an intentional o3 run), or raise maxCostPerMtok in config.');
110
+ } else {
111
+ lines.push('Override: --max-cost <$> to raise the ceiling, or --no-cost-gate to disable both guards (e.g. an intentional o3 run).');
112
+ }
113
+ }
80
114
  return lines.join('\n');
81
115
  }
82
116
 
@@ -61,8 +61,7 @@ function preflightBudget(okLegs, options) {
61
61
  message: 'Error: budget gate refused the wave',
62
62
  hint: `Budget gate: estimated total $${estimate.toFixed(4)} does not fit the --max-cost `
63
63
  + 'allowance still unclaimed by concurrently launching waves (estimate, not guaranteed).\n'
64
- + 'The run continues with the waves that did launch. Override: --max-cost <$> to raise the '
65
- + 'ceiling, or --no-cost-gate to disable both guards.',
64
+ + 'The run continues with the waves that did launch.',
66
65
  };
67
66
  }
68
67
  return { ok: true, estimate };
@@ -18,9 +18,12 @@ const { deriveChain } = require('./fallback-chains');
18
18
 
19
19
  /**
20
20
  * Append ONE attributed ledger row for a single attempt (spec 6.2/7.1). At
21
- * `attempt:0` this is byte-identical to today's pre-fallback appendSpend row
22
- * (same fields, sourced from `leg.attempt`/`leg.substitutedFor`/
23
- * `leg.retryOfWaveId`, all undefined -> omitted for a plain leg). A
21
+ * `attempt:0` this row's LINKAGE fields (attempt/substitutedFor/retryOfWaveId,
22
+ * all undefined -> omitted for a plain leg) are byte-identical to today's
23
+ * pre-fallback appendSpend row. v4.7 F8 D16 adds `tag` a NULLABLE dim, not a
24
+ * linkage field, so every row now carries it (null-default when the leg was
25
+ * never tagged) rather than omitting it; the byte-identity claim above applies
26
+ * only to the linkage-field convention, not to the full row shape. A
24
27
  * substitution (`attempt` param > 0, the LOOP's attempt index) overrides
25
28
  * `row.attempt`/`row.substitutedFor` with the substitution's own values.
26
29
  * Best-effort — never throws, never affects the leg. `deps.spendDir` (tests)
@@ -42,6 +45,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
42
45
  councilRunId: leg && leg.councilRunId, councilName: leg && leg.councilName,
43
46
  project, attempt: leg && leg.attempt, substitutedFor: leg && leg.substitutedFor,
44
47
  retryOfWaveId: leg && leg.retryOfWaveId,
48
+ tag: (leg && leg.tag) || null,
45
49
  };
46
50
  if (attempt > 0) { row.attempt = attempt; row.substitutedFor = originalModel; }
47
51
  appendSpend(row, deps.spendDir ? { dir: deps.spendDir } : undefined);
@@ -72,4 +72,16 @@ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedA
72
72
  return { wave, exitCode };
73
73
  }
74
74
 
75
- module.exports = { writeWaveMetadata, writeWaveDoc, finishWave };
75
+ /**
76
+ * v4.3 §7.2 (moved here v4.7 PR3 Task 1): stamp council attribution onto every
77
+ * leg — fanout-leg's appendSpend reads it; no-op for every non-council caller.
78
+ * v4.7 F8 (Task 7) adds tag stamping in the same pass.
79
+ */
80
+ function stampLegAttribution(legs, options) {
81
+ if (options.councilRunId || options.councilName) {
82
+ legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
83
+ }
84
+ if (options.tag) { legs.forEach(l => { l.tag = options.tag; }); }
85
+ }
86
+
87
+ module.exports = { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution };
@@ -19,7 +19,7 @@ const { ERROR_CODES } = require('../utils/error-doc');
19
19
  // Wave-document persistence lives in ./fanout-wave-io (size-gate split, v4.4.1
20
20
  // Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
21
21
  // fanout tests import it from here.
22
- const { writeWaveMetadata, writeWaveDoc, finishWave } = require('./fanout-wave-io');
22
+ const { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution } = require('./fanout-wave-io');
23
23
 
24
24
  /**
25
25
  * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
@@ -86,7 +86,11 @@ async function runFanout(options) {
86
86
  // `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
87
87
  // waveDir is optional (only the post-creation caller has one).
88
88
  const errorWave = (waveId, message, waveDir) => {
89
- const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack, createdAt, completedAt: new Date().toISOString(), status: 'error' });
89
+ // v4.7 F8 (D13, T3 review): tag: options.tag || metaTag, TDZ-safe (sole call site runs after `const metaTag` below).
90
+ // v4.7 PR3 rider: pack got the same `|| metaPack` inherit the other two
91
+ // buildWaveResult sites have — it was the lone holdout, so an MCP-spawned
92
+ // wave whose server died dropped its pre-seeded pack while keeping its tag.
93
+ const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt: new Date().toISOString(), status: 'error' });
90
94
  doc.error = message;
91
95
  doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
92
96
  // best-effort: an unwritable wave dir must not mask the real error
@@ -119,11 +123,7 @@ async function runFanout(options) {
119
123
  });
120
124
  if (validated.error) { return failPre(validated.code || 'BAD_ARGS', validated.error); }
121
125
  const legs = validated.legs;
122
- // v4.3 §7.2: stamp council attribution onto every leg (fanout-leg.js's
123
- // existing appendSpend reads it); no-op for every non-council caller.
124
- if (options.councilRunId || options.councilName) {
125
- legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
126
- }
126
+ stampLegAttribution(legs, options);
127
127
  const okLegs = legs.filter(l => l.ok);
128
128
  // FIX 2 (#61 whole-branch review): a leg's migration notice has no CLI
129
129
  // stderr to land on — surface it on the wave doc instead, deduped in case
@@ -149,6 +149,7 @@ async function runFanout(options) {
149
149
  briefing: String(options.prompt).slice(0, 200),
150
150
  promptMeta: options.promptMeta || null,
151
151
  ...(options.pack ? { pack: options.pack } : {}), // v4.5 Task 13: absent-not-null.
152
+ ...(options.tag ? { tag: options.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
152
153
  pid: process.pid, project, createdAt,
153
154
  });
154
155
  // v4.5 final-review F2: an MCP-spawned child never gets --pack (single-
@@ -161,6 +162,7 @@ async function runFanout(options) {
161
162
  // result-schema-rebuild.js:93, which reads meta.pack off a metadata.json
162
163
  // it loaded for an unrelated reason).
163
164
  const metaPack = waveMeta.pack;
165
+ const metaTag = waveMeta.tag; // v4.7 F8 (D13): same pre-seed inherit mechanism as metaPack above.
164
166
  emitWaveStarted(waveDir, waveId, legs.map(l => (l.ok ? l.model : l.modelInput)), legIds, follow);
165
167
 
166
168
  // 2b. All legs failed to route (#61 perf): no leg will ever touch the
@@ -171,7 +173,7 @@ async function runFanout(options) {
171
173
  const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
172
174
  const completedAt = new Date().toISOString();
173
175
  const wave = buildWaveResult({
174
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt, notices,
176
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt, notices,
175
177
  });
176
178
  return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
177
179
  exitCode: waveExitCode(wave.status),
@@ -282,7 +284,7 @@ async function runFanout(options) {
282
284
  const completedAt = new Date().toISOString();
283
285
  const signalled = waveAbort.signal();
284
286
  const wave = buildWaveResult({
285
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt,
287
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt,
286
288
  status: signalled ? 'aborted' : null, notices,
287
289
  });
288
290
  const exitCode = signalled
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @module sidecar/list-limit
3
+ * The `--limit` core behind `amicus list` (v4.7 PR3 rider).
4
+ * Split out of read.js on the same relief-extraction rule that produced
5
+ * list-search.js (T6 review) — read.js was at 293/300 with this inline.
6
+ * Re-exported from src/sidecar/read.js; nothing outside it needs this file.
7
+ *
8
+ * WHY --limit EXISTS. D14 made `--all` real (cross-project via the advisory
9
+ * sessions-index) with no output cap. Measured on the dev machine at merge
10
+ * time: 21,145 rows in 8.3s. An unbounded dump is not a listing.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ /**
16
+ * Normalize `--limit` into a row cap.
17
+ *
18
+ * Absent → 0 (unlimited), which keeps an un-limited `list` byte-identical to
19
+ * its pre-rider output. `0` is also the explicit "no cap" spelling, so a
20
+ * scripted caller can opt out without omitting the flag.
21
+ *
22
+ * @param {number|string|boolean|undefined|null} limit
23
+ * @returns {number} 0 for unlimited, else a positive integer
24
+ * @throws {Error} on a valueless, non-integer, or negative value — silently
25
+ * listing everything after a typo'd `--limit` is exactly the
26
+ * accepted-but-ignored failure src/utils/known-flags.js exists to prevent.
27
+ */
28
+ function normalizeLimit(limit) {
29
+ // parseArgs sets `true` for a valueless flag; mirrors models.js:279-281.
30
+ if (limit === true) { throw new Error('--limit requires a value'); }
31
+ if (limit === undefined || limit === null || limit === '') { return 0; }
32
+ const n = Number(limit);
33
+ if (!Number.isInteger(n) || n < 0) {
34
+ throw new Error(`--limit must be a non-negative integer (got "${limit}"); 0 means unlimited`);
35
+ }
36
+ return n;
37
+ }
38
+
39
+ /**
40
+ * The no-silent-caps notice. Names the real total and how to see it all.
41
+ *
42
+ * @param {number} cap the applied limit
43
+ * @param {number} total row count BEFORE slicing
44
+ * @returns {string}
45
+ */
46
+ function truncationNotice(cap, total) {
47
+ return `Showing ${cap} of ${total} sessions (--limit ${cap}). Use --limit 0 for all.`;
48
+ }
49
+
50
+ module.exports = { normalizeLimit, truncationNotice };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @module sidecar/list-search
3
+ * The `--search` core behind both list surfaces (F8 D15, errata E-PR3-5).
4
+ * Split out of read.js (T6 review) to keep that file under its line budget.
5
+ * Re-exported from src/sidecar/read.js, which is what both consumers
6
+ * (mcp-server.js's amicus_list, the CLI's listSidecars) and the tests already
7
+ * require — nothing outside read.js needs to know this file exists.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { safeSessionDir } = require('../utils/validators');
13
+
14
+ const COUNCIL_MATERIAL_SEPARATOR = '--- MATERIAL / BRIEFING ---';
15
+
16
+ // Council row search material (MCP-only): runDir/briefing.md when readable
17
+ // (MCP-launched runs write one before the engine spawns), else the portion of
18
+ // briefing-stage1.md after the separator (CLI-launched runs have only this),
19
+ // else null. Re-derives runDir from the pointer and re-fences it with
20
+ // containsOnDisk exactly as mcp-council-awareness.js:214 — never a bare join.
21
+ function councilSearchMaterial(project, runId) {
22
+ if (!project) { return null; }
23
+ const { readPointer } = require('../council/run-state');
24
+ const { containsOnDisk } = require('../utils/path-fence');
25
+ const ptr = readPointer(project, runId);
26
+ if (!ptr || !containsOnDisk(project, ptr.runDir)) { return null; }
27
+ try { return fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); } catch { /* try stage1 */ }
28
+ try {
29
+ const s1 = fs.readFileSync(path.join(ptr.runDir, 'briefing-stage1.md'), 'utf-8');
30
+ const idx = s1.indexOf(COUNCIL_MATERIAL_SEPARATOR);
31
+ return idx === -1 ? null : s1.slice(idx + COUNCIL_MATERIAL_SEPARATOR.length);
32
+ } catch { return null; }
33
+ }
34
+
35
+ // Wave material: waveDir/briefing.md (full prompt) when readable, else the
36
+ // 200-char excerpt already on the row (fanout.js:146). safeSessionDir (not a
37
+ // bare path.join) so this gets the same traversal guard every other session
38
+ // path read in the codebase gets.
39
+ function waveSearchMaterial(project, waveId, excerpt) {
40
+ try { return fs.readFileSync(path.join(safeSessionDir(project, waveId), 'briefing.md'), 'utf-8'); }
41
+ catch { return String(excerpt || ''); }
42
+ }
43
+
44
+ // LEG rows (parentWave set) are id/tag ONLY — briefing embeds the parent's material; matching it would hit every leg N+1 times per wave.
45
+ function rowMatchesSearch(row, needle, project) {
46
+ if (String(row.id || '').toLowerCase().includes(needle)) { return true; }
47
+ if (String(row.tag || '').toLowerCase().includes(needle)) { return true; }
48
+ if (row.parentWave) { return false; }
49
+ // --all rows are stamped with their OWN project (enumerateAllProjects,
50
+ // read.js) — a cross-project wave/council row's material lives under THAT
51
+ // project, not the caller's cwd. Using the wrong one doesn't throw; it
52
+ // silently falls back to the low-fidelity excerpt/id-tag match instead of
53
+ // the full text, which is worse than a crash because nothing signals it.
54
+ const proj = row.project || project;
55
+ const material = row.type === 'council-run' ? councilSearchMaterial(proj, row.id)
56
+ : row.type === 'wave' ? waveSearchMaterial(proj, row.id, row.briefing) : row.briefing;
57
+ return String(material || '').toLowerCase().includes(needle);
58
+ }
59
+
60
+ // Case-insensitive substring filter behind both list surfaces (CLI `amicus list
61
+ // --search`, MCP `amicus_list {search}` — F8 D15, errata E-PR3-5), mirroring
62
+ // the models.js:70-73 idiom. Missing material never throws — it degrades to
63
+ // an id/tag-only match (read.js:58 idiom).
64
+ function searchSessions(rows, q, ctx) {
65
+ const needle = String(q).toLowerCase();
66
+ return rows.filter(row => rowMatchesSearch(row, needle, ctx && ctx.project));
67
+ }
68
+
69
+ module.exports = { searchSessions };