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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +162 -0
- package/README.md +17 -2
- package/bin/amicus.js +10 -0
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +226 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +395 -0
- package/docs/council.md +970 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +56 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +849 -0
- package/electron/fold.js +1 -1
- package/electron/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +88 -19
- package/electron/workspace-ui/workspace-render.js +17 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +13 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +52 -4
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-template.js +53 -0
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +68 -5
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +24 -1
- package/src/council/findings.js +199 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/presets-cli.js +23 -11
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +64 -11
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +134 -110
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +45 -47
- package/src/council/tally.js +10 -0
- package/src/headless.js +180 -7
- package/src/mcp-council-run.js +108 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +82 -74
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +42 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +69 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/spend-query.js +17 -5
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/error-doc.js +5 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +66 -7
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- 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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
277
|
+
return { client, server };
|
|
278
|
+
}, { retryDelayMs: options.retryDelayMs });
|
|
270
279
|
}
|
|
271
280
|
|
|
272
281
|
module.exports = {
|
package/src/sidecar/start.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/spend-query.js
CHANGED
|
@@ -60,25 +60,32 @@ function rowKey(row, dimension) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Group rows into {key, amount, tokens, runs, unpricedRows,
|
|
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 };
|