amicus 4.6.3 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +349 -0
- package/README.md +19 -7
- package/bin/amicus.js +31 -0
- package/docs/ROADMAP.md +143 -36
- package/docs/configuration.md +52 -4
- package/docs/council.md +65 -12
- package/docs/doc-system.md +8 -7
- package/docs/schemas.md +10 -1
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +33 -5
- package/docs/usage.md +64 -14
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +52 -14
- package/electron/workspace-ui/workspace-app.js +14 -3
- package/electron/workspace-ui/workspace-lazy.js +233 -0
- package/electron/workspace-ui/workspace-matrix.js +12 -1
- package/electron/workspace-ui/workspace-panels.js +24 -171
- package/electron/workspace-ui/workspace-render.js +6 -2
- package/electron/workspace-ui/workspace-seats.js +68 -0
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +8 -4
- package/schemas/council-run.schema.json +1 -0
- package/schemas/council-stats.schema.json +9 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
- package/skills/second-opinion/MODEL-NOTES.md +5 -4
- package/skills/sidecar/SKILL.md +9 -2
- package/src/cli-council-run-bench.js +86 -0
- package/src/cli-handlers-council-run.js +65 -81
- package/src/cli-handlers-council.js +17 -5
- package/src/cli-handlers-fanout.js +179 -0
- package/src/cli-handlers-pack.js +24 -10
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/cli-handlers-run.js +19 -161
- package/src/cli-template-args.js +48 -0
- package/src/cli.js +39 -46
- package/src/council/debate.js +89 -10
- package/src/council/ledger.js +72 -11
- package/src/council/report.js +17 -6
- package/src/council/run-assemble.js +15 -3
- package/src/council/run-budget.js +2 -2
- package/src/council/run-chair.js +61 -5
- package/src/council/run-debate.js +51 -67
- package/src/council/run-launch.js +20 -2
- package/src/council/run-retry.js +17 -2
- package/src/council/run-stage1-launch.js +94 -0
- package/src/council/run-stage2.js +25 -4
- package/src/council/run-stages.js +79 -86
- package/src/council/run-state.js +10 -2
- package/src/council/run.js +26 -2
- package/src/council/tally.js +6 -2
- package/src/headless.js +69 -6
- package/src/mcp-council-awareness.js +1 -0
- package/src/mcp-council-bench.js +4 -0
- package/src/mcp-council-run.js +10 -0
- package/src/mcp-server.js +114 -54
- package/src/mcp-tools.js +12 -5
- package/src/pack/pack-cli.js +1 -1
- package/src/pack/pack-forward.js +12 -4
- package/src/pack/pack-resolve.js +3 -0
- package/src/pack/pack-store.js +20 -3
- package/src/pack/pack-validate.js +5 -1
- package/src/sidecar/budget.js +38 -4
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-budget.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +7 -3
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +13 -1
- package/src/sidecar/fanout.js +11 -9
- package/src/sidecar/list-limit.js +50 -0
- package/src/sidecar/list-search.js +69 -0
- package/src/sidecar/read.js +90 -5
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/start-metadata.js +58 -0
- package/src/sidecar/start.js +8 -43
- package/src/sidecar/workspace-auto-open.js +2 -2
- package/src/spend-query.js +2 -1
- package/src/template/apply.js +7 -4
- package/src/template/render.js +6 -2
- package/src/template/store.js +1 -1
- package/src/utils/cli-preflight.js +27 -1
- package/src/utils/config.js +15 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/remediation-hints.js +29 -0
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +6 -1
- package/src/utils/session-index-tmp-sweep.js +18 -3
- package/src/utils/session-index.js +1 -0
- package/src/utils/session-metadata-tmp-sweep.js +24 -4
- package/src/utils/spend-ledger.js +11 -4
- package/src/utils/validators.js +16 -0
|
@@ -72,4 +72,16 @@ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedA
|
|
|
72
72
|
return { wave, exitCode };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* v4.3 §7.2 (moved here v4.7 PR3 Task 1): stamp council attribution onto every
|
|
77
|
+
* leg — fanout-leg's appendSpend reads it; no-op for every non-council caller.
|
|
78
|
+
* v4.7 F8 (Task 7) adds tag stamping in the same pass.
|
|
79
|
+
*/
|
|
80
|
+
function stampLegAttribution(legs, options) {
|
|
81
|
+
if (options.councilRunId || options.councilName) {
|
|
82
|
+
legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
|
|
83
|
+
}
|
|
84
|
+
if (options.tag) { legs.forEach(l => { l.tag = options.tag; }); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -19,7 +19,7 @@ const { ERROR_CODES } = require('../utils/error-doc');
|
|
|
19
19
|
// Wave-document persistence lives in ./fanout-wave-io (size-gate split, v4.4.1
|
|
20
20
|
// Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
|
|
21
21
|
// fanout tests import it from here.
|
|
22
|
-
const { writeWaveMetadata, writeWaveDoc, finishWave } = require('./fanout-wave-io');
|
|
22
|
+
const { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution } = require('./fanout-wave-io');
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
|
|
@@ -86,7 +86,11 @@ async function runFanout(options) {
|
|
|
86
86
|
// `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
|
|
87
87
|
// waveDir is optional (only the post-creation caller has one).
|
|
88
88
|
const errorWave = (waveId, message, waveDir) => {
|
|
89
|
-
|
|
89
|
+
// v4.7 F8 (D13, T3 review): tag: options.tag || metaTag, TDZ-safe (sole call site runs after `const metaTag` below).
|
|
90
|
+
// v4.7 PR3 rider: pack got the same `|| metaPack` inherit the other two
|
|
91
|
+
// buildWaveResult sites have — it was the lone holdout, so an MCP-spawned
|
|
92
|
+
// wave whose server died dropped its pre-seeded pack while keeping its tag.
|
|
93
|
+
const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt: new Date().toISOString(), status: 'error' });
|
|
90
94
|
doc.error = message;
|
|
91
95
|
doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
|
|
92
96
|
// best-effort: an unwritable wave dir must not mask the real error
|
|
@@ -119,11 +123,7 @@ async function runFanout(options) {
|
|
|
119
123
|
});
|
|
120
124
|
if (validated.error) { return failPre(validated.code || 'BAD_ARGS', validated.error); }
|
|
121
125
|
const legs = validated.legs;
|
|
122
|
-
|
|
123
|
-
// existing appendSpend reads it); no-op for every non-council caller.
|
|
124
|
-
if (options.councilRunId || options.councilName) {
|
|
125
|
-
legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
|
|
126
|
-
}
|
|
126
|
+
stampLegAttribution(legs, options);
|
|
127
127
|
const okLegs = legs.filter(l => l.ok);
|
|
128
128
|
// FIX 2 (#61 whole-branch review): a leg's migration notice has no CLI
|
|
129
129
|
// stderr to land on — surface it on the wave doc instead, deduped in case
|
|
@@ -149,6 +149,7 @@ async function runFanout(options) {
|
|
|
149
149
|
briefing: String(options.prompt).slice(0, 200),
|
|
150
150
|
promptMeta: options.promptMeta || null,
|
|
151
151
|
...(options.pack ? { pack: options.pack } : {}), // v4.5 Task 13: absent-not-null.
|
|
152
|
+
...(options.tag ? { tag: options.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
|
|
152
153
|
pid: process.pid, project, createdAt,
|
|
153
154
|
});
|
|
154
155
|
// v4.5 final-review F2: an MCP-spawned child never gets --pack (single-
|
|
@@ -161,6 +162,7 @@ async function runFanout(options) {
|
|
|
161
162
|
// result-schema-rebuild.js:93, which reads meta.pack off a metadata.json
|
|
162
163
|
// it loaded for an unrelated reason).
|
|
163
164
|
const metaPack = waveMeta.pack;
|
|
165
|
+
const metaTag = waveMeta.tag; // v4.7 F8 (D13): same pre-seed inherit mechanism as metaPack above.
|
|
164
166
|
emitWaveStarted(waveDir, waveId, legs.map(l => (l.ok ? l.model : l.modelInput)), legIds, follow);
|
|
165
167
|
|
|
166
168
|
// 2b. All legs failed to route (#61 perf): no leg will ever touch the
|
|
@@ -171,7 +173,7 @@ async function runFanout(options) {
|
|
|
171
173
|
const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
|
|
172
174
|
const completedAt = new Date().toISOString();
|
|
173
175
|
const wave = buildWaveResult({
|
|
174
|
-
waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt, notices,
|
|
176
|
+
waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt, notices,
|
|
175
177
|
});
|
|
176
178
|
return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
|
|
177
179
|
exitCode: waveExitCode(wave.status),
|
|
@@ -282,7 +284,7 @@ async function runFanout(options) {
|
|
|
282
284
|
const completedAt = new Date().toISOString();
|
|
283
285
|
const signalled = waveAbort.signal();
|
|
284
286
|
const wave = buildWaveResult({
|
|
285
|
-
waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt,
|
|
287
|
+
waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, tag: options.tag || metaTag, createdAt, completedAt,
|
|
286
288
|
status: signalled ? 'aborted' : null, notices,
|
|
287
289
|
});
|
|
288
290
|
const exitCode = signalled
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/list-limit
|
|
3
|
+
* The `--limit` core behind `amicus list` (v4.7 PR3 rider).
|
|
4
|
+
* Split out of read.js on the same relief-extraction rule that produced
|
|
5
|
+
* list-search.js (T6 review) — read.js was at 293/300 with this inline.
|
|
6
|
+
* Re-exported from src/sidecar/read.js; nothing outside it needs this file.
|
|
7
|
+
*
|
|
8
|
+
* WHY --limit EXISTS. D14 made `--all` real (cross-project via the advisory
|
|
9
|
+
* sessions-index) with no output cap. Measured on the dev machine at merge
|
|
10
|
+
* time: 21,145 rows in 8.3s. An unbounded dump is not a listing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalize `--limit` into a row cap.
|
|
17
|
+
*
|
|
18
|
+
* Absent → 0 (unlimited), which keeps an un-limited `list` byte-identical to
|
|
19
|
+
* its pre-rider output. `0` is also the explicit "no cap" spelling, so a
|
|
20
|
+
* scripted caller can opt out without omitting the flag.
|
|
21
|
+
*
|
|
22
|
+
* @param {number|string|boolean|undefined|null} limit
|
|
23
|
+
* @returns {number} 0 for unlimited, else a positive integer
|
|
24
|
+
* @throws {Error} on a valueless, non-integer, or negative value — silently
|
|
25
|
+
* listing everything after a typo'd `--limit` is exactly the
|
|
26
|
+
* accepted-but-ignored failure src/utils/known-flags.js exists to prevent.
|
|
27
|
+
*/
|
|
28
|
+
function normalizeLimit(limit) {
|
|
29
|
+
// parseArgs sets `true` for a valueless flag; mirrors models.js:279-281.
|
|
30
|
+
if (limit === true) { throw new Error('--limit requires a value'); }
|
|
31
|
+
if (limit === undefined || limit === null || limit === '') { return 0; }
|
|
32
|
+
const n = Number(limit);
|
|
33
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
34
|
+
throw new Error(`--limit must be a non-negative integer (got "${limit}"); 0 means unlimited`);
|
|
35
|
+
}
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The no-silent-caps notice. Names the real total and how to see it all.
|
|
41
|
+
*
|
|
42
|
+
* @param {number} cap the applied limit
|
|
43
|
+
* @param {number} total row count BEFORE slicing
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function truncationNotice(cap, total) {
|
|
47
|
+
return `Showing ${cap} of ${total} sessions (--limit ${cap}). Use --limit 0 for all.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { normalizeLimit, truncationNotice };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/list-search
|
|
3
|
+
* The `--search` core behind both list surfaces (F8 D15, errata E-PR3-5).
|
|
4
|
+
* Split out of read.js (T6 review) to keep that file under its line budget.
|
|
5
|
+
* Re-exported from src/sidecar/read.js, which is what both consumers
|
|
6
|
+
* (mcp-server.js's amicus_list, the CLI's listSidecars) and the tests already
|
|
7
|
+
* require — nothing outside read.js needs to know this file exists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { safeSessionDir } = require('../utils/validators');
|
|
13
|
+
|
|
14
|
+
const COUNCIL_MATERIAL_SEPARATOR = '--- MATERIAL / BRIEFING ---';
|
|
15
|
+
|
|
16
|
+
// Council row search material (MCP-only): runDir/briefing.md when readable
|
|
17
|
+
// (MCP-launched runs write one before the engine spawns), else the portion of
|
|
18
|
+
// briefing-stage1.md after the separator (CLI-launched runs have only this),
|
|
19
|
+
// else null. Re-derives runDir from the pointer and re-fences it with
|
|
20
|
+
// containsOnDisk exactly as mcp-council-awareness.js:214 — never a bare join.
|
|
21
|
+
function councilSearchMaterial(project, runId) {
|
|
22
|
+
if (!project) { return null; }
|
|
23
|
+
const { readPointer } = require('../council/run-state');
|
|
24
|
+
const { containsOnDisk } = require('../utils/path-fence');
|
|
25
|
+
const ptr = readPointer(project, runId);
|
|
26
|
+
if (!ptr || !containsOnDisk(project, ptr.runDir)) { return null; }
|
|
27
|
+
try { return fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); } catch { /* try stage1 */ }
|
|
28
|
+
try {
|
|
29
|
+
const s1 = fs.readFileSync(path.join(ptr.runDir, 'briefing-stage1.md'), 'utf-8');
|
|
30
|
+
const idx = s1.indexOf(COUNCIL_MATERIAL_SEPARATOR);
|
|
31
|
+
return idx === -1 ? null : s1.slice(idx + COUNCIL_MATERIAL_SEPARATOR.length);
|
|
32
|
+
} catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Wave material: waveDir/briefing.md (full prompt) when readable, else the
|
|
36
|
+
// 200-char excerpt already on the row (fanout.js:146). safeSessionDir (not a
|
|
37
|
+
// bare path.join) so this gets the same traversal guard every other session
|
|
38
|
+
// path read in the codebase gets.
|
|
39
|
+
function waveSearchMaterial(project, waveId, excerpt) {
|
|
40
|
+
try { return fs.readFileSync(path.join(safeSessionDir(project, waveId), 'briefing.md'), 'utf-8'); }
|
|
41
|
+
catch { return String(excerpt || ''); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// LEG rows (parentWave set) are id/tag ONLY — briefing embeds the parent's material; matching it would hit every leg N+1 times per wave.
|
|
45
|
+
function rowMatchesSearch(row, needle, project) {
|
|
46
|
+
if (String(row.id || '').toLowerCase().includes(needle)) { return true; }
|
|
47
|
+
if (String(row.tag || '').toLowerCase().includes(needle)) { return true; }
|
|
48
|
+
if (row.parentWave) { return false; }
|
|
49
|
+
// --all rows are stamped with their OWN project (enumerateAllProjects,
|
|
50
|
+
// read.js) — a cross-project wave/council row's material lives under THAT
|
|
51
|
+
// project, not the caller's cwd. Using the wrong one doesn't throw; it
|
|
52
|
+
// silently falls back to the low-fidelity excerpt/id-tag match instead of
|
|
53
|
+
// the full text, which is worse than a crash because nothing signals it.
|
|
54
|
+
const proj = row.project || project;
|
|
55
|
+
const material = row.type === 'council-run' ? councilSearchMaterial(proj, row.id)
|
|
56
|
+
: row.type === 'wave' ? waveSearchMaterial(proj, row.id, row.briefing) : row.briefing;
|
|
57
|
+
return String(material || '').toLowerCase().includes(needle);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Case-insensitive substring filter behind both list surfaces (CLI `amicus list
|
|
61
|
+
// --search`, MCP `amicus_list {search}` — F8 D15, errata E-PR3-5), mirroring
|
|
62
|
+
// the models.js:70-73 idiom. Missing material never throws — it degrades to
|
|
63
|
+
// an id/tag-only match (read.js:58 idiom).
|
|
64
|
+
function searchSessions(rows, q, ctx) {
|
|
65
|
+
const needle = String(q).toLowerCase();
|
|
66
|
+
return rows.filter(row => rowMatchesSearch(row, needle, ctx && ctx.project));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { searchSessions };
|
package/src/sidecar/read.js
CHANGED
|
@@ -10,6 +10,11 @@ const path = require('path');
|
|
|
10
10
|
const { safeSessionDir, TASK_ID_PATTERN } = require('../utils/validators');
|
|
11
11
|
const { SESSIONS_DIR } = require('../session-manager');
|
|
12
12
|
const { fenceSidecarOutput } = require('../utils/untrusted-fence');
|
|
13
|
+
// F8 D15 (errata E-PR3-5) search core — split out (T6 review) to keep this
|
|
14
|
+
// file under its line budget; both list surfaces still reach it through
|
|
15
|
+
// this module's own searchSessions re-export below.
|
|
16
|
+
const { searchSessions } = require('./list-search');
|
|
17
|
+
const { normalizeLimit, truncationNotice } = require('./list-limit');
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* Format a timestamp as relative age
|
|
@@ -54,6 +59,8 @@ function enumerateSessions(project, opts = {}) {
|
|
|
54
59
|
type: meta.type || 'run',
|
|
55
60
|
parentWave: meta.parentWave || null,
|
|
56
61
|
legCount: Array.isArray(meta.legs) ? meta.legs.length : null,
|
|
62
|
+
mode: meta.mode || (meta.headless ? 'headless' : 'interactive'),
|
|
63
|
+
...(meta.tag ? { tag: meta.tag } : {}),
|
|
57
64
|
});
|
|
58
65
|
} catch { /* skip unreadable */ }
|
|
59
66
|
}
|
|
@@ -67,29 +74,96 @@ function enumerateSessions(project, opts = {}) {
|
|
|
67
74
|
return sessions;
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Enumerate sessions across every project the global sessions-index
|
|
79
|
+
* (src/utils/session-index.js) knows about, plus the current project. --all
|
|
80
|
+
* support (F8 D14): the index is a navigation aid, never authoritative, so a
|
|
81
|
+
* stale entry pointing at a missing/unreadable project directory is skipped
|
|
82
|
+
* silently rather than surfaced as an error.
|
|
83
|
+
*
|
|
84
|
+
* Dedup is by CANONICAL identity (T5 review fix-wave): the index stores
|
|
85
|
+
* canonical spellings (canonicalProjectPath — forward slashes, upper-cased
|
|
86
|
+
* drive letter) while `project` arrives however the caller spelled it
|
|
87
|
+
* (process.cwd() is backslashed on Windows). A raw string Set treated those
|
|
88
|
+
* as two different projects, double-counting every row of the current
|
|
89
|
+
* project whenever it was also present in the index (which it usually is —
|
|
90
|
+
* every session start records itself). The current project is seeded FIRST
|
|
91
|
+
* so its rows keep the caller's spelling rather than the index's.
|
|
92
|
+
* @param {{status?: string, project?: string}} [opts]
|
|
93
|
+
* @returns {Array<object>} enumerateSessions rows, each stamped with `project`
|
|
94
|
+
*/
|
|
95
|
+
function enumerateAllProjects(opts = {}) {
|
|
96
|
+
const { status, project = process.cwd() } = opts;
|
|
97
|
+
const { readIndex } = require('../utils/session-index');
|
|
98
|
+
const { canonicalProjectPath } = require('../utils/project-path');
|
|
99
|
+
const index = readIndex();
|
|
100
|
+
|
|
101
|
+
const byCanonical = new Map();
|
|
102
|
+
for (const p of [project, ...Object.values(index)]) {
|
|
103
|
+
if (!p || typeof p !== 'string') { continue; }
|
|
104
|
+
const key = canonicalProjectPath(p);
|
|
105
|
+
if (!byCanonical.has(key)) { byCanonical.set(key, p); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let sessions = [];
|
|
109
|
+
for (const p of byCanonical.values()) {
|
|
110
|
+
try {
|
|
111
|
+
const rows = enumerateSessions(p, {}).map(s => ({ ...s, project: p }));
|
|
112
|
+
sessions = sessions.concat(rows);
|
|
113
|
+
} catch { /* unreadable project — skip silently */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
117
|
+
if (status && status !== 'all') {
|
|
118
|
+
sessions = sessions.filter(s => s.status === status);
|
|
119
|
+
}
|
|
120
|
+
return sessions;
|
|
121
|
+
}
|
|
122
|
+
|
|
70
123
|
/**
|
|
71
124
|
* List previous sidecar sessions
|
|
72
125
|
* Spec Reference: §4.2
|
|
73
126
|
*
|
|
74
127
|
* @param {object} options
|
|
75
128
|
* @param {string} [options.status] - Filter by status (all, running, complete)
|
|
129
|
+
* @param {boolean} [options.all] - Cross-project via the global sessions-index (F8 D14)
|
|
76
130
|
* @param {boolean} [options.json] - Output as JSON
|
|
77
131
|
* @param {string} [options.project] - Project directory
|
|
132
|
+
* @param {string|boolean} [options.search] - id/tag/briefing substring filter (F8 D15); `true` = valueless flag (usage error)
|
|
133
|
+
* @param {number|string|boolean} [options.limit] - cap printed rows (PR3 rider); 0/absent = unlimited, `true` = valueless flag (usage error)
|
|
78
134
|
*/
|
|
79
135
|
async function listSidecars(options) {
|
|
80
|
-
const { status, json, project = process.cwd() } = options;
|
|
136
|
+
const { status, all, json, search, limit, project = process.cwd() } = options;
|
|
137
|
+
// Mirrors models.js:279-281's valueless-flag shape.
|
|
138
|
+
if (search === true) { throw new Error('--search requires a value'); }
|
|
139
|
+
const cap = normalizeLimit(limit); // throws on valueless/non-integer/negative
|
|
81
140
|
|
|
82
|
-
|
|
141
|
+
let sessions = all
|
|
142
|
+
? enumerateAllProjects({ status, project })
|
|
143
|
+
: enumerateSessions(project, { status });
|
|
144
|
+
if (search) { sessions = searchSessions(sessions, search, { project }); }
|
|
83
145
|
if (sessions.length === 0) {
|
|
84
146
|
console.log('No amicus sessions found.');
|
|
85
147
|
return;
|
|
86
148
|
}
|
|
87
149
|
|
|
150
|
+
// v4.7 PR3 rider: cap OUTPUT only — the newest-first sort has already run, so
|
|
151
|
+
// slicing here yields the N most recent. Enumeration is deliberately NOT
|
|
152
|
+
// capped: `--all` must still walk every project or the sort would rank rows
|
|
153
|
+
// it never saw.
|
|
154
|
+
const total = sessions.length;
|
|
155
|
+
const truncated = cap > 0 && total > cap;
|
|
156
|
+
if (truncated) { sessions = sessions.slice(0, cap); }
|
|
157
|
+
|
|
88
158
|
if (json) {
|
|
89
159
|
console.log(JSON.stringify(sessions, null, 2));
|
|
90
160
|
} else {
|
|
91
|
-
console.log(
|
|
92
|
-
|
|
161
|
+
console.log(
|
|
162
|
+
'ID'.padEnd(10) + 'MODEL'.padEnd(23) + 'STATUS'.padEnd(11) +
|
|
163
|
+
'TAG'.padEnd(12) + 'AGE'.padEnd(12) + 'BRIEFING' +
|
|
164
|
+
(all ? ' PROJECT' : '')
|
|
165
|
+
);
|
|
166
|
+
console.log('─'.repeat(all ? 100 : 80));
|
|
93
167
|
sessions.forEach(s => {
|
|
94
168
|
const age = formatAge(s.createdAt);
|
|
95
169
|
const briefingShort = (s.briefing || '').slice(0, 30) +
|
|
@@ -98,11 +172,20 @@ async function listSidecars(options) {
|
|
|
98
172
|
`${(s.id || '').padEnd(10)}` +
|
|
99
173
|
`${(s.type === 'wave' ? `wave(${s.legCount ?? 0} legs)` : (s.model || '')).padEnd(23)}` +
|
|
100
174
|
`${(s.status || 'unknown').padEnd(11)}` +
|
|
175
|
+
`${(s.tag || '').padEnd(12)}` +
|
|
101
176
|
`${age.padEnd(12)}` +
|
|
102
|
-
`${briefingShort}`
|
|
177
|
+
`${briefingShort}` +
|
|
178
|
+
(all ? ` ${s.project || ''}` : '')
|
|
103
179
|
);
|
|
104
180
|
});
|
|
105
181
|
}
|
|
182
|
+
|
|
183
|
+
// No silent caps: say what was elided, and how to see it all. In --json mode
|
|
184
|
+
// this goes to stderr so stdout stays a single parseable document.
|
|
185
|
+
if (truncated) {
|
|
186
|
+
const notice = truncationNotice(cap, total);
|
|
187
|
+
if (json) { console.error(notice); } else { console.log(notice); }
|
|
188
|
+
}
|
|
106
189
|
}
|
|
107
190
|
|
|
108
191
|
/**
|
|
@@ -184,6 +267,8 @@ async function readSidecar(options) {
|
|
|
184
267
|
module.exports = {
|
|
185
268
|
formatAge,
|
|
186
269
|
enumerateSessions,
|
|
270
|
+
enumerateAllProjects,
|
|
271
|
+
searchSessions,
|
|
187
272
|
listSidecars,
|
|
188
273
|
readSidecar
|
|
189
274
|
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/reopen-spend
|
|
3
|
+
* Spend finalization for a REOPENED session (continue/resume). Split out of
|
|
4
|
+
* sidecar/continue.js to keep that file under the 300-line gate; resume.js was
|
|
5
|
+
* already reaching across for it, so the shared home is the honest one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a reopened session's usage, write it onto metadata, and append one
|
|
12
|
+
* attributed ledger row. Mirrors start.js's finalize (the only sites that
|
|
13
|
+
* dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
|
|
14
|
+
* @returns {{usage: object|null}}
|
|
15
|
+
*/
|
|
16
|
+
function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
|
|
17
|
+
const { resolveUsage } = require('../utils/pricing');
|
|
18
|
+
const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
|
|
19
|
+
if (usage) {
|
|
20
|
+
metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
|
|
21
|
+
try {
|
|
22
|
+
const { appendSpend } = require('../utils/spend-ledger');
|
|
23
|
+
const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
|
|
24
|
+
// v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
|
|
25
|
+
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
|
|
26
|
+
appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
|
|
27
|
+
} catch { /* best-effort */ }
|
|
28
|
+
}
|
|
29
|
+
return { usage };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { finalizeSpendForReopen };
|
package/src/sidecar/resume.js
CHANGED
|
@@ -251,7 +251,7 @@ async function resumeSidecar(options) {
|
|
|
251
251
|
// v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
|
|
252
252
|
// a ledger row (status: statusFromResult, matching start.js — not terminal.status).
|
|
253
253
|
{
|
|
254
|
-
const { finalizeSpendForReopen } = require('./
|
|
254
|
+
const { finalizeSpendForReopen } = require('./reopen-spend');
|
|
255
255
|
const { statusFromResult } = require('../utils/result-schema');
|
|
256
256
|
const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
257
257
|
const { usage } = finalizeSpendForReopen({
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/sidecar/start-metadata.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module start-metadata
|
|
6
|
+
* createSessionMetadata, split out of start.js to keep that file under the
|
|
7
|
+
* hard size gate (v4.7 PR3 Task 1: F8 relief). Pure move — same writes, same
|
|
8
|
+
* order, same atomicity. start.js re-exports this so no importer changes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
14
|
+
const { SessionPaths } = require('./session-utils');
|
|
15
|
+
|
|
16
|
+
/** Create session directory and save metadata */
|
|
17
|
+
function createSessionMetadata(taskId, project, options) {
|
|
18
|
+
const { model, prompt, briefing, noUi, headless, agent, thinking, pack, tag } = options;
|
|
19
|
+
|
|
20
|
+
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
21
|
+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
22
|
+
|
|
23
|
+
const effectiveBriefing = prompt || briefing;
|
|
24
|
+
const isHeadless = noUi !== undefined ? noUi : headless;
|
|
25
|
+
|
|
26
|
+
// Preserve fields from existing metadata (e.g., pid written by MCP handler)
|
|
27
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
28
|
+
let existing = {};
|
|
29
|
+
if (fs.existsSync(metaPath)) {
|
|
30
|
+
try {
|
|
31
|
+
existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore corrupt metadata
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const metadata = {
|
|
38
|
+
...existing,
|
|
39
|
+
taskId,
|
|
40
|
+
model,
|
|
41
|
+
project,
|
|
42
|
+
briefing: effectiveBriefing,
|
|
43
|
+
mode: isHeadless ? 'headless' : 'interactive',
|
|
44
|
+
agent: agent || (isHeadless ? 'build' : 'chat'),
|
|
45
|
+
thinking: thinking || 'medium',
|
|
46
|
+
status: 'running',
|
|
47
|
+
pid: existing.pid || process.pid,
|
|
48
|
+
createdAt: existing.createdAt || new Date().toISOString(),
|
|
49
|
+
...(pack ? { pack } : {}), // v4.5 Task 13: absent-not-null; ...existing above preserves a prior write when this call omits pack.
|
|
50
|
+
...(tag ? { tag } : {}), // v4.7 F8 (D13): absent-not-null, same merge-preserve idiom as pack above.
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
54
|
+
|
|
55
|
+
return sessionDir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { createSessionMetadata };
|
package/src/sidecar/start.js
CHANGED
|
@@ -27,53 +27,13 @@ const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
|
27
27
|
const { discoverParentMcps } = require('../utils/mcp-discovery');
|
|
28
28
|
const { stripSelfMcpEntries } = require('../utils/mcp-self-identity');
|
|
29
29
|
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
30
|
+
const { createSessionMetadata } = require('./start-metadata');
|
|
30
31
|
|
|
31
32
|
/** Generate a unique 8-character hex task ID */
|
|
32
33
|
function generateTaskId() {
|
|
33
34
|
return crypto.randomBytes(4).toString('hex');
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
/** Create session directory and save metadata */
|
|
37
|
-
function createSessionMetadata(taskId, project, options) {
|
|
38
|
-
const { model, prompt, briefing, noUi, headless, agent, thinking, pack } = options;
|
|
39
|
-
|
|
40
|
-
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
41
|
-
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
42
|
-
|
|
43
|
-
const effectiveBriefing = prompt || briefing;
|
|
44
|
-
const isHeadless = noUi !== undefined ? noUi : headless;
|
|
45
|
-
|
|
46
|
-
// Preserve fields from existing metadata (e.g., pid written by MCP handler)
|
|
47
|
-
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
48
|
-
let existing = {};
|
|
49
|
-
if (fs.existsSync(metaPath)) {
|
|
50
|
-
try {
|
|
51
|
-
existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
52
|
-
} catch {
|
|
53
|
-
// ignore corrupt metadata
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const metadata = {
|
|
58
|
-
...existing,
|
|
59
|
-
taskId,
|
|
60
|
-
model,
|
|
61
|
-
project,
|
|
62
|
-
briefing: effectiveBriefing,
|
|
63
|
-
mode: isHeadless ? 'headless' : 'interactive',
|
|
64
|
-
agent: agent || (isHeadless ? 'build' : 'chat'),
|
|
65
|
-
thinking: thinking || 'medium',
|
|
66
|
-
status: 'running',
|
|
67
|
-
pid: existing.pid || process.pid,
|
|
68
|
-
createdAt: existing.createdAt || new Date().toISOString(),
|
|
69
|
-
...(pack ? { pack } : {}), // v4.5 Task 13: absent-not-null; ...existing above preserves a prior write when this call omits pack.
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
73
|
-
|
|
74
|
-
return sessionDir;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
37
|
/**
|
|
78
38
|
* Build MCP configuration from options.
|
|
79
39
|
* Merge priority: CLI --mcp > --mcp-config > file config > discovered parent MCPs
|
|
@@ -154,7 +114,7 @@ async function startSidecar(options) {
|
|
|
154
114
|
contextMaxTokens = 80000, noUi, headless = false, timeout = 15,
|
|
155
115
|
agent, mcp, mcpConfig, summaryLength = 'normal', thinking,
|
|
156
116
|
client, sessionDir, noMcp, excludeMcp, opencodePort, coworkProcess, includeContext = true,
|
|
157
|
-
position = 'right', json = false, modelInput = null, pack = null
|
|
117
|
+
position = 'right', json = false, modelInput = null, pack = null, tag
|
|
158
118
|
} = options;
|
|
159
119
|
|
|
160
120
|
const effectivePrompt = prompt || briefing;
|
|
@@ -183,7 +143,7 @@ async function startSidecar(options) {
|
|
|
183
143
|
);
|
|
184
144
|
|
|
185
145
|
const sessDir = createSessionMetadata(taskId, effectiveProject, {
|
|
186
|
-
model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking, pack
|
|
146
|
+
model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking, pack, tag
|
|
187
147
|
});
|
|
188
148
|
saveInitialContext(sessDir, systemPrompt, userMessage);
|
|
189
149
|
acquireLock(sessDir, effectiveHeadless ? 'headless' : 'interactive');
|
|
@@ -270,6 +230,11 @@ async function startSidecar(options) {
|
|
|
270
230
|
gateway: String(model).startsWith('openrouter/') ? 'openrouter' : 'direct',
|
|
271
231
|
// (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
|
|
272
232
|
// cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
|
|
233
|
+
// v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
|
|
234
|
+
// just-re-read metadata (line 214), which carries `tag` when
|
|
235
|
+
// createSessionMetadata stored one (absent otherwise); `|| null` folds
|
|
236
|
+
// that into spend-ledger.js's null-not-absent dim convention.
|
|
237
|
+
tag: m.tag || null,
|
|
273
238
|
});
|
|
274
239
|
} catch { /* best-effort */ }
|
|
275
240
|
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* @param {'ok'|'package-missing'|'binary-missing'} [options.electronState] - 3-state probe (#76); takes precedence
|
|
24
24
|
* @param {string|null} [options.electronDir] - Resolved electron dir, named in the electron-broken reason
|
|
25
25
|
* @param {string} options.platform - The platform (e.g., 'win32', 'darwin', 'linux')
|
|
26
|
-
* @param {object} options.env - Environment variables object
|
|
26
|
+
* @param {object} [options.env] - Environment variables object
|
|
27
27
|
* @param {boolean} options.autoOpenConfig - The config.workspace.autoOpen setting
|
|
28
28
|
* @param {boolean|undefined} options.uiParam - Explicit UI parameter (true, false, or undefined)
|
|
29
29
|
* @returns {{open: boolean, reason: string}}
|
|
@@ -34,7 +34,7 @@ function shouldAutoOpenWorkspace({
|
|
|
34
34
|
electronState,
|
|
35
35
|
electronDir,
|
|
36
36
|
platform,
|
|
37
|
-
env,
|
|
37
|
+
env = {},
|
|
38
38
|
autoOpenConfig,
|
|
39
39
|
uiParam,
|
|
40
40
|
}) {
|
package/src/spend-query.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* NOT hand-copy this array elsewhere: a 7th dimension added here must reach
|
|
18
18
|
* both surfaces automatically, not just the one someone remembered to edit.
|
|
19
19
|
*/
|
|
20
|
-
const GROUP_DIMS = ['model', 'wave', 'council', 'project', 'op', 'day'];
|
|
20
|
+
const GROUP_DIMS = ['model', 'wave', 'council', 'project', 'op', 'day', 'tag'];
|
|
21
21
|
|
|
22
22
|
/** Cap on rows returned when a caller opts into raw rows (CLI --rows / MCP rows:true). */
|
|
23
23
|
const ROWS_CAP = 1000;
|
|
@@ -55,6 +55,7 @@ function rowKey(row, dimension) {
|
|
|
55
55
|
case 'project': return row.project || '(unattributed)';
|
|
56
56
|
case 'op': return row.op || '(unattributed)';
|
|
57
57
|
case 'day': return typeof row.ts === 'string' ? row.ts.slice(0, 10) : '(unattributed)';
|
|
58
|
+
case 'tag': return row.tag || '(unattributed)';
|
|
58
59
|
default: return '(unattributed)';
|
|
59
60
|
}
|
|
60
61
|
}
|
package/src/template/apply.js
CHANGED
|
@@ -52,10 +52,10 @@ function applyTemplate({ templateRef, prompt, artifactFile, varList, project })
|
|
|
52
52
|
try {
|
|
53
53
|
raw = fs.readFileSync(artifactPath);
|
|
54
54
|
} catch (err) {
|
|
55
|
-
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: cannot read --artifact ${artifactFile}: ${err.message}`, hint:
|
|
55
|
+
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: cannot read --artifact ${artifactFile}: ${err.message}`, hint: 'Check that the --artifact path exists and is readable — relative paths resolve from the current working directory.' } };
|
|
56
56
|
}
|
|
57
57
|
if (raw.length > ARTIFACT_CAP_BYTES) {
|
|
58
|
-
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: --artifact ${artifactFile} is ${raw.length} bytes; the cap is 256 KB`, hint:
|
|
58
|
+
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: --artifact ${artifactFile} is ${raw.length} bytes; the cap is 256 KB`, hint: 'Trim --artifact to under 256 KB, or point it at a smaller excerpt.' } };
|
|
59
59
|
}
|
|
60
60
|
artifact = raw.toString('utf-8');
|
|
61
61
|
if (artifact.charCodeAt(0) === 0xFEFF) { artifact = artifact.slice(1); }
|
|
@@ -66,11 +66,14 @@ function applyTemplate({ templateRef, prompt, artifactFile, varList, project })
|
|
|
66
66
|
artifact,
|
|
67
67
|
artifactPath,
|
|
68
68
|
date: new Date().toISOString().slice(0, 10),
|
|
69
|
-
project: String(project),
|
|
69
|
+
project: path.resolve(String(project)),
|
|
70
70
|
vars,
|
|
71
71
|
});
|
|
72
72
|
if (res.error) {
|
|
73
|
-
|
|
73
|
+
// No variable list here on purpose: renderTemplate's own message already ends
|
|
74
|
+
// with a knownList()-derived "Known: ..." line. A copy would be a third
|
|
75
|
+
// hand-maintained KNOWN_VARIABLES mirror, which BACKLOG's T3-m2 hard gate forbids.
|
|
76
|
+
return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: res.error, hint: 'amicus template show <name>' } };
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
return {
|
package/src/template/render.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* happens ONLY in template files (spec carried decision 7) — --prompt text is
|
|
8
8
|
* always literal, so this module never sees non-template input.
|
|
9
9
|
*
|
|
10
|
-
* v4.5 variable set. {{input}} is deliberately ABSENT — it ships with
|
|
11
|
-
* --input-from; on v4.5 it fails as an
|
|
10
|
+
* v4.5 variable set. {{input}} is deliberately ABSENT — it ships with
|
|
11
|
+
* composable waves (--input-from), a future rev; on v4.5 it fails as an
|
|
12
|
+
* unknown variable, which is accurate.
|
|
12
13
|
* No {{model}}: prompts are built once per wave, model-independent.
|
|
13
14
|
*/
|
|
14
15
|
|
|
@@ -37,6 +38,9 @@ function renderTemplate(text, data) {
|
|
|
37
38
|
for (const name of used) {
|
|
38
39
|
if (name.startsWith('var.')) {
|
|
39
40
|
const key = name.slice(4);
|
|
41
|
+
if (key === '') {
|
|
42
|
+
return { error: `Error: Unknown template variable {{var.}} — {{var.<key>}} requires a key. Known: ${knownList()}` };
|
|
43
|
+
}
|
|
40
44
|
if (!(key in vars)) {
|
|
41
45
|
return { error: `Error: template uses {{var.${key}}} but no --var ${key}=<value> was given` };
|
|
42
46
|
}
|
package/src/template/store.js
CHANGED
|
@@ -18,7 +18,7 @@ function _getConfigDir() { return require('../utils/config').getConfigDir(); }
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* v4.5 ships `review` only. `critique`/`refine` are {{input}}-centric and
|
|
21
|
-
* arrive with
|
|
21
|
+
* arrive with composable waves (--input-from).
|
|
22
22
|
*/
|
|
23
23
|
const BUILTIN_TEMPLATES = Object.freeze({
|
|
24
24
|
review: [
|