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
@@ -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
- const sessions = enumerateSessions(project, { status });
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('ID MODEL STATUS AGE BRIEFING');
92
- console.log(''.repeat(80));
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 };
@@ -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
  }) {
@@ -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
  }
@@ -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: null } };
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: null } };
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
- return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: res.error, hint: null } };
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 {
@@ -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 v4.6's
11
- * --input-from; on v4.5 it fails as an unknown variable, which is accurate.
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
  }
@@ -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 v4.6's chaining (--input-from).
21
+ * arrive with composable waves (--input-from).
22
22
  */
23
23
  const BUILTIN_TEMPLATES = Object.freeze({
24
24
  review: [
@@ -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
- module.exports = { requireNoUiForJson, requireValidTaskId };
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 };
@@ -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); openrouter route only.
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
- { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.6-sol-pro' } },
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
- { alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5' } },
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 that is OpenRouter-only today (e.g. fable).
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`). Both made `amicus doctor` and `amicus
227
- * models --check` warn about the product's own shipped defaults.
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, DIVERGENT_VENDORS
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) === 'invalid') {
80
- findings.push({ alias, gateway, kind: 'stale', model: id });
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