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/sidecar/read.js
CHANGED
|
@@ -10,6 +10,11 @@ const path = require('path');
|
|
|
10
10
|
const { safeSessionDir, TASK_ID_PATTERN } = require('../utils/validators');
|
|
11
11
|
const { SESSIONS_DIR } = require('../session-manager');
|
|
12
12
|
const { fenceSidecarOutput } = require('../utils/untrusted-fence');
|
|
13
|
+
// F8 D15 (errata E-PR3-5) search core — split out (T6 review) to keep this
|
|
14
|
+
// file under its line budget; both list surfaces still reach it through
|
|
15
|
+
// this module's own searchSessions re-export below.
|
|
16
|
+
const { searchSessions } = require('./list-search');
|
|
17
|
+
const { normalizeLimit, truncationNotice } = require('./list-limit');
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* Format a timestamp as relative age
|
|
@@ -54,6 +59,8 @@ function enumerateSessions(project, opts = {}) {
|
|
|
54
59
|
type: meta.type || 'run',
|
|
55
60
|
parentWave: meta.parentWave || null,
|
|
56
61
|
legCount: Array.isArray(meta.legs) ? meta.legs.length : null,
|
|
62
|
+
mode: meta.mode || (meta.headless ? 'headless' : 'interactive'),
|
|
63
|
+
...(meta.tag ? { tag: meta.tag } : {}),
|
|
57
64
|
});
|
|
58
65
|
} catch { /* skip unreadable */ }
|
|
59
66
|
}
|
|
@@ -67,29 +74,96 @@ function enumerateSessions(project, opts = {}) {
|
|
|
67
74
|
return sessions;
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Enumerate sessions across every project the global sessions-index
|
|
79
|
+
* (src/utils/session-index.js) knows about, plus the current project. --all
|
|
80
|
+
* support (F8 D14): the index is a navigation aid, never authoritative, so a
|
|
81
|
+
* stale entry pointing at a missing/unreadable project directory is skipped
|
|
82
|
+
* silently rather than surfaced as an error.
|
|
83
|
+
*
|
|
84
|
+
* Dedup is by CANONICAL identity (T5 review fix-wave): the index stores
|
|
85
|
+
* canonical spellings (canonicalProjectPath — forward slashes, upper-cased
|
|
86
|
+
* drive letter) while `project` arrives however the caller spelled it
|
|
87
|
+
* (process.cwd() is backslashed on Windows). A raw string Set treated those
|
|
88
|
+
* as two different projects, double-counting every row of the current
|
|
89
|
+
* project whenever it was also present in the index (which it usually is —
|
|
90
|
+
* every session start records itself). The current project is seeded FIRST
|
|
91
|
+
* so its rows keep the caller's spelling rather than the index's.
|
|
92
|
+
* @param {{status?: string, project?: string}} [opts]
|
|
93
|
+
* @returns {Array<object>} enumerateSessions rows, each stamped with `project`
|
|
94
|
+
*/
|
|
95
|
+
function enumerateAllProjects(opts = {}) {
|
|
96
|
+
const { status, project = process.cwd() } = opts;
|
|
97
|
+
const { readIndex } = require('../utils/session-index');
|
|
98
|
+
const { canonicalProjectPath } = require('../utils/project-path');
|
|
99
|
+
const index = readIndex();
|
|
100
|
+
|
|
101
|
+
const byCanonical = new Map();
|
|
102
|
+
for (const p of [project, ...Object.values(index)]) {
|
|
103
|
+
if (!p || typeof p !== 'string') { continue; }
|
|
104
|
+
const key = canonicalProjectPath(p);
|
|
105
|
+
if (!byCanonical.has(key)) { byCanonical.set(key, p); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let sessions = [];
|
|
109
|
+
for (const p of byCanonical.values()) {
|
|
110
|
+
try {
|
|
111
|
+
const rows = enumerateSessions(p, {}).map(s => ({ ...s, project: p }));
|
|
112
|
+
sessions = sessions.concat(rows);
|
|
113
|
+
} catch { /* unreadable project — skip silently */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
117
|
+
if (status && status !== 'all') {
|
|
118
|
+
sessions = sessions.filter(s => s.status === status);
|
|
119
|
+
}
|
|
120
|
+
return sessions;
|
|
121
|
+
}
|
|
122
|
+
|
|
70
123
|
/**
|
|
71
124
|
* List previous sidecar sessions
|
|
72
125
|
* Spec Reference: §4.2
|
|
73
126
|
*
|
|
74
127
|
* @param {object} options
|
|
75
128
|
* @param {string} [options.status] - Filter by status (all, running, complete)
|
|
129
|
+
* @param {boolean} [options.all] - Cross-project via the global sessions-index (F8 D14)
|
|
76
130
|
* @param {boolean} [options.json] - Output as JSON
|
|
77
131
|
* @param {string} [options.project] - Project directory
|
|
132
|
+
* @param {string|boolean} [options.search] - id/tag/briefing substring filter (F8 D15); `true` = valueless flag (usage error)
|
|
133
|
+
* @param {number|string|boolean} [options.limit] - cap printed rows (PR3 rider); 0/absent = unlimited, `true` = valueless flag (usage error)
|
|
78
134
|
*/
|
|
79
135
|
async function listSidecars(options) {
|
|
80
|
-
const { status, json, project = process.cwd() } = options;
|
|
136
|
+
const { status, all, json, search, limit, project = process.cwd() } = options;
|
|
137
|
+
// Mirrors models.js:279-281's valueless-flag shape.
|
|
138
|
+
if (search === true) { throw new Error('--search requires a value'); }
|
|
139
|
+
const cap = normalizeLimit(limit); // throws on valueless/non-integer/negative
|
|
81
140
|
|
|
82
|
-
|
|
141
|
+
let sessions = all
|
|
142
|
+
? enumerateAllProjects({ status, project })
|
|
143
|
+
: enumerateSessions(project, { status });
|
|
144
|
+
if (search) { sessions = searchSessions(sessions, search, { project }); }
|
|
83
145
|
if (sessions.length === 0) {
|
|
84
146
|
console.log('No amicus sessions found.');
|
|
85
147
|
return;
|
|
86
148
|
}
|
|
87
149
|
|
|
150
|
+
// v4.7 PR3 rider: cap OUTPUT only — the newest-first sort has already run, so
|
|
151
|
+
// slicing here yields the N most recent. Enumeration is deliberately NOT
|
|
152
|
+
// capped: `--all` must still walk every project or the sort would rank rows
|
|
153
|
+
// it never saw.
|
|
154
|
+
const total = sessions.length;
|
|
155
|
+
const truncated = cap > 0 && total > cap;
|
|
156
|
+
if (truncated) { sessions = sessions.slice(0, cap); }
|
|
157
|
+
|
|
88
158
|
if (json) {
|
|
89
159
|
console.log(JSON.stringify(sessions, null, 2));
|
|
90
160
|
} else {
|
|
91
|
-
console.log(
|
|
92
|
-
|
|
161
|
+
console.log(
|
|
162
|
+
'ID'.padEnd(10) + 'MODEL'.padEnd(23) + 'STATUS'.padEnd(11) +
|
|
163
|
+
'TAG'.padEnd(12) + 'AGE'.padEnd(12) + 'BRIEFING' +
|
|
164
|
+
(all ? ' PROJECT' : '')
|
|
165
|
+
);
|
|
166
|
+
console.log('─'.repeat(all ? 100 : 80));
|
|
93
167
|
sessions.forEach(s => {
|
|
94
168
|
const age = formatAge(s.createdAt);
|
|
95
169
|
const briefingShort = (s.briefing || '').slice(0, 30) +
|
|
@@ -98,11 +172,20 @@ async function listSidecars(options) {
|
|
|
98
172
|
`${(s.id || '').padEnd(10)}` +
|
|
99
173
|
`${(s.type === 'wave' ? `wave(${s.legCount ?? 0} legs)` : (s.model || '')).padEnd(23)}` +
|
|
100
174
|
`${(s.status || 'unknown').padEnd(11)}` +
|
|
175
|
+
`${(s.tag || '').padEnd(12)}` +
|
|
101
176
|
`${age.padEnd(12)}` +
|
|
102
|
-
`${briefingShort}`
|
|
177
|
+
`${briefingShort}` +
|
|
178
|
+
(all ? ` ${s.project || ''}` : '')
|
|
103
179
|
);
|
|
104
180
|
});
|
|
105
181
|
}
|
|
182
|
+
|
|
183
|
+
// No silent caps: say what was elided, and how to see it all. In --json mode
|
|
184
|
+
// this goes to stderr so stdout stays a single parseable document.
|
|
185
|
+
if (truncated) {
|
|
186
|
+
const notice = truncationNotice(cap, total);
|
|
187
|
+
if (json) { console.error(notice); } else { console.log(notice); }
|
|
188
|
+
}
|
|
106
189
|
}
|
|
107
190
|
|
|
108
191
|
/**
|
|
@@ -184,6 +267,8 @@ async function readSidecar(options) {
|
|
|
184
267
|
module.exports = {
|
|
185
268
|
formatAge,
|
|
186
269
|
enumerateSessions,
|
|
270
|
+
enumerateAllProjects,
|
|
271
|
+
searchSessions,
|
|
187
272
|
listSidecars,
|
|
188
273
|
readSidecar
|
|
189
274
|
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/sidecar/start-metadata.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module start-metadata
|
|
6
|
+
* createSessionMetadata, split out of start.js to keep that file under the
|
|
7
|
+
* hard size gate (v4.7 PR3 Task 1: F8 relief). Pure move — same writes, same
|
|
8
|
+
* order, same atomicity. start.js re-exports this so no importer changes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
14
|
+
const { SessionPaths } = require('./session-utils');
|
|
15
|
+
|
|
16
|
+
/** Create session directory and save metadata */
|
|
17
|
+
function createSessionMetadata(taskId, project, options) {
|
|
18
|
+
const { model, prompt, briefing, noUi, headless, agent, thinking, pack, tag } = options;
|
|
19
|
+
|
|
20
|
+
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
21
|
+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
22
|
+
|
|
23
|
+
const effectiveBriefing = prompt || briefing;
|
|
24
|
+
const isHeadless = noUi !== undefined ? noUi : headless;
|
|
25
|
+
|
|
26
|
+
// Preserve fields from existing metadata (e.g., pid written by MCP handler)
|
|
27
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
28
|
+
let existing = {};
|
|
29
|
+
if (fs.existsSync(metaPath)) {
|
|
30
|
+
try {
|
|
31
|
+
existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore corrupt metadata
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const metadata = {
|
|
38
|
+
...existing,
|
|
39
|
+
taskId,
|
|
40
|
+
model,
|
|
41
|
+
project,
|
|
42
|
+
briefing: effectiveBriefing,
|
|
43
|
+
mode: isHeadless ? 'headless' : 'interactive',
|
|
44
|
+
agent: agent || (isHeadless ? 'build' : 'chat'),
|
|
45
|
+
thinking: thinking || 'medium',
|
|
46
|
+
status: 'running',
|
|
47
|
+
pid: existing.pid || process.pid,
|
|
48
|
+
createdAt: existing.createdAt || new Date().toISOString(),
|
|
49
|
+
...(pack ? { pack } : {}), // v4.5 Task 13: absent-not-null; ...existing above preserves a prior write when this call omits pack.
|
|
50
|
+
...(tag ? { tag } : {}), // v4.7 F8 (D13): absent-not-null, same merge-preserve idiom as pack above.
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
54
|
+
|
|
55
|
+
return sessionDir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { createSessionMetadata };
|
package/src/sidecar/start.js
CHANGED
|
@@ -27,53 +27,13 @@ const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
|
27
27
|
const { discoverParentMcps } = require('../utils/mcp-discovery');
|
|
28
28
|
const { stripSelfMcpEntries } = require('../utils/mcp-self-identity');
|
|
29
29
|
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
30
|
+
const { createSessionMetadata } = require('./start-metadata');
|
|
30
31
|
|
|
31
32
|
/** Generate a unique 8-character hex task ID */
|
|
32
33
|
function generateTaskId() {
|
|
33
34
|
return crypto.randomBytes(4).toString('hex');
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
/** Create session directory and save metadata */
|
|
37
|
-
function createSessionMetadata(taskId, project, options) {
|
|
38
|
-
const { model, prompt, briefing, noUi, headless, agent, thinking, pack } = options;
|
|
39
|
-
|
|
40
|
-
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
41
|
-
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
42
|
-
|
|
43
|
-
const effectiveBriefing = prompt || briefing;
|
|
44
|
-
const isHeadless = noUi !== undefined ? noUi : headless;
|
|
45
|
-
|
|
46
|
-
// Preserve fields from existing metadata (e.g., pid written by MCP handler)
|
|
47
|
-
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
48
|
-
let existing = {};
|
|
49
|
-
if (fs.existsSync(metaPath)) {
|
|
50
|
-
try {
|
|
51
|
-
existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
52
|
-
} catch {
|
|
53
|
-
// ignore corrupt metadata
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const metadata = {
|
|
58
|
-
...existing,
|
|
59
|
-
taskId,
|
|
60
|
-
model,
|
|
61
|
-
project,
|
|
62
|
-
briefing: effectiveBriefing,
|
|
63
|
-
mode: isHeadless ? 'headless' : 'interactive',
|
|
64
|
-
agent: agent || (isHeadless ? 'build' : 'chat'),
|
|
65
|
-
thinking: thinking || 'medium',
|
|
66
|
-
status: 'running',
|
|
67
|
-
pid: existing.pid || process.pid,
|
|
68
|
-
createdAt: existing.createdAt || new Date().toISOString(),
|
|
69
|
-
...(pack ? { pack } : {}), // v4.5 Task 13: absent-not-null; ...existing above preserves a prior write when this call omits pack.
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
73
|
-
|
|
74
|
-
return sessionDir;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
37
|
/**
|
|
78
38
|
* Build MCP configuration from options.
|
|
79
39
|
* Merge priority: CLI --mcp > --mcp-config > file config > discovered parent MCPs
|
|
@@ -154,7 +114,7 @@ async function startSidecar(options) {
|
|
|
154
114
|
contextMaxTokens = 80000, noUi, headless = false, timeout = 15,
|
|
155
115
|
agent, mcp, mcpConfig, summaryLength = 'normal', thinking,
|
|
156
116
|
client, sessionDir, noMcp, excludeMcp, opencodePort, coworkProcess, includeContext = true,
|
|
157
|
-
position = 'right', json = false, modelInput = null, pack = null
|
|
117
|
+
position = 'right', json = false, modelInput = null, pack = null, tag
|
|
158
118
|
} = options;
|
|
159
119
|
|
|
160
120
|
const effectivePrompt = prompt || briefing;
|
|
@@ -183,7 +143,7 @@ async function startSidecar(options) {
|
|
|
183
143
|
);
|
|
184
144
|
|
|
185
145
|
const sessDir = createSessionMetadata(taskId, effectiveProject, {
|
|
186
|
-
model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking, pack
|
|
146
|
+
model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking, pack, tag
|
|
187
147
|
});
|
|
188
148
|
saveInitialContext(sessDir, systemPrompt, userMessage);
|
|
189
149
|
acquireLock(sessDir, effectiveHeadless ? 'headless' : 'interactive');
|
|
@@ -270,6 +230,11 @@ async function startSidecar(options) {
|
|
|
270
230
|
gateway: String(model).startsWith('openrouter/') ? 'openrouter' : 'direct',
|
|
271
231
|
// (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
|
|
272
232
|
// cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
|
|
233
|
+
// v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
|
|
234
|
+
// just-re-read metadata (line 214), which carries `tag` when
|
|
235
|
+
// createSessionMetadata stored one (absent otherwise); `|| null` folds
|
|
236
|
+
// that into spend-ledger.js's null-not-absent dim convention.
|
|
237
|
+
tag: m.tag || null,
|
|
273
238
|
});
|
|
274
239
|
} catch { /* best-effort */ }
|
|
275
240
|
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* @param {'ok'|'package-missing'|'binary-missing'} [options.electronState] - 3-state probe (#76); takes precedence
|
|
24
24
|
* @param {string|null} [options.electronDir] - Resolved electron dir, named in the electron-broken reason
|
|
25
25
|
* @param {string} options.platform - The platform (e.g., 'win32', 'darwin', 'linux')
|
|
26
|
-
* @param {object} options.env - Environment variables object
|
|
26
|
+
* @param {object} [options.env] - Environment variables object
|
|
27
27
|
* @param {boolean} options.autoOpenConfig - The config.workspace.autoOpen setting
|
|
28
28
|
* @param {boolean|undefined} options.uiParam - Explicit UI parameter (true, false, or undefined)
|
|
29
29
|
* @returns {{open: boolean, reason: string}}
|
|
@@ -34,7 +34,7 @@ function shouldAutoOpenWorkspace({
|
|
|
34
34
|
electronState,
|
|
35
35
|
electronDir,
|
|
36
36
|
platform,
|
|
37
|
-
env,
|
|
37
|
+
env = {},
|
|
38
38
|
autoOpenConfig,
|
|
39
39
|
uiParam,
|
|
40
40
|
}) {
|
package/src/spend-query.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* NOT hand-copy this array elsewhere: a 7th dimension added here must reach
|
|
18
18
|
* both surfaces automatically, not just the one someone remembered to edit.
|
|
19
19
|
*/
|
|
20
|
-
const GROUP_DIMS = ['model', 'wave', 'council', 'project', 'op', 'day'];
|
|
20
|
+
const GROUP_DIMS = ['model', 'wave', 'council', 'project', 'op', 'day', 'tag'];
|
|
21
21
|
|
|
22
22
|
/** Cap on rows returned when a caller opts into raw rows (CLI --rows / MCP rows:true). */
|
|
23
23
|
const ROWS_CAP = 1000;
|
|
@@ -55,6 +55,7 @@ function rowKey(row, dimension) {
|
|
|
55
55
|
case 'project': return row.project || '(unattributed)';
|
|
56
56
|
case 'op': return row.op || '(unattributed)';
|
|
57
57
|
case 'day': return typeof row.ts === 'string' ? row.ts.slice(0, 10) : '(unattributed)';
|
|
58
|
+
case 'tag': return row.tag || '(unattributed)';
|
|
58
59
|
default: return '(unattributed)';
|
|
59
60
|
}
|
|
60
61
|
}
|
package/src/template/apply.js
CHANGED
|
@@ -52,10 +52,10 @@ function applyTemplate({ templateRef, prompt, artifactFile, varList, project })
|
|
|
52
52
|
try {
|
|
53
53
|
raw = fs.readFileSync(artifactPath);
|
|
54
54
|
} catch (err) {
|
|
55
|
-
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: cannot read --artifact ${artifactFile}: ${err.message}`, hint:
|
|
55
|
+
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: cannot read --artifact ${artifactFile}: ${err.message}`, hint: 'Check that the --artifact path exists and is readable — relative paths resolve from the current working directory.' } };
|
|
56
56
|
}
|
|
57
57
|
if (raw.length > ARTIFACT_CAP_BYTES) {
|
|
58
|
-
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: --artifact ${artifactFile} is ${raw.length} bytes; the cap is 256 KB`, hint:
|
|
58
|
+
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: --artifact ${artifactFile} is ${raw.length} bytes; the cap is 256 KB`, hint: 'Trim --artifact to under 256 KB, or point it at a smaller excerpt.' } };
|
|
59
59
|
}
|
|
60
60
|
artifact = raw.toString('utf-8');
|
|
61
61
|
if (artifact.charCodeAt(0) === 0xFEFF) { artifact = artifact.slice(1); }
|
|
@@ -66,11 +66,14 @@ function applyTemplate({ templateRef, prompt, artifactFile, varList, project })
|
|
|
66
66
|
artifact,
|
|
67
67
|
artifactPath,
|
|
68
68
|
date: new Date().toISOString().slice(0, 10),
|
|
69
|
-
project: String(project),
|
|
69
|
+
project: path.resolve(String(project)),
|
|
70
70
|
vars,
|
|
71
71
|
});
|
|
72
72
|
if (res.error) {
|
|
73
|
-
|
|
73
|
+
// No variable list here on purpose: renderTemplate's own message already ends
|
|
74
|
+
// with a knownList()-derived "Known: ..." line. A copy would be a third
|
|
75
|
+
// hand-maintained KNOWN_VARIABLES mirror, which BACKLOG's T3-m2 hard gate forbids.
|
|
76
|
+
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: res.error, hint: 'amicus template show <name>' } };
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
return {
|
package/src/template/render.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* happens ONLY in template files (spec carried decision 7) — --prompt text is
|
|
8
8
|
* always literal, so this module never sees non-template input.
|
|
9
9
|
*
|
|
10
|
-
* v4.5 variable set. {{input}} is deliberately ABSENT — it ships with
|
|
11
|
-
* --input-from; on v4.5 it fails as an
|
|
10
|
+
* v4.5 variable set. {{input}} is deliberately ABSENT — it ships with
|
|
11
|
+
* composable waves (--input-from), a future rev; on v4.5 it fails as an
|
|
12
|
+
* unknown variable, which is accurate.
|
|
12
13
|
* No {{model}}: prompts are built once per wave, model-independent.
|
|
13
14
|
*/
|
|
14
15
|
|
|
@@ -37,6 +38,9 @@ function renderTemplate(text, data) {
|
|
|
37
38
|
for (const name of used) {
|
|
38
39
|
if (name.startsWith('var.')) {
|
|
39
40
|
const key = name.slice(4);
|
|
41
|
+
if (key === '') {
|
|
42
|
+
return { error: `Error: Unknown template variable {{var.}} — {{var.<key>}} requires a key. Known: ${knownList()}` };
|
|
43
|
+
}
|
|
40
44
|
if (!(key in vars)) {
|
|
41
45
|
return { error: `Error: template uses {{var.${key}}} but no --var ${key}=<value> was given` };
|
|
42
46
|
}
|
package/src/template/store.js
CHANGED
|
@@ -18,7 +18,7 @@ function _getConfigDir() { return require('../utils/config').getConfigDir(); }
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* v4.5 ships `review` only. `critique`/`refine` are {{input}}-centric and
|
|
21
|
-
* arrive with
|
|
21
|
+
* arrive with composable waves (--input-from).
|
|
22
22
|
*/
|
|
23
23
|
const BUILTIN_TEMPLATES = Object.freeze({
|
|
24
24
|
review: [
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -62,6 +62,12 @@ function idsByProvider(catalog) {
|
|
|
62
62
|
* at least one live resolution from any other source. This ensures the
|
|
63
63
|
* suggested `--add-alias` fix actually clears the warning, and prevents
|
|
64
64
|
* permanently unclearable noise when the default openrouter route is live.
|
|
65
|
+
*
|
|
66
|
+
* A stale 'defaults' entry is additionally suppressed when it is the alias's
|
|
67
|
+
* DERIVED direct-form pin (per curated-models.directFormProvenance()) and the
|
|
68
|
+
* alias is either covered live from another source or declares gatewayOnly
|
|
69
|
+
* (v4.6.3 PR1, spec D2) — an AUTHORED defaults pin and user-config rows are
|
|
70
|
+
* never suppressed this way.
|
|
65
71
|
* @param {Array<{alias,model,source}>} sources
|
|
66
72
|
* @param {Array<{id:string}>} catalog
|
|
67
73
|
*/
|
|
@@ -75,11 +81,24 @@ function findStaleAliases(sources, catalog) {
|
|
|
75
81
|
const covered = new Set(
|
|
76
82
|
sources.filter(({ model }) => isLive(model) === true).map(({ alias }) => alias)
|
|
77
83
|
);
|
|
84
|
+
// v4.6.3 PR1 (spec D2). Lazy-required so suites that doMock curated-models
|
|
85
|
+
// for other cases keep working; a stub without the accessor simply gets no
|
|
86
|
+
// suppression (fail-open toward reporting).
|
|
87
|
+
const cm = require('./curated-models');
|
|
88
|
+
const provenance = typeof cm.directFormProvenance === 'function' ? (cm.directFormProvenance() || {}) : {};
|
|
78
89
|
return sources.filter(({ alias, model, source }) => {
|
|
79
90
|
const ids = byProvider.get(model.split('/')[0]);
|
|
80
91
|
if (!ids) { return false; } // provider unverifiable
|
|
81
92
|
if (ids.has(model)) { return false; } // live
|
|
82
93
|
if (source.startsWith('curated-route') && covered.has(alias)) { return false; }
|
|
94
|
+
// A 'defaults' pin that is the alias's DERIVED direct form: its absence
|
|
95
|
+
// from the direct namespace is a routing fact, not staleness, while the
|
|
96
|
+
// alias has live coverage (or declares gatewayOnly). The fix: suggestion
|
|
97
|
+
// this row would otherwise print is a retarget nobody should run — the
|
|
98
|
+
// 2026-08-05 release-gate false positive (v4.6.3 spec §3).
|
|
99
|
+
const prov = provenance[alias];
|
|
100
|
+
if (source === 'defaults' && prov && prov.directForm === 'derived' &&
|
|
101
|
+
(prov.gatewayOnly || covered.has(alias))) { return false; }
|
|
83
102
|
return true;
|
|
84
103
|
});
|
|
85
104
|
}
|
|
@@ -40,4 +40,30 @@ function requireValidTaskId(args, useJson, commandLabel, usage) {
|
|
|
40
40
|
return taskId;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* `pack save --version <semver>` can never work, and used to fail SILENTLY:
|
|
45
|
+
* `version` is a global BOOLEAN_FLAG (src/cli.js), so parseArgs sets
|
|
46
|
+
* `args.version = true` and drops the semver into positionals, then
|
|
47
|
+
* bin/amicus.js prints the version banner BEFORE command dispatch — so
|
|
48
|
+
* `handlePack` never runs and no pack is written, at exit 0. `--version=2.0.0`
|
|
49
|
+
* fails identically (the inline value is discarded at the isBooleanFlag branch,
|
|
50
|
+
* ahead of the --key=value branch). The pack's own version is `--pack-version`.
|
|
51
|
+
*
|
|
52
|
+
* Returns the failure rather than exiting, so bin/amicus.js keeps one exit site
|
|
53
|
+
* and this stays unit-testable (bin/amicus.js is a script, not a module).
|
|
54
|
+
*
|
|
55
|
+
* @param {object} args - parsed CLI args
|
|
56
|
+
* @returns {{code: string, message: string, hint: string}|null} null when there is no conflict
|
|
57
|
+
*/
|
|
58
|
+
function packSaveVersionConflict(args) {
|
|
59
|
+
if (!args || !args.version) { return null; }
|
|
60
|
+
const argv = Array.isArray(args._) ? args._ : [];
|
|
61
|
+
if (argv[0] !== 'pack' || argv[1] !== 'save') { return null; }
|
|
62
|
+
return {
|
|
63
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
64
|
+
message: "Error: --version is amicus's own global flag, not the saved pack's version",
|
|
65
|
+
hint: 'Use --pack-version <semver> to set the version of the pack being saved.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { requireNoUiForJson, requireValidTaskId, packSaveVersionConflict };
|
package/src/utils/config.js
CHANGED
|
@@ -419,6 +419,21 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
419
419
|
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
|
|
420
420
|
* `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
|
|
421
421
|
* callers keep working); `droppedMembers` additively pairs each with WHY.
|
|
422
|
+
*
|
|
423
|
+
* Standing note (D18, v4.7 PR5): each `droppedMembers` entry is `{member, reason}`
|
|
424
|
+
* (that is the real key — BACKLOG.md's description of this shape had drifted to
|
|
425
|
+
* `{ref, reason}`). The two `reason` literals are produced below, in the
|
|
426
|
+
* alias-miss and catalog-miss branches of `classifyCouncilMembers`; they are
|
|
427
|
+
* free text, not a coded enum, and today NO consumer branches on the string. They
|
|
428
|
+
* are not display-only: `council/presets-cli.js` (`amicus council show`,
|
|
429
|
+
* `:149-150`) renders `${member} (${reason})`, `council/run.js` (`:80-87`) carries
|
|
430
|
+
* it verbatim into a degrade-note payload, and `reason` is also persisted into
|
|
431
|
+
* `run.json` (`run-state.js`, `mcp-council-run.js`) — not merely shown to a human.
|
|
432
|
+
* `cli-council-run-bench.js` (`:74`) only type-checks that `reason` is a string
|
|
433
|
+
* when round-tripping `--dropped-members` across the MCP→CLI spawn boundary. The
|
|
434
|
+
* tripwire: if a THIRD reason string is ever added here, stop and re-decide
|
|
435
|
+
* whether `reason` should become a coded enum instead of free text — this note
|
|
436
|
+
* marks that decision point, it does not make it.
|
|
422
437
|
*/
|
|
423
438
|
function classifyCouncilMembers(members, catalog = []) {
|
|
424
439
|
const aliases = getEffectiveAliases();
|
|
@@ -68,7 +68,9 @@ const FAMILIES = [
|
|
|
68
68
|
];
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Alias-only entries (no wizard quick pick)
|
|
71
|
+
* Alias-only entries (no wizard quick pick). Every entry authors an
|
|
72
|
+
* openrouter route; entries whose vendor's direct API genuinely serves the
|
|
73
|
+
* model also author a direct route (claude/sonnet/haiku/fable).
|
|
72
74
|
* Refreshed against the live catalog 2026-08-04.
|
|
73
75
|
*/
|
|
74
76
|
const CARDLESS = [
|
|
@@ -77,7 +79,12 @@ const CARDLESS = [
|
|
|
77
79
|
// gpt-5.5-pro ($30/$180 — still served, but expected to sunset with the
|
|
78
80
|
// 5.5 line). `gpt-pro` tracks SOL while the `gpt` family tracks terra —
|
|
79
81
|
// see the tier-semantics comment on the `gpt` family above.
|
|
80
|
-
|
|
82
|
+
// gatewayOnly (owner ruling 2026-08-05, recorded in the v4.6.3 spec): the
|
|
83
|
+
// openrouter-only route is a deliberate routing choice — OpenAI's direct
|
|
84
|
+
// namespace does not serve gpt-5.6-sol-pro, so the DERIVED direct form
|
|
85
|
+
// must never be audited as stale and no direct pairing may be suggested.
|
|
86
|
+
{ alias: 'gpt-pro', gatewayOnly: true,
|
|
87
|
+
routes: { openrouter: 'openrouter/openai/gpt-5.6-sol-pro' } },
|
|
81
88
|
// codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
|
|
82
89
|
{ alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
|
|
83
90
|
{ alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
|
|
@@ -86,7 +93,11 @@ const CARDLESS = [
|
|
|
86
93
|
anthropic: 'anthropic/claude-sonnet-5' } },
|
|
87
94
|
{ alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5',
|
|
88
95
|
anthropic: 'anthropic/claude-haiku-4-5-20251001' } },
|
|
89
|
-
|
|
96
|
+
// fable: direct route authored 2026-08-05 (owner ruling R2, v4.6.3 spec §3).
|
|
97
|
+
// Anthropic's /v1/models lists claude-fable-5 AND the direct route serves
|
|
98
|
+
// (live smoke wave 47278069) — the entry was OpenRouter-only at authoring.
|
|
99
|
+
{ alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5',
|
|
100
|
+
anthropic: 'anthropic/claude-fable-5' } },
|
|
90
101
|
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
|
|
91
102
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
92
103
|
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
@@ -160,7 +171,8 @@ function listCuratedRoutes() {
|
|
|
160
171
|
* Vendors whose direct-API ids differ from OpenRouter's (dot vs. dash
|
|
161
172
|
* versioning, distinct model names, etc.). NEVER derive a direct form for
|
|
162
173
|
* these — derivation would emit the wrong (dot) id, or invent a direct id
|
|
163
|
-
* for a model
|
|
174
|
+
* for a model the direct API does not serve (fable was that case until its
|
|
175
|
+
* direct route was verified and authored, 2026-08-05).
|
|
164
176
|
* Frozen so consumers can only read it (`.has()`) — a frozen Set still
|
|
165
177
|
* supports lookups, it just can't be `.add()`/`.delete()`/`.clear()`-ed.
|
|
166
178
|
*/
|
|
@@ -200,6 +212,28 @@ function gatewayRoutesFor(vendorPath, obj) {
|
|
|
200
212
|
return routes;
|
|
201
213
|
}
|
|
202
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Per-alias provenance of the `direct` form in toGatewayRoutes(), for the
|
|
217
|
+
* auditors (alias-audit.js / gateway-route-audit.js): an AUTHORED direct
|
|
218
|
+
* form absent from its namespace is stale; a DERIVED one is a computed
|
|
219
|
+
* convenience whose absence is a routing fact, not staleness, while the
|
|
220
|
+
* authoring openrouter route is live. `gatewayOnly` mirrors an entry's
|
|
221
|
+
* explicit routing-choice annotation (owner-ruled): suppress derived-form
|
|
222
|
+
* findings unconditionally and never suggest a direct pairing.
|
|
223
|
+
* @returns {Object<string, {directForm: 'authored'|'derived'|'none', gatewayOnly: boolean}>}
|
|
224
|
+
*/
|
|
225
|
+
function directFormProvenance() {
|
|
226
|
+
const out = {};
|
|
227
|
+
const entryProv = (vendorPath, obj, gatewayOnly) => {
|
|
228
|
+
const direct = directFormFor(vendorPath, obj);
|
|
229
|
+
const directForm = !direct ? 'none' : (obj[vendorPath] ? 'authored' : 'derived');
|
|
230
|
+
return { directForm, gatewayOnly: gatewayOnly === true };
|
|
231
|
+
};
|
|
232
|
+
for (const f of FAMILIES) { out[f.alias] = entryProv(f.vendorPath, f.fallback, f.gatewayOnly); }
|
|
233
|
+
for (const e of CARDLESS) { out[e.alias] = entryProv(vendorOf(e.routes.openrouter), e.routes, e.gatewayOnly); }
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
203
237
|
/**
|
|
204
238
|
* @returns {Object<string,{direct?: string, openrouter: string}>} alias →
|
|
205
239
|
* per-gateway executable ids. Unlike `toDefaultAliases` (a single pinned
|
|
@@ -223,8 +257,9 @@ function toGatewayRoutes() {
|
|
|
223
257
|
* disagree. It previously string-stripped `openrouter/` itself, which emitted
|
|
224
258
|
* OpenRouter's dot ids for divergent vendors (`anthropic/claude-opus-4.8` —
|
|
225
259
|
* the direct API only serves the dash form) and invented a bare direct id for
|
|
226
|
-
* OpenRouter-only models (`fable
|
|
227
|
-
* models --check` warn about the product's own
|
|
260
|
+
* then-OpenRouter-only models (`fable`, direct-authored 2026-08-05). Both made
|
|
261
|
+
* `amicus doctor` and `amicus models --check` warn about the product's own
|
|
262
|
+
* shipped defaults.
|
|
228
263
|
*/
|
|
229
264
|
function toDefaultAliases() {
|
|
230
265
|
const out = {};
|
|
@@ -235,5 +270,6 @@ function toDefaultAliases() {
|
|
|
235
270
|
}
|
|
236
271
|
|
|
237
272
|
module.exports = {
|
|
238
|
-
getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes,
|
|
273
|
+
getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes,
|
|
274
|
+
directFormProvenance, DIVERGENT_VENDORS
|
|
239
275
|
};
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
'use strict';
|
|
32
32
|
|
|
33
|
-
const { toGatewayRoutes } = require('./curated-models');
|
|
33
|
+
const { toGatewayRoutes, directFormProvenance } = require('./curated-models');
|
|
34
34
|
const { classifyModel } = require('./model-classification');
|
|
35
35
|
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
36
36
|
const { isDirectProvider } = require('./provider-registry');
|
|
@@ -70,19 +70,32 @@ function isAuthoritative(catalogInfo, id) {
|
|
|
70
70
|
*/
|
|
71
71
|
function auditGatewayRoutes(catalogInfo) {
|
|
72
72
|
const routes = toGatewayRoutes();
|
|
73
|
+
const provenance = directFormProvenance();
|
|
73
74
|
const findings = [];
|
|
74
75
|
|
|
75
76
|
for (const [alias, forms] of Object.entries(routes)) {
|
|
77
|
+
const prov = provenance[alias] || { directForm: 'none', gatewayOnly: false };
|
|
76
78
|
for (const gateway of ['direct', 'openrouter']) {
|
|
77
79
|
const id = forms[gateway];
|
|
78
80
|
if (!id) { continue; }
|
|
79
|
-
if (classifyModel(id, gateway, catalogInfo)
|
|
80
|
-
|
|
81
|
+
if (classifyModel(id, gateway, catalogInfo) !== 'invalid') { continue; }
|
|
82
|
+
// v4.6.3 PR1 (spec D2): a DERIVED direct form is a computed convenience,
|
|
83
|
+
// not an authored claim. Its absence from the direct namespace is a
|
|
84
|
+
// routing fact — not staleness — while the authoring openrouter route
|
|
85
|
+
// is live, or when the entry declares gatewayOnly (an owner-ruled
|
|
86
|
+
// routing choice). An AUTHORED direct form absent from its namespace
|
|
87
|
+
// reports exactly as before.
|
|
88
|
+
if (gateway === 'direct' && prov.directForm === 'derived' &&
|
|
89
|
+
(prov.gatewayOnly ||
|
|
90
|
+
classifyModel(forms.openrouter, 'openrouter', catalogInfo) === 'valid')) {
|
|
91
|
+
continue;
|
|
81
92
|
}
|
|
93
|
+
findings.push({ alias, gateway, kind: 'stale', model: id });
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
const vendor = vendorOf(forms.openrouter);
|
|
85
97
|
if (!vendor || !isDirectProvider(vendor)) { continue; } // gateway-only vendor: no direct route ever possible
|
|
98
|
+
if (prov.gatewayOnly) { continue; } // declared routing choice: never suggest a direct pairing
|
|
86
99
|
const token = bareSegment(forms.openrouter, vendor);
|
|
87
100
|
if (!token) { continue; }
|
|
88
101
|
const paired = pairAcrossGateways(vendor, token, catalogInfo); // Task-5 contract: bare segment only
|