amicus 4.4.0 → 4.5.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 (109) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +162 -0
  3. package/README.md +17 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/DISTRIBUTION.md +234 -0
  6. package/docs/ROADMAP.md +226 -0
  7. package/docs/SHIMS.md +62 -0
  8. package/docs/architecture.md +104 -0
  9. package/docs/configuration.md +395 -0
  10. package/docs/council.md +970 -0
  11. package/docs/doc-system.md +92 -0
  12. package/docs/electron-testing.md +471 -0
  13. package/docs/jsdoc-setup.md +75 -0
  14. package/docs/opencode-integration.md +114 -0
  15. package/docs/publishing.md +60 -0
  16. package/docs/schemas.md +56 -0
  17. package/docs/testing.md +589 -0
  18. package/docs/troubleshooting.md +298 -0
  19. package/docs/usage.md +849 -0
  20. package/electron/fold.js +1 -1
  21. package/electron/main.js +4 -1
  22. package/electron/setup-ui-aliases.js +6 -6
  23. package/electron/workspace-ui/live-model.js +12 -1
  24. package/electron/workspace-ui/md-lite.js +52 -8
  25. package/electron/workspace-ui/workspace-app.js +39 -17
  26. package/electron/workspace-ui/workspace-matrix.js +46 -9
  27. package/electron/workspace-ui/workspace-panels.js +88 -19
  28. package/electron/workspace-ui/workspace-render.js +17 -1
  29. package/electron/workspace-ui/workspace-verbs.js +48 -2
  30. package/package.json +8 -3
  31. package/schemas/council-run-live.schema.json +1 -1
  32. package/schemas/council-run.schema.json +34 -0
  33. package/schemas/error.schema.json +1 -1
  34. package/schemas/event.schema.json +1 -1
  35. package/schemas/pack.schema.json +30 -0
  36. package/schemas/progress.schema.json +13 -1
  37. package/schemas/run-live.schema.json +1 -1
  38. package/schemas/run.schema.json +2 -1
  39. package/schemas/spend.schema.json +52 -4
  40. package/schemas/wave-live.schema.json +1 -1
  41. package/schemas/wave.schema.json +2 -1
  42. package/skills/second-opinion/SKILL.md +5 -0
  43. package/src/cli-handlers-council-run.js +51 -8
  44. package/src/cli-handlers-pack.js +238 -0
  45. package/src/cli-handlers-run.js +36 -8
  46. package/src/cli-handlers-spend.js +20 -2
  47. package/src/cli-handlers-template.js +53 -0
  48. package/src/cli-handlers-watch.js +11 -0
  49. package/src/cli.js +68 -5
  50. package/src/council/briefings-debate.js +27 -7
  51. package/src/council/briefings-stage2.js +155 -25
  52. package/src/council/briefings.js +24 -1
  53. package/src/council/findings.js +199 -9
  54. package/src/council/parse-stage2.js +10 -2
  55. package/src/council/presets-cli.js +23 -11
  56. package/src/council/report.js +19 -8
  57. package/src/council/run-assemble.js +42 -1
  58. package/src/council/run-budget.js +64 -11
  59. package/src/council/run-chair.js +4 -1
  60. package/src/council/run-debate.js +4 -2
  61. package/src/council/run-finalize.js +102 -0
  62. package/src/council/run-launch.js +29 -1
  63. package/src/council/run-server.js +248 -0
  64. package/src/council/run-stage2.js +118 -0
  65. package/src/council/run-stages.js +134 -110
  66. package/src/council/run-state.js +40 -1
  67. package/src/council/run.js +45 -47
  68. package/src/council/tally.js +10 -0
  69. package/src/headless.js +180 -7
  70. package/src/mcp-council-run.js +108 -4
  71. package/src/mcp-server.js +203 -7
  72. package/src/mcp-tools.js +15 -5
  73. package/src/observe/council-legs.js +60 -3
  74. package/src/observe/live-doc.js +18 -1
  75. package/src/observe/watch-render.js +4 -1
  76. package/src/pack/pack-cli.js +38 -0
  77. package/src/pack/pack-forward.js +96 -0
  78. package/src/pack/pack-resolve.js +297 -0
  79. package/src/pack/pack-store.js +130 -0
  80. package/src/pack/pack-validate.js +113 -0
  81. package/src/sidecar/child-sessions.js +1 -2
  82. package/src/sidecar/fanout-leg-fallback.js +69 -21
  83. package/src/sidecar/fanout-leg.js +6 -0
  84. package/src/sidecar/fanout-signals.js +61 -0
  85. package/src/sidecar/fanout-wave-io.js +75 -0
  86. package/src/sidecar/fanout.js +82 -74
  87. package/src/sidecar/progress-fields.js +26 -4
  88. package/src/sidecar/progress.js +42 -1
  89. package/src/sidecar/session-utils.js +23 -14
  90. package/src/sidecar/start.js +5 -4
  91. package/src/sidecar/workspace-auto-open.js +69 -0
  92. package/src/sidecar/workspace-window.js +46 -1
  93. package/src/spend-query.js +17 -5
  94. package/src/template/apply.js +88 -0
  95. package/src/template/render.js +86 -0
  96. package/src/template/store.js +106 -0
  97. package/src/utils/config.js +65 -25
  98. package/src/utils/error-doc.js +5 -0
  99. package/src/utils/lifecycle.js +37 -1
  100. package/src/utils/path-fence.js +39 -1
  101. package/src/utils/pricing.js +26 -10
  102. package/src/utils/result-schema-rebuild.js +1 -0
  103. package/src/utils/result-schema.js +8 -2
  104. package/src/utils/server-setup.js +79 -1
  105. package/src/utils/spend-ledger.js +24 -3
  106. package/src/workspace/artifact-guard.js +66 -7
  107. package/src/workspace/fold-format.js +33 -4
  108. package/src/workspace/live-normalize.js +28 -15
  109. package/src/workspace/run-detail.js +13 -1
@@ -229,13 +229,14 @@ async function executeMode(options) {
229
229
  * has no single default `config.model`, so this registers ALL of them in provider.models
230
230
  * instead. Additive alongside the single-model `options.model` path used by owned-server
231
231
  * callers (start/continue); see opencode-client.js's buildServerOptions.
232
+ * @param {number} [options.retryDelayMs] - Test seam: collapse the lock-race backoff.
232
233
  * @returns {Promise<{client: object, server: object}>}
233
234
  * @throws {Error} If server fails to start or health check fails
234
235
  */
235
236
  async function startOpenCodeServer(mcpConfig, options = {}) {
236
237
  const { checkHealth, startServer } = require('../opencode-client');
237
238
  const { ensureNodeModulesBinInPath } = require('../utils/path-setup');
238
- const { ensurePortAvailable } = require('../utils/server-setup');
239
+ const { ensurePortAvailable, retryOnLockRace } = require('../utils/server-setup');
239
240
  const { waitForServer } = require('../headless');
240
241
 
241
242
  ensureNodeModulesBinInPath();
@@ -253,20 +254,28 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
253
254
  if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
254
255
  if (options.agentName) { serverOptions.agentName = options.agentName; }
255
256
 
256
- const { client, server } = await startServer(serverOptions);
257
- logger.debug('OpenCode server started', { url: server.url });
258
-
259
- const ready = await waitForServer(client, checkHealth);
260
- if (!ready) {
261
- // Fire-and-forget: today close() is sync (Promise.resolve wraps a
262
- // non-promise harmlessly); once close() becomes async (bounded
263
- // kill-escalation poll) this guard prevents an unhandled rejection
264
- // from racing the throw below.
265
- Promise.resolve(server.close()).catch(() => {});
266
- throw new Error('OpenCode server failed to become ready');
267
- }
257
+ // v4.4.1 Task 0.5: a LOCK-CLASS start failure is retried (5 attempts,
258
+ // 250/500/1000/2000ms widened from 3/750ms by Step 10.5, see server-setup).
259
+ // The per-run shared server removes the races a single amicus process creates;
260
+ // this covers the ones it cannot — two amicus processes, or a CLI run beside a
261
+ // live MCP server, sharing one OpenCode SQLite database. Nothing else retries:
262
+ // see isLockClassStartFailure in ../utils/server-setup.
263
+ return retryOnLockRace(async () => {
264
+ const { client, server } = await startServer(serverOptions);
265
+ logger.debug('OpenCode server started', { url: server.url });
266
+
267
+ const ready = await waitForServer(client, checkHealth);
268
+ if (!ready) {
269
+ // Fire-and-forget: today close() is sync (Promise.resolve wraps a
270
+ // non-promise harmlessly); once close() becomes async (bounded
271
+ // kill-escalation poll) this guard prevents an unhandled rejection
272
+ // from racing the throw below.
273
+ Promise.resolve(server.close()).catch(() => {});
274
+ throw new Error('OpenCode server failed to become ready');
275
+ }
268
276
 
269
- return { client, server };
277
+ return { client, server };
278
+ }, { retryDelayMs: options.retryDelayMs });
270
279
  }
271
280
 
272
281
  module.exports = {
@@ -35,7 +35,7 @@ function generateTaskId() {
35
35
 
36
36
  /** Create session directory and save metadata */
37
37
  function createSessionMetadata(taskId, project, options) {
38
- const { model, prompt, briefing, noUi, headless, agent, thinking } = options;
38
+ const { model, prompt, briefing, noUi, headless, agent, thinking, pack } = options;
39
39
 
40
40
  const sessionDir = SessionPaths.sessionDir(project, taskId);
41
41
  fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
@@ -65,7 +65,8 @@ function createSessionMetadata(taskId, project, options) {
65
65
  thinking: thinking || 'medium',
66
66
  status: 'running',
67
67
  pid: existing.pid || process.pid,
68
- createdAt: existing.createdAt || new Date().toISOString()
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.
69
70
  };
70
71
 
71
72
  writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
@@ -153,7 +154,7 @@ async function startSidecar(options) {
153
154
  contextMaxTokens = 80000, noUi, headless = false, timeout = 15,
154
155
  agent, mcp, mcpConfig, summaryLength = 'normal', thinking,
155
156
  client, sessionDir, noMcp, excludeMcp, opencodePort, coworkProcess, includeContext = true,
156
- position = 'right', json = false, modelInput = null
157
+ position = 'right', json = false, modelInput = null, pack = null
157
158
  } = options;
158
159
 
159
160
  const effectivePrompt = prompt || briefing;
@@ -182,7 +183,7 @@ async function startSidecar(options) {
182
183
  );
183
184
 
184
185
  const sessDir = createSessionMetadata(taskId, effectiveProject, {
185
- model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking
186
+ model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking, pack
186
187
  });
187
188
  saveInitialContext(sessDir, systemPrompt, userMessage);
188
189
  acquireLock(sessDir, effectiveHeadless ? 'headless' : 'interactive');
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Workspace Auto-Open Decision Helper
3
+ *
4
+ * Pure helper to determine whether the Council Workspace should auto-open
5
+ * on MCP council runs from Claude Code (local). Returns the decision and reason.
6
+ *
7
+ * Decision order (spec §6 guard 4):
8
+ * 1. uiParam === false → 'param-suppressed' (explicit user request beats everything, checked first)
9
+ * 2. Hard guards (always checked, beat even explicit true):
10
+ * - !electronUsable → 'electron-absent'
11
+ * - platform === 'linux' && !env.DISPLAY → 'no-display'
12
+ * 3. uiParam === true → 'ok' (explicit request overrides config and client gate, never hard guards)
13
+ * 4. autoOpenConfig === false → 'config-disabled'
14
+ * 5. client !== 'code-local' → 'client-not-code-local'
15
+ * 6. else → 'ok'
16
+ *
17
+ * @param {object} options
18
+ * @param {string} options.client - The client type (e.g., 'code-local', 'cowork', 'code-web')
19
+ * @param {boolean} options.electronUsable - Whether Electron is available
20
+ * @param {string} options.platform - The platform (e.g., 'win32', 'darwin', 'linux')
21
+ * @param {object} options.env - Environment variables object
22
+ * @param {boolean} options.autoOpenConfig - The config.workspace.autoOpen setting
23
+ * @param {boolean|undefined} options.uiParam - Explicit UI parameter (true, false, or undefined)
24
+ * @returns {{open: boolean, reason: string}}
25
+ */
26
+ function shouldAutoOpenWorkspace({
27
+ client,
28
+ electronUsable,
29
+ platform,
30
+ env,
31
+ autoOpenConfig,
32
+ uiParam,
33
+ }) {
34
+ // Step 1: uiParam === false beats everything (checked first)
35
+ if (uiParam === false) {
36
+ return { open: false, reason: 'param-suppressed' };
37
+ }
38
+
39
+ // Step 2: Hard guards (always checked, beat even explicit uiParam === true)
40
+ if (!electronUsable) {
41
+ return { open: false, reason: 'electron-absent' };
42
+ }
43
+
44
+ if (platform === 'linux' && !env.DISPLAY) {
45
+ return { open: false, reason: 'no-display' };
46
+ }
47
+
48
+ // Step 3: uiParam === true overrides config and client gate (but not hard guards)
49
+ if (uiParam === true) {
50
+ return { open: true, reason: 'ok' };
51
+ }
52
+
53
+ // Step 4: autoOpenConfig === false
54
+ if (autoOpenConfig === false) {
55
+ return { open: false, reason: 'config-disabled' };
56
+ }
57
+
58
+ // Step 5: client !== 'code-local'
59
+ if (client !== 'code-local') {
60
+ return { open: false, reason: 'client-not-code-local' };
61
+ }
62
+
63
+ // Step 6: else
64
+ return { open: true, reason: 'ok' };
65
+ }
66
+
67
+ module.exports = {
68
+ shouldAutoOpenWorkspace,
69
+ };
@@ -59,4 +59,49 @@ async function launchWorkspaceWindow({ project, runId = '' }, deps = {}) {
59
59
  });
60
60
  }
61
61
 
62
- module.exports = { launchWorkspaceWindow };
62
+ /**
63
+ * v4.5 auto-open: fire-and-forget Workspace launch for the MCP path.
64
+ * DELIBERATELY different from launchWorkspaceWindow above:
65
+ * - never provisions (isElectronUsable check only — ensureElectron can
66
+ * DOWNLOAD Electron, which auto-open guard 3 forbids);
67
+ * - no stdout relay (the caller may be an MCP stdio server whose stdout IS
68
+ * the JSON-RPC channel — relaying would corrupt the protocol);
69
+ * - detached + unref, returns immediately (the sibling's promise resolves
70
+ * only when the window CLOSES, which no request path may wait on).
71
+ * @param {{project: string, runId?: string}} opts
72
+ * @param {{isElectronUsable?: Function, resolveElectronBinary?: Function, spawn?: Function}} [deps]
73
+ * @returns {{launched: boolean, reason?: string}}
74
+ */
75
+ function launchWorkspaceWindowDetached({ project, runId = '' }, deps = {}) {
76
+ const usable = deps.isElectronUsable || require('./electron-install').isElectronUsable;
77
+ const resolveExe = deps.resolveElectronBinary || require('./electron-install').resolveElectronBinary;
78
+ const spawnFn = deps.spawn || spawn;
79
+ if (!usable()) { return { launched: false, reason: 'electron-absent' }; }
80
+ const electronPath = resolveExe() || getElectronPath();
81
+ const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
82
+ const env = {
83
+ ...process.env,
84
+ AMICUS_MODE: 'council-workspace',
85
+ AMICUS_PROJECT: project,
86
+ AMICUS_RUN_ID: runId || '',
87
+ AMICUS_FOLD_NONCE: generateFoldNonce(),
88
+ };
89
+ try {
90
+ const proc = spawnFn(electronPath, [mainPath], { env, detached: true, stdio: 'ignore' });
91
+ // Node emits spawn failures (ENOENT/EACCES/corrupt binary) as an async
92
+ // 'error' event on the child; an unlistened ChildProcess 'error' is an
93
+ // uncaught exception. The MCP server (`amicus mcp`) installs no
94
+ // uncaughtException handler (bin/amicus.js only does that for
95
+ // start/continue), so that would kill the JSON-RPC channel. Best-effort:
96
+ // just log it, matching the fire-and-forget contract of this function.
97
+ proc.on('error', (err) => logger.debug('Workspace auto-open child failed (best-effort)', { error: err.message }));
98
+ proc.unref();
99
+ logger.info('Auto-opened council workspace (detached)', { runId: runId || '(run list)' });
100
+ return { launched: true };
101
+ } catch (err) {
102
+ logger.debug('Workspace auto-open spawn failed (best-effort)', { error: err.message });
103
+ return { launched: false, reason: `spawn-failed: ${err.message}` };
104
+ }
105
+ }
106
+
107
+ module.exports = { launchWorkspaceWindow, launchWorkspaceWindowDetached };
@@ -60,25 +60,32 @@ function rowKey(row, dimension) {
60
60
  }
61
61
 
62
62
  /**
63
- * Group rows into {key, amount, tokens, runs, unpricedRows, sourceMix},
64
- * most-expensive first.
63
+ * Group rows into {key, amount, tokens, runs, unpricedRows,
64
+ * unattributedSubtreeRows, sourceMix}, most-expensive first.
65
65
  *
66
66
  * v4.4: `amount` deliberately stays a plain number — the published
67
67
  * spend.schema.json pins `groups[].amount` to `type: "number"` — so
68
68
  * `unpricedRows` is how a group says "this figure omits N rows we cannot
69
69
  * price". Without it, a group of entirely unpriced rows was indistinguishable
70
70
  * from a group that genuinely cost $0 (diagnosis §8).
71
+ *
72
+ * v4.4.1 CA-2: `unattributedSubtreeRows` is the SECOND way a figure can be a
73
+ * floor, and `unpricedRows` structurally cannot see it — such a row IS priced,
74
+ * lands in the `r` source bucket, and contributes its own cost to `amount`; what
75
+ * is missing is the child session it spawned. The two counters are incremented
76
+ * BESIDE each other, never instead of: a row can be both.
71
77
  */
72
78
  function groupRows(rows, dimension) {
73
79
  const map = new Map();
74
80
  for (const r of rows) {
75
81
  const key = rowKey(r, dimension);
76
- if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
82
+ if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
77
83
  const b = map.get(key);
78
84
  b.runs += 1;
79
85
  addTokens(b.tokens, r.tokens);
80
86
  const cost = r.cost || {};
81
87
  if (typeof cost.amount === 'number') { b.amount += cost.amount; } else { b.unpricedRows += 1; }
88
+ if (r.subtreeUnknown) { b.unattributedSubtreeRows += 1; }
82
89
  const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
83
90
  b.sourceMix[src] += 1;
84
91
  }
@@ -96,7 +103,7 @@ function groupRows(rows, dimension) {
96
103
  * a row); computeWasted intentionally drops it instead.
97
104
  */
98
105
  function computeWasted(rows) {
99
- const out = { amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, byStatus: {} };
106
+ const out = { amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0, byStatus: {} };
100
107
  for (const r of rows) {
101
108
  if (r.status === 'complete' || !r.status) { continue; }
102
109
  out.runs += 1;
@@ -104,14 +111,19 @@ function computeWasted(rows) {
104
111
  // v4.4: null→0 here is arithmetic, not a claim. `unpricedRows` records how
105
112
  // many failed rows we could not price so "wasted $X" is never mistaken for
106
113
  // the whole loss (see groupRows for why `amount` stays a number).
114
+ // v4.4.1 CA-2: `unattributedSubtreeRows` is the other half of the same
115
+ // understatement — a failed leg that DID resolve its own cost but left a
116
+ // child session unpriced. Counted beside `unpricedRows`, never instead of.
107
117
  const priced = r.cost && typeof r.cost.amount === 'number';
108
118
  const amt = priced ? r.cost.amount : 0;
109
119
  if (!priced) { out.unpricedRows += 1; }
120
+ if (r.subtreeUnknown) { out.unattributedSubtreeRows += 1; }
110
121
  out.amount += amt;
111
- if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0, unpricedRows: 0 }; }
122
+ if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0 }; }
112
123
  out.byStatus[r.status].amount += amt;
113
124
  out.byStatus[r.status].runs += 1;
114
125
  if (!priced) { out.byStatus[r.status].unpricedRows += 1; }
126
+ if (r.subtreeUnknown) { out.byStatus[r.status].unattributedSubtreeRows += 1; }
115
127
  }
116
128
  return out;
117
129
  }
@@ -0,0 +1,88 @@
1
+ // src/template/apply.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module template/apply
6
+ * F9 (v4.5): the one seam that turns (--template, --prompt, --artifact, --var)
7
+ * into a rendered briefing + template-sourced promptMeta. Used by the three CLI
8
+ * run commands and by pack-resolve for a pack's briefing.template (which is how
9
+ * templates reach MCP callers — MCP has no template params of its own).
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { ERROR_CODES } = require('../utils/error-doc');
15
+ const { resolveTemplate } = require('./store');
16
+ const { renderTemplate } = require('./render');
17
+
18
+ const ARTIFACT_CAP_BYTES = 256 * 1024;
19
+
20
+ /**
21
+ * @param {{templateRef: string, prompt?: string, artifactFile?: string,
22
+ * varList?: string[], project: string}} opts
23
+ * @returns {{prompt, promptMeta, notices} | {error: {code, message, hint}}}
24
+ */
25
+ function applyTemplate({ templateRef, prompt, artifactFile, varList, project }) {
26
+ const tpl = resolveTemplate(templateRef);
27
+ if (tpl.error) {
28
+ return { error: { code: ERROR_CODES.TEMPLATE_NOT_FOUND, message: tpl.error, hint: 'amicus template list' } };
29
+ }
30
+
31
+ const vars = {};
32
+ // F4 (Task-5 review): parseArgs' inline `--var=k=v` form takes the single-value
33
+ // branch, not the array-accumulation one, so varList arrives as a bare string
34
+ // instead of a one-element array — wrap it rather than let `for..of` iterate
35
+ // its characters. parseArgs itself is unchanged (plan-mandated, shared with
36
+ // --exclude-mcp); this coercion is the seam that absorbs both shapes.
37
+ const varArr = Array.isArray(varList)
38
+ ? varList
39
+ : (varList !== undefined && varList !== null) ? [varList] : [];
40
+ for (const entry of varArr) {
41
+ const eq = String(entry).indexOf('=');
42
+ if (eq < 1) {
43
+ return { error: { code: ERROR_CODES.BAD_ARGS, message: `Error: --var expects key=value, got '${entry}'`, hint: null } };
44
+ }
45
+ vars[String(entry).slice(0, eq)] = String(entry).slice(eq + 1);
46
+ }
47
+
48
+ let artifact; let artifactPath;
49
+ if (artifactFile !== undefined) {
50
+ artifactPath = path.resolve(String(artifactFile));
51
+ let raw;
52
+ try {
53
+ raw = fs.readFileSync(artifactPath);
54
+ } catch (err) {
55
+ return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: cannot read --artifact ${artifactFile}: ${err.message}`, hint: null } };
56
+ }
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 } };
59
+ }
60
+ artifact = raw.toString('utf-8');
61
+ if (artifact.charCodeAt(0) === 0xFEFF) { artifact = artifact.slice(1); }
62
+ }
63
+
64
+ const res = renderTemplate(tpl.text, {
65
+ prompt,
66
+ artifact,
67
+ artifactPath,
68
+ date: new Date().toISOString().slice(0, 10),
69
+ project: String(project),
70
+ vars,
71
+ });
72
+ if (res.error) {
73
+ return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: res.error, hint: null } };
74
+ }
75
+
76
+ return {
77
+ prompt: res.text,
78
+ promptMeta: {
79
+ source: 'template',
80
+ file: tpl.path,
81
+ chars: res.text.length,
82
+ template: { name: tpl.name, hash: tpl.hash },
83
+ },
84
+ notices: res.notices,
85
+ };
86
+ }
87
+
88
+ module.exports = { applyTemplate, ARTIFACT_CAP_BYTES };
@@ -0,0 +1,86 @@
1
+ // src/template/render.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module template/render
6
+ * F9 (v4.5): strict {{variable}} rendering for briefing templates. Expansion
7
+ * happens ONLY in template files (spec carried decision 7) — --prompt text is
8
+ * always literal, so this module never sees non-template input.
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.
12
+ * No {{model}}: prompts are built once per wave, model-independent.
13
+ */
14
+
15
+ const VAR_RE = /\{\{\s*([A-Za-z_][\w.]*)\s*\}\}/g;
16
+ const KNOWN_VARIABLES = ['prompt', 'artifact', 'artifact_path', 'date', 'project', 'var.<key>'];
17
+
18
+ function knownList() {
19
+ return KNOWN_VARIABLES.map((v) => `{{${v}}}`).join(', ');
20
+ }
21
+
22
+ /**
23
+ * Render a template with strict typo-safety rules:
24
+ * unknown variable -> error; slot present without its data -> error; data
25
+ * passed without its slot -> error ("silently dropped"); unused --var -> notice.
26
+ *
27
+ * @param {string} text - raw template text
28
+ * @param {{prompt?: string, artifact?: string, artifactPath?: string,
29
+ * date: string, project: string, vars?: Object<string,string>}} data
30
+ * @returns {{text: string, notices: string[]} | {error: string}}
31
+ */
32
+ function renderTemplate(text, data) {
33
+ const vars = data.vars || {};
34
+ const used = new Set();
35
+ for (const m of String(text).matchAll(VAR_RE)) { used.add(m[1]); }
36
+
37
+ for (const name of used) {
38
+ if (name.startsWith('var.')) {
39
+ const key = name.slice(4);
40
+ if (!(key in vars)) {
41
+ return { error: `Error: template uses {{var.${key}}} but no --var ${key}=<value> was given` };
42
+ }
43
+ continue;
44
+ }
45
+ if (!['prompt', 'artifact', 'artifact_path', 'date', 'project'].includes(name)) {
46
+ return { error: `Error: Unknown template variable {{${name}}}. Known: ${knownList()}` };
47
+ }
48
+ }
49
+
50
+ if (used.has('prompt') && data.prompt === undefined) {
51
+ return { error: 'Error: template has {{prompt}} but no --prompt/--prompt-file was given' };
52
+ }
53
+ if (!used.has('prompt') && data.prompt !== undefined) {
54
+ return { error: 'Error: --prompt/--prompt-file was given but the template has no {{prompt}} slot — the text would be silently dropped' };
55
+ }
56
+ const usesArtifact = used.has('artifact') || used.has('artifact_path');
57
+ if (used.has('artifact') && data.artifact === undefined) {
58
+ return { error: 'Error: template has {{artifact}} but no --artifact <file> was given' };
59
+ }
60
+ if (used.has('artifact_path') && data.artifactPath === undefined) {
61
+ return { error: 'Error: template has {{artifact_path}} but no --artifact <file> was given' };
62
+ }
63
+ if (!usesArtifact && (data.artifact !== undefined || data.artifactPath !== undefined)) {
64
+ return { error: 'Error: --artifact was given but the template has no {{artifact}}/{{artifact_path}} slot — the file would be silently dropped' };
65
+ }
66
+
67
+ const notices = [];
68
+ for (const key of Object.keys(vars)) {
69
+ if (!used.has(`var.${key}`)) {
70
+ notices.push(`Notice: --var ${key}=… is not used by this template`);
71
+ }
72
+ }
73
+
74
+ const rendered = String(text).replace(VAR_RE, (_, name) => {
75
+ if (name === 'prompt') { return data.prompt; }
76
+ if (name === 'artifact') { return data.artifact; }
77
+ if (name === 'artifact_path') { return data.artifactPath; }
78
+ if (name === 'date') { return data.date; }
79
+ if (name === 'project') { return data.project; }
80
+ return vars[name.slice(4)];
81
+ });
82
+
83
+ return { text: rendered, notices };
84
+ }
85
+
86
+ module.exports = { renderTemplate, KNOWN_VARIABLES };
@@ -0,0 +1,106 @@
1
+ // src/template/store.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module template/store
6
+ * F9 (v4.5): briefing templates are Markdown files in <configDir>/templates/
7
+ * (peer of packs/). Name = basename sans .md. Built-ins are embedded strings,
8
+ * shadowed by a same-named user file — exactly the built-in-bench precedent
9
+ * (config.js getCouncilWithSource). No save/rm: your editor is the manager.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+
16
+ // Lazy so jest.doMock / AMICUS_CONFIG_DIR re-pointing works per-test.
17
+ function _getConfigDir() { return require('../utils/config').getConfigDir(); }
18
+
19
+ /**
20
+ * v4.5 ships `review` only. `critique`/`refine` are {{input}}-centric and
21
+ * arrive with v4.6's chaining (--input-from).
22
+ */
23
+ const BUILTIN_TEMPLATES = Object.freeze({
24
+ review: [
25
+ '# Review briefing',
26
+ '',
27
+ 'You are reviewing the artifact below against the caller\'s focus.',
28
+ '',
29
+ '## Focus',
30
+ '',
31
+ '{{prompt}}',
32
+ '',
33
+ '## Artifact ({{artifact_path}})',
34
+ '',
35
+ '{{artifact}}',
36
+ '',
37
+ '## Instructions',
38
+ '',
39
+ '- Ground every finding in the artifact text and cite its location.',
40
+ '- Give each finding a severity: critical / major / minor / nit.',
41
+ '- If you find nothing at a severity, say so explicitly.',
42
+ '- End with a one-paragraph overall verdict.',
43
+ '',
44
+ ].join('\n'),
45
+ });
46
+
47
+ /** @returns {string} the user templates directory (peer of packs/) */
48
+ function templatesDir() {
49
+ return path.join(_getConfigDir(), 'templates');
50
+ }
51
+
52
+ function hashText(text) {
53
+ return crypto.createHash('sha256').update(text, 'utf-8').digest('hex').slice(0, 12);
54
+ }
55
+
56
+ function stripBom(text) {
57
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
58
+ }
59
+
60
+ /**
61
+ * @param {string} nameOrPath - a path when it contains a path separator or
62
+ * ends in `.md`; otherwise a template name.
63
+ * @returns {{name, path: string|null, text, hash, builtin: boolean} | {error: string}}
64
+ */
65
+ function resolveTemplate(nameOrPath) {
66
+ const v = String(nameOrPath);
67
+ const isPath = v.endsWith('.md') || v.includes('/') || v.includes(path.sep);
68
+ if (isPath) {
69
+ const abs = path.resolve(v);
70
+ let text;
71
+ try {
72
+ text = stripBom(fs.readFileSync(abs, 'utf-8'));
73
+ } catch (err) {
74
+ return { error: `Error: cannot read template ${v}: ${err.message}` };
75
+ }
76
+ return { name: path.basename(abs, '.md'), path: abs, text, hash: hashText(text), builtin: false };
77
+ }
78
+ const userFile = path.join(templatesDir(), `${v}.md`);
79
+ try {
80
+ const text = stripBom(fs.readFileSync(userFile, 'utf-8'));
81
+ return { name: v, path: userFile, text, hash: hashText(text), builtin: false };
82
+ } catch { /* fall through to built-ins */ }
83
+ if (Object.prototype.hasOwnProperty.call(BUILTIN_TEMPLATES, v)) {
84
+ const text = BUILTIN_TEMPLATES[v];
85
+ return { name: v, path: null, text, hash: hashText(text), builtin: true };
86
+ }
87
+ return { error: `Error: Template '${v}' not found (looked in ${templatesDir()} and built-ins)` };
88
+ }
89
+
90
+ /** @returns {Array<{name, builtin: boolean, shadowed: boolean}>} name-sorted */
91
+ function listTemplates() {
92
+ const out = new Map();
93
+ for (const name of Object.keys(BUILTIN_TEMPLATES)) {
94
+ out.set(name, { name, builtin: true, shadowed: false });
95
+ }
96
+ let entries = [];
97
+ try { entries = fs.readdirSync(templatesDir()); } catch { /* no user dir yet */ }
98
+ for (const f of entries) {
99
+ if (!f.endsWith('.md')) { continue; }
100
+ const name = path.basename(f, '.md');
101
+ out.set(name, { name, builtin: false, shadowed: Object.prototype.hasOwnProperty.call(BUILTIN_TEMPLATES, name) });
102
+ }
103
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
104
+ }
105
+
106
+ module.exports = { templatesDir, resolveTemplate, listTemplates, BUILTIN_TEMPLATES };