amicus 4.6.3 → 4.7.1

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 (99) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -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 +52 -4
  7. package/docs/council.md +65 -12
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/testing.md +1 -1
  11. package/docs/troubleshooting.md +33 -5
  12. package/docs/usage.md +64 -14
  13. package/electron/workspace-ui/index.html +3 -0
  14. package/electron/workspace-ui/live-model.js +52 -14
  15. package/electron/workspace-ui/workspace-app.js +14 -3
  16. package/electron/workspace-ui/workspace-lazy.js +233 -0
  17. package/electron/workspace-ui/workspace-matrix.js +12 -1
  18. package/electron/workspace-ui/workspace-panels.js +24 -171
  19. package/electron/workspace-ui/workspace-render.js +6 -2
  20. package/electron/workspace-ui/workspace-seats.js +68 -0
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +8 -4
  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/scripts/postinstall.js +6 -3
  29. package/scripts/setup-hooks.js +49 -3
  30. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  31. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  32. package/skills/sidecar/SKILL.md +9 -2
  33. package/src/cli-council-run-bench.js +86 -0
  34. package/src/cli-handlers-council-run.js +65 -81
  35. package/src/cli-handlers-council.js +17 -5
  36. package/src/cli-handlers-fanout.js +179 -0
  37. package/src/cli-handlers-pack.js +24 -10
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/cli-handlers-run.js +19 -161
  40. package/src/cli-template-args.js +48 -0
  41. package/src/cli.js +39 -46
  42. package/src/council/debate.js +89 -10
  43. package/src/council/ledger.js +72 -11
  44. package/src/council/report.js +17 -6
  45. package/src/council/run-assemble.js +15 -3
  46. package/src/council/run-budget.js +2 -2
  47. package/src/council/run-chair.js +61 -5
  48. package/src/council/run-debate.js +51 -67
  49. package/src/council/run-launch.js +20 -2
  50. package/src/council/run-retry.js +17 -2
  51. package/src/council/run-stage1-launch.js +94 -0
  52. package/src/council/run-stage2.js +25 -4
  53. package/src/council/run-stages.js +79 -86
  54. package/src/council/run-state.js +10 -2
  55. package/src/council/run.js +26 -2
  56. package/src/council/tally.js +6 -2
  57. package/src/headless.js +69 -6
  58. package/src/mcp-council-awareness.js +1 -0
  59. package/src/mcp-council-bench.js +4 -0
  60. package/src/mcp-council-run.js +10 -0
  61. package/src/mcp-server.js +114 -54
  62. package/src/mcp-tools.js +12 -5
  63. package/src/pack/pack-cli.js +1 -1
  64. package/src/pack/pack-forward.js +12 -4
  65. package/src/pack/pack-resolve.js +3 -0
  66. package/src/pack/pack-store.js +20 -3
  67. package/src/pack/pack-validate.js +5 -1
  68. package/src/sidecar/budget.js +38 -4
  69. package/src/sidecar/continue.js +8 -23
  70. package/src/sidecar/fanout-budget.js +1 -2
  71. package/src/sidecar/fanout-leg-fallback.js +7 -3
  72. package/src/sidecar/fanout-retry.js +15 -3
  73. package/src/sidecar/fanout-wave-io.js +13 -1
  74. package/src/sidecar/fanout.js +11 -9
  75. package/src/sidecar/list-limit.js +50 -0
  76. package/src/sidecar/list-search.js +69 -0
  77. package/src/sidecar/read.js +90 -5
  78. package/src/sidecar/reopen-spend.js +32 -0
  79. package/src/sidecar/resume.js +1 -1
  80. package/src/sidecar/start-metadata.js +58 -0
  81. package/src/sidecar/start.js +8 -43
  82. package/src/sidecar/workspace-auto-open.js +2 -2
  83. package/src/spend-query.js +2 -1
  84. package/src/template/apply.js +7 -4
  85. package/src/template/render.js +6 -2
  86. package/src/template/store.js +1 -1
  87. package/src/utils/cli-preflight.js +27 -1
  88. package/src/utils/config.js +15 -0
  89. package/src/utils/doctor-engine-check.js +32 -0
  90. package/src/utils/engine-install-scan.js +98 -15
  91. package/src/utils/engine-repair.js +96 -2
  92. package/src/utils/remediation-hints.js +29 -0
  93. package/src/utils/result-schema-rebuild.js +1 -0
  94. package/src/utils/result-schema.js +6 -1
  95. package/src/utils/session-index-tmp-sweep.js +18 -3
  96. package/src/utils/session-index.js +1 -0
  97. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  98. package/src/utils/spend-ledger.js +11 -4
  99. package/src/utils/validators.js +16 -0
@@ -72,4 +72,16 @@ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedA
72
72
  return { wave, exitCode };
73
73
  }
74
74
 
75
- module.exports = { writeWaveMetadata, writeWaveDoc, finishWave };
75
+ /**
76
+ * v4.3 §7.2 (moved here v4.7 PR3 Task 1): stamp council attribution onto every
77
+ * leg — fanout-leg's appendSpend reads it; no-op for every non-council caller.
78
+ * v4.7 F8 (Task 7) adds tag stamping in the same pass.
79
+ */
80
+ function stampLegAttribution(legs, options) {
81
+ if (options.councilRunId || options.councilName) {
82
+ legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
83
+ }
84
+ if (options.tag) { legs.forEach(l => { l.tag = options.tag; }); }
85
+ }
86
+
87
+ module.exports = { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution };
@@ -19,7 +19,7 @@ const { ERROR_CODES } = require('../utils/error-doc');
19
19
  // Wave-document persistence lives in ./fanout-wave-io (size-gate split, v4.4.1
20
20
  // Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
21
21
  // fanout tests import it from here.
22
- const { writeWaveMetadata, writeWaveDoc, finishWave } = require('./fanout-wave-io');
22
+ const { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution } = require('./fanout-wave-io');
23
23
 
24
24
  /**
25
25
  * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
@@ -86,7 +86,11 @@ async function runFanout(options) {
86
86
  // `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
87
87
  // waveDir is optional (only the post-creation caller has one).
88
88
  const errorWave = (waveId, message, waveDir) => {
89
- const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack, createdAt, completedAt: new Date().toISOString(), status: 'error' });
89
+ // v4.7 F8 (D13, T3 review): tag: options.tag || metaTag, TDZ-safe (sole call site runs after `const metaTag` below).
90
+ // v4.7 PR3 rider: pack got the same `|| metaPack` inherit the other two
91
+ // buildWaveResult sites have — it was the lone holdout, so an MCP-spawned
92
+ // wave whose server died dropped its pre-seeded pack while keeping its tag.
93
+ const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt: new Date().toISOString(), status: 'error' });
90
94
  doc.error = message;
91
95
  doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
92
96
  // best-effort: an unwritable wave dir must not mask the real error
@@ -119,11 +123,7 @@ async function runFanout(options) {
119
123
  });
120
124
  if (validated.error) { return failPre(validated.code || 'BAD_ARGS', validated.error); }
121
125
  const legs = validated.legs;
122
- // v4.3 §7.2: stamp council attribution onto every leg (fanout-leg.js's
123
- // existing appendSpend reads it); no-op for every non-council caller.
124
- if (options.councilRunId || options.councilName) {
125
- legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
126
- }
126
+ stampLegAttribution(legs, options);
127
127
  const okLegs = legs.filter(l => l.ok);
128
128
  // FIX 2 (#61 whole-branch review): a leg's migration notice has no CLI
129
129
  // stderr to land on — surface it on the wave doc instead, deduped in case
@@ -149,6 +149,7 @@ async function runFanout(options) {
149
149
  briefing: String(options.prompt).slice(0, 200),
150
150
  promptMeta: options.promptMeta || null,
151
151
  ...(options.pack ? { pack: options.pack } : {}), // v4.5 Task 13: absent-not-null.
152
+ ...(options.tag ? { tag: options.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
152
153
  pid: process.pid, project, createdAt,
153
154
  });
154
155
  // v4.5 final-review F2: an MCP-spawned child never gets --pack (single-
@@ -161,6 +162,7 @@ async function runFanout(options) {
161
162
  // result-schema-rebuild.js:93, which reads meta.pack off a metadata.json
162
163
  // it loaded for an unrelated reason).
163
164
  const metaPack = waveMeta.pack;
165
+ const metaTag = waveMeta.tag; // v4.7 F8 (D13): same pre-seed inherit mechanism as metaPack above.
164
166
  emitWaveStarted(waveDir, waveId, legs.map(l => (l.ok ? l.model : l.modelInput)), legIds, follow);
165
167
 
166
168
  // 2b. All legs failed to route (#61 perf): no leg will ever touch the
@@ -171,7 +173,7 @@ async function runFanout(options) {
171
173
  const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
172
174
  const completedAt = new Date().toISOString();
173
175
  const wave = buildWaveResult({
174
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt, notices,
176
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt, notices,
175
177
  });
176
178
  return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
177
179
  exitCode: waveExitCode(wave.status),
@@ -282,7 +284,7 @@ async function runFanout(options) {
282
284
  const completedAt = new Date().toISOString();
283
285
  const signalled = waveAbort.signal();
284
286
  const wave = buildWaveResult({
285
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt,
287
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt,
286
288
  status: signalled ? 'aborted' : null, notices,
287
289
  });
288
290
  const exitCode = signalled
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @module sidecar/list-limit
3
+ * The `--limit` core behind `amicus list` (v4.7 PR3 rider).
4
+ * Split out of read.js on the same relief-extraction rule that produced
5
+ * list-search.js (T6 review) — read.js was at 293/300 with this inline.
6
+ * Re-exported from src/sidecar/read.js; nothing outside it needs this file.
7
+ *
8
+ * WHY --limit EXISTS. D14 made `--all` real (cross-project via the advisory
9
+ * sessions-index) with no output cap. Measured on the dev machine at merge
10
+ * time: 21,145 rows in 8.3s. An unbounded dump is not a listing.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ /**
16
+ * Normalize `--limit` into a row cap.
17
+ *
18
+ * Absent → 0 (unlimited), which keeps an un-limited `list` byte-identical to
19
+ * its pre-rider output. `0` is also the explicit "no cap" spelling, so a
20
+ * scripted caller can opt out without omitting the flag.
21
+ *
22
+ * @param {number|string|boolean|undefined|null} limit
23
+ * @returns {number} 0 for unlimited, else a positive integer
24
+ * @throws {Error} on a valueless, non-integer, or negative value — silently
25
+ * listing everything after a typo'd `--limit` is exactly the
26
+ * accepted-but-ignored failure src/utils/known-flags.js exists to prevent.
27
+ */
28
+ function normalizeLimit(limit) {
29
+ // parseArgs sets `true` for a valueless flag; mirrors models.js:279-281.
30
+ if (limit === true) { throw new Error('--limit requires a value'); }
31
+ if (limit === undefined || limit === null || limit === '') { return 0; }
32
+ const n = Number(limit);
33
+ if (!Number.isInteger(n) || n < 0) {
34
+ throw new Error(`--limit must be a non-negative integer (got "${limit}"); 0 means unlimited`);
35
+ }
36
+ return n;
37
+ }
38
+
39
+ /**
40
+ * The no-silent-caps notice. Names the real total and how to see it all.
41
+ *
42
+ * @param {number} cap the applied limit
43
+ * @param {number} total row count BEFORE slicing
44
+ * @returns {string}
45
+ */
46
+ function truncationNotice(cap, total) {
47
+ return `Showing ${cap} of ${total} sessions (--limit ${cap}). Use --limit 0 for all.`;
48
+ }
49
+
50
+ module.exports = { normalizeLimit, truncationNotice };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @module sidecar/list-search
3
+ * The `--search` core behind both list surfaces (F8 D15, errata E-PR3-5).
4
+ * Split out of read.js (T6 review) to keep that file under its line budget.
5
+ * Re-exported from src/sidecar/read.js, which is what both consumers
6
+ * (mcp-server.js's amicus_list, the CLI's listSidecars) and the tests already
7
+ * require — nothing outside read.js needs to know this file exists.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { safeSessionDir } = require('../utils/validators');
13
+
14
+ const COUNCIL_MATERIAL_SEPARATOR = '--- MATERIAL / BRIEFING ---';
15
+
16
+ // Council row search material (MCP-only): runDir/briefing.md when readable
17
+ // (MCP-launched runs write one before the engine spawns), else the portion of
18
+ // briefing-stage1.md after the separator (CLI-launched runs have only this),
19
+ // else null. Re-derives runDir from the pointer and re-fences it with
20
+ // containsOnDisk exactly as mcp-council-awareness.js:214 — never a bare join.
21
+ function councilSearchMaterial(project, runId) {
22
+ if (!project) { return null; }
23
+ const { readPointer } = require('../council/run-state');
24
+ const { containsOnDisk } = require('../utils/path-fence');
25
+ const ptr = readPointer(project, runId);
26
+ if (!ptr || !containsOnDisk(project, ptr.runDir)) { return null; }
27
+ try { return fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); } catch { /* try stage1 */ }
28
+ try {
29
+ const s1 = fs.readFileSync(path.join(ptr.runDir, 'briefing-stage1.md'), 'utf-8');
30
+ const idx = s1.indexOf(COUNCIL_MATERIAL_SEPARATOR);
31
+ return idx === -1 ? null : s1.slice(idx + COUNCIL_MATERIAL_SEPARATOR.length);
32
+ } catch { return null; }
33
+ }
34
+
35
+ // Wave material: waveDir/briefing.md (full prompt) when readable, else the
36
+ // 200-char excerpt already on the row (fanout.js:146). safeSessionDir (not a
37
+ // bare path.join) so this gets the same traversal guard every other session
38
+ // path read in the codebase gets.
39
+ function waveSearchMaterial(project, waveId, excerpt) {
40
+ try { return fs.readFileSync(path.join(safeSessionDir(project, waveId), 'briefing.md'), 'utf-8'); }
41
+ catch { return String(excerpt || ''); }
42
+ }
43
+
44
+ // LEG rows (parentWave set) are id/tag ONLY — briefing embeds the parent's material; matching it would hit every leg N+1 times per wave.
45
+ function rowMatchesSearch(row, needle, project) {
46
+ if (String(row.id || '').toLowerCase().includes(needle)) { return true; }
47
+ if (String(row.tag || '').toLowerCase().includes(needle)) { return true; }
48
+ if (row.parentWave) { return false; }
49
+ // --all rows are stamped with their OWN project (enumerateAllProjects,
50
+ // read.js) — a cross-project wave/council row's material lives under THAT
51
+ // project, not the caller's cwd. Using the wrong one doesn't throw; it
52
+ // silently falls back to the low-fidelity excerpt/id-tag match instead of
53
+ // the full text, which is worse than a crash because nothing signals it.
54
+ const proj = row.project || project;
55
+ const material = row.type === 'council-run' ? councilSearchMaterial(proj, row.id)
56
+ : row.type === 'wave' ? waveSearchMaterial(proj, row.id, row.briefing) : row.briefing;
57
+ return String(material || '').toLowerCase().includes(needle);
58
+ }
59
+
60
+ // Case-insensitive substring filter behind both list surfaces (CLI `amicus list
61
+ // --search`, MCP `amicus_list {search}` — F8 D15, errata E-PR3-5), mirroring
62
+ // the models.js:70-73 idiom. Missing material never throws — it degrades to
63
+ // an id/tag-only match (read.js:58 idiom).
64
+ function searchSessions(rows, q, ctx) {
65
+ const needle = String(q).toLowerCase();
66
+ return rows.filter(row => rowMatchesSearch(row, needle, ctx && ctx.project));
67
+ }
68
+
69
+ module.exports = { searchSessions };
@@ -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,32 @@
1
+ /**
2
+ * @module sidecar/reopen-spend
3
+ * Spend finalization for a REOPENED session (continue/resume). Split out of
4
+ * sidecar/continue.js to keep that file under the 300-line gate; resume.js was
5
+ * already reaching across for it, so the shared home is the honest one.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ /**
11
+ * Resolve a reopened session's usage, write it onto metadata, and append one
12
+ * attributed ledger row. Mirrors start.js's finalize (the only sites that
13
+ * dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
14
+ * @returns {{usage: object|null}}
15
+ */
16
+ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
17
+ const { resolveUsage } = require('../utils/pricing');
18
+ const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
19
+ if (usage) {
20
+ metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
21
+ try {
22
+ const { appendSpend } = require('../utils/spend-ledger');
23
+ const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
24
+ // v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
25
+ // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
26
+ appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
27
+ } catch { /* best-effort */ }
28
+ }
29
+ return { usage };
30
+ }
31
+
32
+ module.exports = { finalizeSpendForReopen };
@@ -251,7 +251,7 @@ async function resumeSidecar(options) {
251
251
  // v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
252
252
  // a ledger row (status: statusFromResult, matching start.js — not terminal.status).
253
253
  {
254
- const { finalizeSpendForReopen } = require('./continue');
254
+ const { finalizeSpendForReopen } = require('./reopen-spend');
255
255
  const { statusFromResult } = require('../utils/result-schema');
256
256
  const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
257
257
  const { usage } = finalizeSpendForReopen({
@@ -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: [