amicus 4.6.3 → 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 (86) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +283 -0
  3. package/README.md +19 -7
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +143 -36
  6. package/docs/configuration.md +51 -3
  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 +59 -13
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +52 -14
  14. package/electron/workspace-ui/workspace-app.js +14 -3
  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 +6 -2
  19. package/electron/workspace-ui/workspace-seats.js +68 -0
  20. package/electron/workspace-ui/workspace.css +6 -0
  21. package/package.json +5 -2
  22. package/schemas/council-run.schema.json +1 -0
  23. package/schemas/council-stats.schema.json +9 -1
  24. package/schemas/run.schema.json +2 -1
  25. package/schemas/spend.schema.json +1 -1
  26. package/schemas/wave.schema.json +2 -1
  27. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  29. package/skills/sidecar/SKILL.md +7 -2
  30. package/src/cli-council-run-bench.js +86 -0
  31. package/src/cli-handlers-council-run.js +65 -81
  32. package/src/cli-handlers-council.js +17 -5
  33. package/src/cli-handlers-fanout.js +179 -0
  34. package/src/cli-handlers-pack.js +24 -10
  35. package/src/cli-handlers-run.js +19 -161
  36. package/src/cli-template-args.js +48 -0
  37. package/src/cli.js +39 -46
  38. package/src/council/debate.js +89 -10
  39. package/src/council/ledger.js +72 -11
  40. package/src/council/report.js +17 -6
  41. package/src/council/run-assemble.js +15 -3
  42. package/src/council/run-budget.js +2 -2
  43. package/src/council/run-chair.js +61 -5
  44. package/src/council/run-debate.js +51 -67
  45. package/src/council/run-launch.js +9 -2
  46. package/src/council/run-retry.js +4 -1
  47. package/src/council/run-stage1-launch.js +94 -0
  48. package/src/council/run-stage2.js +25 -4
  49. package/src/council/run-stages.js +79 -86
  50. package/src/council/run-state.js +10 -2
  51. package/src/council/run.js +26 -2
  52. package/src/council/tally.js +6 -2
  53. package/src/mcp-council-awareness.js +1 -0
  54. package/src/mcp-council-bench.js +4 -0
  55. package/src/mcp-council-run.js +10 -0
  56. package/src/mcp-server.js +114 -54
  57. package/src/mcp-tools.js +12 -5
  58. package/src/pack/pack-cli.js +1 -1
  59. package/src/pack/pack-forward.js +12 -4
  60. package/src/pack/pack-resolve.js +3 -0
  61. package/src/pack/pack-store.js +20 -3
  62. package/src/pack/pack-validate.js +5 -1
  63. package/src/sidecar/budget.js +38 -4
  64. package/src/sidecar/fanout-budget.js +1 -2
  65. package/src/sidecar/fanout-leg-fallback.js +7 -3
  66. package/src/sidecar/fanout-wave-io.js +13 -1
  67. package/src/sidecar/fanout.js +11 -9
  68. package/src/sidecar/list-limit.js +50 -0
  69. package/src/sidecar/list-search.js +69 -0
  70. package/src/sidecar/read.js +90 -5
  71. package/src/sidecar/start-metadata.js +58 -0
  72. package/src/sidecar/start.js +8 -43
  73. package/src/sidecar/workspace-auto-open.js +2 -2
  74. package/src/spend-query.js +2 -1
  75. package/src/template/apply.js +7 -4
  76. package/src/template/render.js +6 -2
  77. package/src/template/store.js +1 -1
  78. package/src/utils/cli-preflight.js +27 -1
  79. package/src/utils/config.js +15 -0
  80. package/src/utils/result-schema-rebuild.js +1 -0
  81. package/src/utils/result-schema.js +6 -1
  82. package/src/utils/session-index-tmp-sweep.js +18 -3
  83. package/src/utils/session-index.js +1 -0
  84. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  85. package/src/utils/spend-ledger.js +11 -4
  86. package/src/utils/validators.js +16 -0
@@ -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 };
@@ -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: [
@@ -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();
@@ -91,6 +91,7 @@ function buildWaveResultFromSession(project, waveId) {
91
91
  legs,
92
92
  promptMeta: meta.promptMeta || null,
93
93
  ...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
94
+ ...(meta.tag ? { tag: meta.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
94
95
  createdAt: meta.createdAt || null,
95
96
  completedAt: meta.completedAt || null,
96
97
  });
@@ -47,6 +47,7 @@ function durationBetween(createdAt, completedAt) {
47
47
  * @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
48
48
  * metadata.pack was recorded (solo session launched via --pack), sourced straight off
49
49
  * `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
50
+ * `tag` (v4.7 F8/D13) is additive the same way — present only when metadata.tag was recorded.
50
51
  */
51
52
  function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
52
53
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
@@ -71,6 +72,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
71
72
  opencodeSessionId: metadata.opencodeSessionId || null,
72
73
  usage: usage !== null ? usage : (metadata.usage || null),
73
74
  ...(metadata.pack ? { pack: metadata.pack } : {}),
75
+ ...(metadata.tag ? { tag: metadata.tag } : {}),
74
76
  };
75
77
  }
76
78
 
@@ -127,9 +129,11 @@ function waveExitCode(waveStatus) {
127
129
  * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
128
130
  * @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
129
131
  * additive — present only when the wave was launched via --pack (absent, never null, otherwise).
132
+ * @param {string|null} [opts.tag] - v4.7 F8 (D13): additive — present only when the wave was
133
+ * launched via --tag (absent, never null, otherwise).
130
134
  * @returns {object} wave document
131
135
  */
132
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
136
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null, tag = null }) {
133
137
  const { sumWaveUsage } = require('./pricing');
134
138
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
135
139
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -157,6 +161,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
157
161
  usage: sumWaveUsage(legs),
158
162
  notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
159
163
  ...(pack ? { pack } : {}),
164
+ ...(tag ? { tag } : {}),
160
165
  };
161
166
  }
162
167
 
@@ -33,9 +33,21 @@ function listSessionIndexTmpFiles() {
33
33
  return entries
34
34
  .filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
35
35
  .map((name) => {
36
- let mtimeMs = null;
37
- try { mtimeMs = fs.statSync(path.join(dir, name)).mtimeMs; } catch { /* raced away skip below */ }
38
- return { name, mtimeMs };
36
+ let st = null;
37
+ // statSync (not lstatSync) is a DECIDED policy, not an oversight (owner
38
+ // ruling, v4.7 PR7 — see BACKLOG.md "session-index-tmp-sweep.js follows
39
+ // symlinks... where its sibling deliberately does not"). This sweep's
40
+ // targets are index tmp files directly in the flat config dir, never
41
+ // traversed through a directory an attacker could redirect — unlike
42
+ // session-metadata-tmp-sweep.js's walk through per-session directories
43
+ // (see that file's "Symlink safety" paragraph for why THAT sweep must
44
+ // never follow). Following here means a symlink named like an orphaned
45
+ // tmp file is still swept (unlink removes only the link); the one real
46
+ // consequence is AGE_THRESHOLD_MS reading the TARGET's mtime, so a fresh
47
+ // link to an old file is swept with no grace window. Filed, not fixed —
48
+ // see the BACKLOG entry above for the full inclusion/unfiled-delta split.
49
+ try { st = fs.statSync(path.join(dir, name)); } catch { /* raced away */ }
50
+ return { name, mtimeMs: st && st.isFile() ? st.mtimeMs : null };
39
51
  })
40
52
  .filter((f) => f.mtimeMs !== null);
41
53
  }
@@ -52,6 +64,9 @@ function unlinkSessionIndexTmp(name) {
52
64
  * this in guard() the same way it wires the mcp-legacy check's inspect/migrate.
53
65
  * @param {{listSessionIndexTmpFiles: () => Array<{name:string, mtimeMs:number}>,
54
66
  * fix?: boolean, now: () => number, unlinkSessionIndexTmp: (name: string) => void}} d
67
+ * Its four `message` strings are byte-identical to
68
+ * session-metadata-tmp-sweep.js's evaluateSessionMetadataTmpSweep by design
69
+ * (only `id`/`name`/`fixDetail` differ) — reword both or neither.
55
70
  */
56
71
  function evaluateSessionIndexTmpSweep(d) {
57
72
  const id = 'sessions-index-tmp'; const name = 'Session index tmp files';
@@ -94,4 +94,5 @@ module.exports = {
94
94
  INDEX_FILENAME,
95
95
  recordSession,
96
96
  lookupSessionProject,
97
+ readIndex,
97
98
  };