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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +349 -0
- package/README.md +24 -13
- package/bin/amicus.js +31 -0
- package/docs/ROADMAP.md +172 -36
- package/docs/configuration.md +56 -6
- package/docs/council.md +63 -10
- package/docs/doc-system.md +8 -7
- package/docs/schemas.md +10 -1
- package/docs/troubleshooting.md +27 -1
- package/docs/usage.md +68 -15
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +132 -21
- package/electron/workspace-ui/workspace-app.js +20 -4
- package/electron/workspace-ui/workspace-lazy.js +233 -0
- package/electron/workspace-ui/workspace-matrix.js +12 -1
- package/electron/workspace-ui/workspace-panels.js +24 -171
- package/electron/workspace-ui/workspace-render.js +15 -5
- package/electron/workspace-ui/workspace-seats.js +88 -5
- package/electron/workspace-ui/workspace-verbs.js +1 -1
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +5 -2
- package/schemas/council-run.schema.json +1 -0
- package/schemas/council-stats.schema.json +9 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
- package/skills/second-opinion/MODEL-NOTES.md +5 -4
- package/skills/sidecar/SKILL.md +7 -2
- package/src/cli-council-run-bench.js +86 -0
- package/src/cli-handlers-council-run.js +65 -81
- package/src/cli-handlers-council.js +24 -3
- package/src/cli-handlers-doctor.js +9 -3
- package/src/cli-handlers-fanout.js +179 -0
- package/src/cli-handlers-pack.js +24 -10
- package/src/cli-handlers-run.js +19 -161
- package/src/cli-template-args.js +48 -0
- package/src/cli.js +39 -46
- package/src/council/debate.js +89 -10
- package/src/council/ledger.js +72 -11
- package/src/council/presets-cli.js +6 -2
- package/src/council/report.js +17 -6
- package/src/council/run-assemble.js +15 -3
- package/src/council/run-budget.js +2 -2
- package/src/council/run-chair.js +70 -11
- package/src/council/run-debate.js +51 -67
- package/src/council/run-launch.js +9 -2
- package/src/council/run-retry.js +4 -1
- package/src/council/run-stage1-launch.js +94 -0
- package/src/council/run-stage2.js +25 -4
- package/src/council/run-stages.js +79 -86
- package/src/council/run-state.js +10 -2
- package/src/council/run.js +26 -2
- package/src/council/tally.js +6 -2
- package/src/mcp-council-awareness.js +1 -0
- package/src/mcp-council-bench.js +4 -0
- package/src/mcp-council-run.js +10 -0
- package/src/mcp-server.js +114 -54
- package/src/mcp-tools.js +12 -5
- package/src/pack/pack-cli.js +1 -1
- package/src/pack/pack-forward.js +12 -4
- package/src/pack/pack-resolve.js +3 -0
- package/src/pack/pack-store.js +20 -3
- package/src/pack/pack-validate.js +5 -1
- package/src/session-manager.js +6 -2
- package/src/sidecar/budget.js +38 -4
- package/src/sidecar/fanout-budget.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +7 -3
- package/src/sidecar/fanout-wave-io.js +13 -1
- package/src/sidecar/fanout.js +11 -9
- package/src/sidecar/list-limit.js +50 -0
- package/src/sidecar/list-search.js +69 -0
- package/src/sidecar/read.js +90 -5
- package/src/sidecar/start-metadata.js +58 -0
- package/src/sidecar/start.js +8 -43
- package/src/sidecar/workspace-auto-open.js +2 -2
- package/src/spend-query.js +2 -1
- package/src/template/apply.js +7 -4
- package/src/template/render.js +6 -2
- package/src/template/store.js +1 -1
- package/src/utils/alias-audit.js +19 -0
- package/src/utils/cli-preflight.js +27 -1
- package/src/utils/config.js +15 -0
- package/src/utils/curated-models.js +43 -7
- package/src/utils/gateway-route-audit.js +16 -3
- package/src/utils/model-fetcher.js +8 -6
- package/src/utils/remediation-hints.js +14 -0
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +6 -1
- package/src/utils/session-index-tmp-sweep.js +18 -3
- package/src/utils/session-index.js +1 -0
- package/src/utils/session-metadata-tmp-sweep.js +156 -0
- package/src/utils/spend-ledger.js +11 -4
- 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,
|
|
215
|
+
'model, status, tag, createdAt, and briefing excerpt. Use `search` to ' +
|
|
216
|
+
'filter by id/tag/briefing substring.',
|
|
215
217
|
inputSchema: {
|
|
216
|
-
status: z.
|
|
217
|
-
|
|
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 ' +
|
package/src/pack/pack-cli.js
CHANGED
|
@@ -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
|
}
|
package/src/pack/pack-forward.js
CHANGED
|
@@ -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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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');
|
package/src/pack/pack-resolve.js
CHANGED
|
@@ -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
|
}
|
package/src/pack/pack-store.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 (
|
|
70
|
-
return { error: `Error: pack
|
|
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
|
}
|
package/src/session-manager.js
CHANGED
|
@@ -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),
|
|
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),
|
|
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,
|
package/src/sidecar/budget.js
CHANGED
|
@@ -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
|
-
/**
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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 };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|