amicus 4.4.1 → 4.5.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +154 -0
- package/README.md +15 -2
- package/bin/amicus.js +10 -0
- package/docs/ROADMAP.md +38 -14
- package/docs/configuration.md +24 -0
- package/docs/council.md +62 -0
- package/docs/schemas.md +1 -0
- package/docs/usage.md +151 -1
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-panels.js +76 -18
- package/electron/workspace-ui/workspace-render.js +10 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +14 -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 +1 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- 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-doctor.js +10 -0
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-template.js +53 -0
- package/src/cli.js +64 -3
- package/src/council/findings.js +4 -41
- package/src/council/presets-cli.js +23 -11
- package/src/council/run-stages.js +12 -9
- package/src/council/run-state.js +17 -0
- package/src/council/run.js +1 -1
- package/src/headless.js +18 -14
- package/src/mcp-council-run.js +110 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- 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/electron-state.js +61 -0
- package/src/sidecar/fanout.js +21 -4
- package/src/sidecar/progress.js +34 -0
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +83 -0
- package/src/sidecar/workspace-window.js +46 -1
- 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/doctor-electron-mcp-check.js +150 -0
- package/src/utils/error-doc.js +5 -0
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/workspace/artifact-guard.js +44 -6
- package/src/workspace/run-detail.js +6 -0
package/src/sidecar/progress.js
CHANGED
|
@@ -130,6 +130,39 @@ function writeProgress(sessionDir, stage, extra = {}) {
|
|
|
130
130
|
writeFileAtomic(progressPath, JSON.stringify(data), { mode: 0o600 });
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* FR-1 (v4.5): stamp a TERMINAL stage into progress.json on a failure path,
|
|
135
|
+
* preserving previously recorded usage. Extracted from headless.js's A3 outer
|
|
136
|
+
* catch so the early-return paths and the exception path can never drift.
|
|
137
|
+
*
|
|
138
|
+
* writeProgress REBUILDS progress.json (no merge) — a bare terminal write would
|
|
139
|
+
* delete whatever real spend the last flush recorded, trading a stale-stage bug
|
|
140
|
+
* for a cost under-report on exactly the legs that failed. So the prior usage is
|
|
141
|
+
* read back and re-attached; `extra` fields beyond usage are deliberately
|
|
142
|
+
* dropped (readProgress derives message counts from conversation.jsonl).
|
|
143
|
+
*
|
|
144
|
+
* Runs on error paths: must never throw and never mask the original error.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} sessionDir
|
|
147
|
+
* @param {string} errorMessage - the failure being recorded (stage derives from it)
|
|
148
|
+
* @returns {boolean} true if the terminal record was written
|
|
149
|
+
*/
|
|
150
|
+
function writeTerminalProgressSafe(sessionDir, errorMessage) {
|
|
151
|
+
try {
|
|
152
|
+
let priorUsage = null;
|
|
153
|
+
try {
|
|
154
|
+
const prior = JSON.parse(fs.readFileSync(path.join(sessionDir, 'progress.json'), 'utf-8'));
|
|
155
|
+
if (prior && prior.usage) { priorUsage = prior.usage; }
|
|
156
|
+
} catch { /* no readable prior record: write the terminal stage without usage */ }
|
|
157
|
+
const { resolveTerminalState } = require('./session-finalize');
|
|
158
|
+
const stage = resolveTerminalState({ error: errorMessage }).status;
|
|
159
|
+
writeProgress(sessionDir, stage, priorUsage ? { usage: priorUsage } : {});
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
/**
|
|
134
167
|
* Read progress from a session's conversation.jsonl and progress.json files.
|
|
135
168
|
*
|
|
@@ -242,6 +275,7 @@ function readProgress(sessionDir) {
|
|
|
242
275
|
module.exports = {
|
|
243
276
|
readProgress,
|
|
244
277
|
writeProgress,
|
|
278
|
+
writeTerminalProgressSafe,
|
|
245
279
|
extractLatest,
|
|
246
280
|
computeLastActivity,
|
|
247
281
|
STAGE_LABELS,
|
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,83 @@
|
|
|
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
|
+
* - electron package-missing → 'electron-absent'
|
|
11
|
+
* - electron binary-missing → 'electron-broken: …' (#76: package present but
|
|
12
|
+
* the exe never arrived — a repairable state the old boolean conflated
|
|
13
|
+
* with never-installed; the reason names the dir and the fix)
|
|
14
|
+
* - platform === 'linux' && !env.DISPLAY → 'no-display'
|
|
15
|
+
* 3. uiParam === true → 'ok' (explicit request overrides config and client gate, never hard guards)
|
|
16
|
+
* 4. autoOpenConfig === false → 'config-disabled'
|
|
17
|
+
* 5. client !== 'code-local' → 'client-not-code-local'
|
|
18
|
+
* 6. else → 'ok'
|
|
19
|
+
*
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {string} options.client - The client type (e.g., 'code-local', 'cowork', 'code-web')
|
|
22
|
+
* @param {boolean} [options.electronUsable] - Legacy boolean probe (used only when electronState is absent)
|
|
23
|
+
* @param {'ok'|'package-missing'|'binary-missing'} [options.electronState] - 3-state probe (#76); takes precedence
|
|
24
|
+
* @param {string|null} [options.electronDir] - Resolved electron dir, named in the electron-broken reason
|
|
25
|
+
* @param {string} options.platform - The platform (e.g., 'win32', 'darwin', 'linux')
|
|
26
|
+
* @param {object} options.env - Environment variables object
|
|
27
|
+
* @param {boolean} options.autoOpenConfig - The config.workspace.autoOpen setting
|
|
28
|
+
* @param {boolean|undefined} options.uiParam - Explicit UI parameter (true, false, or undefined)
|
|
29
|
+
* @returns {{open: boolean, reason: string}}
|
|
30
|
+
*/
|
|
31
|
+
function shouldAutoOpenWorkspace({
|
|
32
|
+
client,
|
|
33
|
+
electronUsable,
|
|
34
|
+
electronState,
|
|
35
|
+
electronDir,
|
|
36
|
+
platform,
|
|
37
|
+
env,
|
|
38
|
+
autoOpenConfig,
|
|
39
|
+
uiParam,
|
|
40
|
+
}) {
|
|
41
|
+
// Step 1: uiParam === false beats everything (checked first)
|
|
42
|
+
if (uiParam === false) {
|
|
43
|
+
return { open: false, reason: 'param-suppressed' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Step 2: Hard guards (always checked, beat even explicit uiParam === true).
|
|
47
|
+
// electronState (3-state, #76) wins over the legacy electronUsable boolean,
|
|
48
|
+
// whose false collapses to package-missing (the old 'electron-absent').
|
|
49
|
+
const state = electronState || (electronUsable ? 'ok' : 'package-missing');
|
|
50
|
+
if (state === 'package-missing') {
|
|
51
|
+
return { open: false, reason: 'electron-absent' };
|
|
52
|
+
}
|
|
53
|
+
if (state === 'binary-missing') {
|
|
54
|
+
const where = electronDir ? ` under ${electronDir}` : '';
|
|
55
|
+
return { open: false, reason: `electron-broken: binary missing${where} — run \`amicus doctor --fix\`` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (platform === 'linux' && !env.DISPLAY) {
|
|
59
|
+
return { open: false, reason: 'no-display' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 3: uiParam === true overrides config and client gate (but not hard guards)
|
|
63
|
+
if (uiParam === true) {
|
|
64
|
+
return { open: true, reason: 'ok' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Step 4: autoOpenConfig === false
|
|
68
|
+
if (autoOpenConfig === false) {
|
|
69
|
+
return { open: false, reason: 'config-disabled' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Step 5: client !== 'code-local'
|
|
73
|
+
if (client !== 'code-local') {
|
|
74
|
+
return { open: false, reason: 'client-not-code-local' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Step 6: else
|
|
78
|
+
return { open: true, reason: 'ok' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
shouldAutoOpenWorkspace,
|
|
83
|
+
};
|
|
@@ -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 };
|
|
@@ -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 };
|
package/src/utils/config.js
CHANGED
|
@@ -401,14 +401,57 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
401
401
|
return { members: null, builtin: false };
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Per-member alias/catalog classification — the SAME check `resolveCouncilMembers`
|
|
406
|
+
* (below) uses to decide the real run path's bench, extracted so `amicus council
|
|
407
|
+
* show` (council/presets-cli.js) can reuse it verbatim instead of re-deriving a
|
|
408
|
+
* parallel (and, pre-v4.5-Wave-2, drifted) check. Each member is resolved to its
|
|
409
|
+
* full model id (alias → id via effective aliases; a member containing '/' is
|
|
410
|
+
* taken as-is) and that id checked against the cached catalog. Tri-state catalog
|
|
411
|
+
* rule: an EMPTY catalog (offline / never fetched) never drops anything —
|
|
412
|
+
* "unknown" is not "delisted" — and a local-vendor member is never dropped on
|
|
413
|
+
* catalog absence either way (v4.2 §4.4: a local server may simply have been off
|
|
414
|
+
* at the last refresh; the leg itself fails pre-flight with the actionable
|
|
415
|
+
* local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
|
|
416
|
+
* catalog that omits the resolved id is a definitive drop.
|
|
417
|
+
* @param {string[]} members raw council members (aliases or provider/model ids)
|
|
418
|
+
* @param {Array<{id:string}>} [catalog]
|
|
419
|
+
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
|
|
420
|
+
* `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
|
|
421
|
+
* callers keep working); `droppedMembers` additively pairs each with WHY.
|
|
422
|
+
*/
|
|
423
|
+
function classifyCouncilMembers(members, catalog = []) {
|
|
424
|
+
const aliases = getEffectiveAliases();
|
|
425
|
+
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
426
|
+
const { isLocalProvider } = require('./local-providers');
|
|
427
|
+
const models = [];
|
|
428
|
+
const dropped = [];
|
|
429
|
+
const droppedMembers = [];
|
|
430
|
+
for (const member of members) {
|
|
431
|
+
const id = member.includes('/') ? member : aliases[member];
|
|
432
|
+
if (!id) { // alias no longer resolves
|
|
433
|
+
dropped.push(member);
|
|
434
|
+
droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const vendor = typeof id === 'string' ? id.split('/')[0] : '';
|
|
438
|
+
if (isLocalProvider(vendor)) { models.push(member); continue; }
|
|
439
|
+
if (known.size > 0 && !known.has(id)) { // delisted model
|
|
440
|
+
dropped.push(member);
|
|
441
|
+
droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
models.push(member);
|
|
445
|
+
}
|
|
446
|
+
return { models, dropped, droppedMembers };
|
|
447
|
+
}
|
|
448
|
+
|
|
404
449
|
/**
|
|
405
450
|
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* skipped when the catalog is empty (offline). Returns members RAW (alias or
|
|
411
|
-
* id) — leg-time validation resolves them again.
|
|
451
|
+
* Unresolvable aliases and delisted ids are dropped with a warning rather than
|
|
452
|
+
* fail-fast-aborting the whole wave (classification: classifyCouncilMembers
|
|
453
|
+
* above). Returns members RAW (alias or id) — leg-time validation resolves
|
|
454
|
+
* them again.
|
|
412
455
|
*
|
|
413
456
|
* Resolution order: user config (`config.councils`) is checked first; when
|
|
414
457
|
* `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
|
|
@@ -416,7 +459,7 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
416
459
|
* shadows a built-in of the same name.
|
|
417
460
|
* @param {string} name
|
|
418
461
|
* @param {Array<{id:string}>} [catalog]
|
|
419
|
-
* @returns {{models:string[], dropped:string[]} | {error:string}}
|
|
462
|
+
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>} | {error:string}}
|
|
420
463
|
*/
|
|
421
464
|
function resolveCouncilMembers(name, catalog = []) {
|
|
422
465
|
const { members } = getCouncilWithSource(name, catalog);
|
|
@@ -426,23 +469,7 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
426
469
|
if (!Array.isArray(members) || members.length === 0) {
|
|
427
470
|
return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
|
|
428
471
|
}
|
|
429
|
-
const
|
|
430
|
-
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
431
|
-
const { isLocalProvider } = require('./local-providers');
|
|
432
|
-
const models = [];
|
|
433
|
-
const dropped = [];
|
|
434
|
-
for (const member of members) {
|
|
435
|
-
const id = member.includes('/') ? member : aliases[member];
|
|
436
|
-
if (!id) { dropped.push(member); continue; } // alias no longer resolves
|
|
437
|
-
const vendor = typeof id === 'string' ? id.split('/')[0] : '';
|
|
438
|
-
// v4.2 §4.4: a local server may simply have been off at the last catalog
|
|
439
|
-
// refresh — that is "unknown", not "delisted". Never drop a local-vendor
|
|
440
|
-
// member on catalog absence; the leg itself fails pre-flight with the
|
|
441
|
-
// actionable local_endpoint_unreachable error if the server is truly down.
|
|
442
|
-
if (isLocalProvider(vendor)) { models.push(member); continue; }
|
|
443
|
-
if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
|
|
444
|
-
models.push(member);
|
|
445
|
-
}
|
|
472
|
+
const { models, dropped, droppedMembers } = classifyCouncilMembers(members, catalog);
|
|
446
473
|
if (models.length < 2) {
|
|
447
474
|
return {
|
|
448
475
|
error: `Council '${name}' has fewer than 2 usable members` +
|
|
@@ -450,7 +477,7 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
450
477
|
'. Run \'amicus setup\' to refresh it.',
|
|
451
478
|
};
|
|
452
479
|
}
|
|
453
|
-
return { models, dropped };
|
|
480
|
+
return { models, dropped, droppedMembers };
|
|
454
481
|
}
|
|
455
482
|
|
|
456
483
|
/** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
|
|
@@ -503,6 +530,17 @@ function hasTierOnboarded() {
|
|
|
503
530
|
return !!(config.routing && config.routing.tier_onboarded === true);
|
|
504
531
|
}
|
|
505
532
|
|
|
533
|
+
/**
|
|
534
|
+
* v4.5 auto-open (spec §6 guard 4): the Workspace auto-opens on MCP council
|
|
535
|
+
* runs from Claude Code (local) unless config.workspace.autoOpen === false. Only an
|
|
536
|
+
* explicit false disables — absent/junk values stay ON (opt-out semantics).
|
|
537
|
+
* @returns {boolean}
|
|
538
|
+
*/
|
|
539
|
+
function getWorkspaceAutoOpen() {
|
|
540
|
+
const config = loadConfig() || {};
|
|
541
|
+
return !(config.workspace && config.workspace.autoOpen === false);
|
|
542
|
+
}
|
|
543
|
+
|
|
506
544
|
/**
|
|
507
545
|
* Persist the one-time onboarding-notice flag, preserving any other routing
|
|
508
546
|
* keys (prefer, tier, migration_notified). Best-effort: swallows any
|
|
@@ -562,6 +600,7 @@ module.exports = {
|
|
|
562
600
|
getCouncils,
|
|
563
601
|
getCouncil,
|
|
564
602
|
getCouncilWithSource,
|
|
603
|
+
classifyCouncilMembers,
|
|
565
604
|
resolveCouncilMembers,
|
|
566
605
|
getRoutingConfig,
|
|
567
606
|
resolveGatewayMode,
|
|
@@ -571,4 +610,5 @@ module.exports = {
|
|
|
571
610
|
setCostTier,
|
|
572
611
|
hasTierOnboarded,
|
|
573
612
|
markTierOnboarded,
|
|
613
|
+
getWorkspaceAutoOpen,
|
|
574
614
|
};
|