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
|
@@ -40,4 +40,30 @@ function requireValidTaskId(args, useJson, commandLabel, usage) {
|
|
|
40
40
|
return taskId;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* `pack save --version <semver>` can never work, and used to fail SILENTLY:
|
|
45
|
+
* `version` is a global BOOLEAN_FLAG (src/cli.js), so parseArgs sets
|
|
46
|
+
* `args.version = true` and drops the semver into positionals, then
|
|
47
|
+
* bin/amicus.js prints the version banner BEFORE command dispatch — so
|
|
48
|
+
* `handlePack` never runs and no pack is written, at exit 0. `--version=2.0.0`
|
|
49
|
+
* fails identically (the inline value is discarded at the isBooleanFlag branch,
|
|
50
|
+
* ahead of the --key=value branch). The pack's own version is `--pack-version`.
|
|
51
|
+
*
|
|
52
|
+
* Returns the failure rather than exiting, so bin/amicus.js keeps one exit site
|
|
53
|
+
* and this stays unit-testable (bin/amicus.js is a script, not a module).
|
|
54
|
+
*
|
|
55
|
+
* @param {object} args - parsed CLI args
|
|
56
|
+
* @returns {{code: string, message: string, hint: string}|null} null when there is no conflict
|
|
57
|
+
*/
|
|
58
|
+
function packSaveVersionConflict(args) {
|
|
59
|
+
if (!args || !args.version) { return null; }
|
|
60
|
+
const argv = Array.isArray(args._) ? args._ : [];
|
|
61
|
+
if (argv[0] !== 'pack' || argv[1] !== 'save') { return null; }
|
|
62
|
+
return {
|
|
63
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
64
|
+
message: "Error: --version is amicus's own global flag, not the saved pack's version",
|
|
65
|
+
hint: 'Use --pack-version <semver> to set the version of the pack being saved.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { requireNoUiForJson, requireValidTaskId, packSaveVersionConflict };
|
package/src/utils/config.js
CHANGED
|
@@ -419,6 +419,21 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
419
419
|
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
|
|
420
420
|
* `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
|
|
421
421
|
* callers keep working); `droppedMembers` additively pairs each with WHY.
|
|
422
|
+
*
|
|
423
|
+
* Standing note (D18, v4.7 PR5): each `droppedMembers` entry is `{member, reason}`
|
|
424
|
+
* (that is the real key — BACKLOG.md's description of this shape had drifted to
|
|
425
|
+
* `{ref, reason}`). The two `reason` literals are produced below, in the
|
|
426
|
+
* alias-miss and catalog-miss branches of `classifyCouncilMembers`; they are
|
|
427
|
+
* free text, not a coded enum, and today NO consumer branches on the string. They
|
|
428
|
+
* are not display-only: `council/presets-cli.js` (`amicus council show`,
|
|
429
|
+
* `:149-150`) renders `${member} (${reason})`, `council/run.js` (`:80-87`) carries
|
|
430
|
+
* it verbatim into a degrade-note payload, and `reason` is also persisted into
|
|
431
|
+
* `run.json` (`run-state.js`, `mcp-council-run.js`) — not merely shown to a human.
|
|
432
|
+
* `cli-council-run-bench.js` (`:74`) only type-checks that `reason` is a string
|
|
433
|
+
* when round-tripping `--dropped-members` across the MCP→CLI spawn boundary. The
|
|
434
|
+
* tripwire: if a THIRD reason string is ever added here, stop and re-decide
|
|
435
|
+
* whether `reason` should become a coded enum instead of free text — this note
|
|
436
|
+
* marks that decision point, it does not make it.
|
|
422
437
|
*/
|
|
423
438
|
function classifyCouncilMembers(members, catalog = []) {
|
|
424
439
|
const aliases = getEffectiveAliases();
|
|
@@ -48,6 +48,38 @@ function evaluateEngineInstalls(d) {
|
|
|
48
48
|
|
|
49
49
|
const broken = npxCopies.filter((i) => !i.engineOk);
|
|
50
50
|
if (broken.length === 0) {
|
|
51
|
+
// Version skew (#133): a PRESENT engine can still be the wrong one. The
|
|
52
|
+
// npx copies and the global install resolve independently and at different
|
|
53
|
+
// times, and two versions writing one shared opencode.db is what produced
|
|
54
|
+
// #133's SQLiteError. Compare npx against global ONLY — a genuine source
|
|
55
|
+
// checkout's engine legitimately differs, so including it would fire red
|
|
56
|
+
// on every developer machine and in CI (E-1c). Unresolved versions never
|
|
57
|
+
// signal skew; absence of evidence is not evidence.
|
|
58
|
+
// WARN, never ERROR: doctor --fix has no skew branch, so an error would be
|
|
59
|
+
// unfixable, and this file already downgrades to warn at the ambiguous-npx
|
|
60
|
+
// branch below whenever the copy npx will select is ambiguous.
|
|
61
|
+
//
|
|
62
|
+
// Review round 2, finding 1: `kind === 'global'` alone is NOT the whole
|
|
63
|
+
// baseline. On the documented end-user invocation — `amicus doctor` run
|
|
64
|
+
// from the globally-installed copy — the running process IS the global
|
|
65
|
+
// install, so engine-install-scan.js's own dedup drops the separate
|
|
66
|
+
// `global` record and only `kind:'running'` survives, carrying
|
|
67
|
+
// `isGlobal: true` instead (see scanEngineInstalls). Without the
|
|
68
|
+
// `|| i.isGlobal` clause, the skew check was structurally unable to fire
|
|
69
|
+
// for exactly the users who filed #133. A TRUE source checkout (running,
|
|
70
|
+
// no isGlobal) is still excluded, as intended.
|
|
71
|
+
const globalV = (installs.find((i) => i.kind === 'global' || i.isGlobal) || {}).engineVersion;
|
|
72
|
+
const skewed = globalV
|
|
73
|
+
? npxCopies.filter((i) => i.engineVersion && i.engineVersion !== globalV)
|
|
74
|
+
: [];
|
|
75
|
+
if (skewed.length > 0) {
|
|
76
|
+
const detail = skewed.map((i) => `${i.pkgDir} has ${i.engineVersion}`).join('; ');
|
|
77
|
+
return {
|
|
78
|
+
id, name, status: 'warn',
|
|
79
|
+
message: `engine version skew — global install has ${globalV}; ${detail}`,
|
|
80
|
+
hint: HINTS.engineVersionSkew,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
51
83
|
return {
|
|
52
84
|
id, name, status: 'ok',
|
|
53
85
|
message: `engine present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,
|
|
@@ -27,12 +27,21 @@ function defaultNpmCacheDir(platform) {
|
|
|
27
27
|
return path.join(os.homedir(), '.npm');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort `npm root -g`. Never throws; returns null on any failure.
|
|
32
|
+
* ⚠️ Windows needs shell:true — npm is a .cmd shim, and Node 24's
|
|
33
|
+
* CVE-2024-27980 hardening rejects .cmd via execFileSync without a shell
|
|
34
|
+
* (bare `npm` → ENOENT, `npm.cmd` → EINVAL). Without this the global install
|
|
35
|
+
* was invisible to the whole scan, which also blinded engine-repair's donor
|
|
36
|
+
* search: `doctor --fix` reported "no healthy sibling install" while one sat
|
|
37
|
+
* at %AppData%\npm\node_modules.
|
|
38
|
+
*/
|
|
39
|
+
function resolveNpmRootG({ execFileSync, platform } = {}) {
|
|
40
|
+
const win = (platform || process.platform) === 'win32';
|
|
32
41
|
try {
|
|
33
|
-
const
|
|
34
|
-
const out =
|
|
35
|
-
encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
42
|
+
const exec = execFileSync || require('child_process').execFileSync;
|
|
43
|
+
const out = exec(win ? 'npm root -g' : 'npm', win ? [] : ['root', '-g'], {
|
|
44
|
+
encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'], shell: win,
|
|
36
45
|
});
|
|
37
46
|
return String(out).trim() || null;
|
|
38
47
|
} catch (_e) {
|
|
@@ -45,13 +54,41 @@ function safe(fn, fallback) {
|
|
|
45
54
|
try { return fn(); } catch (_e) { return fallback; }
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the engine version from the roots already on the record. Reads
|
|
59
|
+
* opencode-ai's own package.json, which is a faithful proxy for the executed
|
|
60
|
+
* binary because opencode-ai exact-pins all 12 platform sub-packages.
|
|
61
|
+
* ⚠️ Do NOT read next to the binary: hasOpencodeBinary probes
|
|
62
|
+
* opencode-windows-<arch>/bin/opencode.exe on win32 but .bin/opencode on
|
|
63
|
+
* POSIX, and .bin/ has no package.json — a binary-adjacent rule would work on
|
|
64
|
+
* Windows only and silently return nothing on the two POSIX CI legs.
|
|
65
|
+
* Uses the real `fs` module directly (not a seam) because this is the
|
|
66
|
+
* PRODUCTION default — tests always inject `readEngineVersion` instead (the
|
|
67
|
+
* suite's fakeFs implements no readFileSync).
|
|
68
|
+
* @returns {string|undefined} undefined (never null) so toEqual fixtures survive
|
|
69
|
+
*/
|
|
70
|
+
function defaultReadEngineVersion({ roots }) {
|
|
71
|
+
for (const root of roots || []) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = require('fs').readFileSync(path.join(root, 'opencode-ai', 'package.json'), 'utf-8');
|
|
74
|
+
const v = JSON.parse(raw).version;
|
|
75
|
+
if (v) { return String(v); }
|
|
76
|
+
} catch (_e) { /* try the next root */ }
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve p's real path via fs.realpathSync, tolerating any throw. */
|
|
82
|
+
function realNorm(p, fs) {
|
|
83
|
+
return path.normalize(safe(() => fs.realpathSync(p), p));
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
/** Drop installs whose pkgDir resolves to the same real path; keep the first. */
|
|
49
87
|
function dedupByRealpath(installs, fs) {
|
|
50
88
|
const seen = new Set();
|
|
51
89
|
const out = [];
|
|
52
90
|
for (const inst of installs) {
|
|
53
|
-
const
|
|
54
|
-
const key = path.normalize(real);
|
|
91
|
+
const key = realNorm(inst.pkgDir, fs);
|
|
55
92
|
if (seen.has(key)) { continue; }
|
|
56
93
|
seen.add(key);
|
|
57
94
|
out.push(inst);
|
|
@@ -59,6 +96,21 @@ function dedupByRealpath(installs, fs) {
|
|
|
59
96
|
return out;
|
|
60
97
|
}
|
|
61
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True when pkgDir is the same real install as the npm-global amicus
|
|
101
|
+
* package (`<gRoot>/amicus`). Recovers "this record IS the global install"
|
|
102
|
+
* after dedupByRealpath has already dropped the separate `kind:'global'`
|
|
103
|
+
* record — see the docblock on scanEngineInstalls for why (#133 R-A finding
|
|
104
|
+
* 1: on the documented end-user invocation, `amicus doctor` run from the
|
|
105
|
+
* globally-installed copy, `runningPkgDir` IS `<npm root -g>/amicus`, and
|
|
106
|
+
* listAmicusInstalls pushes `running` first, so dedup keeps `running` and
|
|
107
|
+
* drops `global`).
|
|
108
|
+
*/
|
|
109
|
+
function isGlobalInstall({ pkgDir, fs, gRoot }) {
|
|
110
|
+
if (!gRoot) { return false; }
|
|
111
|
+
return realNorm(pkgDir, fs) === realNorm(path.join(gRoot, 'amicus'), fs);
|
|
112
|
+
}
|
|
113
|
+
|
|
62
114
|
/**
|
|
63
115
|
* The amicus installs that could serve the MCP, highest-priority first
|
|
64
116
|
* (running, global, then npx-cache copies). All I/O behind seams.
|
|
@@ -76,7 +128,7 @@ function listAmicusInstalls(deps = {}) {
|
|
|
76
128
|
const platform = deps.platform || process.platform;
|
|
77
129
|
const runningPkgDir = deps.runningPkgDir || path.join(__dirname, '..', '..');
|
|
78
130
|
const npmCacheDir = deps.npmCacheDir || defaultNpmCacheDir(platform);
|
|
79
|
-
const npmRootG = deps.npmRootG ||
|
|
131
|
+
const npmRootG = deps.npmRootG || (() => resolveNpmRootG({ platform }));
|
|
80
132
|
|
|
81
133
|
const raw = [{ kind: 'running', pkgDir: runningPkgDir }];
|
|
82
134
|
|
|
@@ -121,22 +173,53 @@ function classifyLaunch(config) {
|
|
|
121
173
|
* @param {object} [deps] - listAmicusInstalls seams, plus:
|
|
122
174
|
* @param {(d:{pkgDir:string}) => boolean} [deps.hasOpencodeBinary]
|
|
123
175
|
* @param {(d:{pkgDir:string}) => string[]} [deps.opencodeRoots]
|
|
176
|
+
* @param {(d:{pkgDir:string, roots:string[]}) => (string|undefined)} [deps.readEngineVersion]
|
|
124
177
|
* @param {() => (object|null)} [deps.readAmicusMcpConfig]
|
|
125
|
-
* @returns {{installs: Array<{kind,pkgDir,engineOk,roots}>, mcpLaunch: string}}
|
|
178
|
+
* @returns {{installs: Array<{kind,pkgDir,engineOk,roots,engineVersion,isGlobal?}>, mcpLaunch: string}}
|
|
126
179
|
*/
|
|
127
180
|
function scanEngineInstalls(deps = {}) {
|
|
181
|
+
const fs = deps.fs || require('fs');
|
|
182
|
+
const platform = deps.platform || process.platform;
|
|
128
183
|
const hasOpencodeBinary = deps.hasOpencodeBinary || require('./path-setup').hasOpencodeBinary;
|
|
129
184
|
const opencodeRoots = deps.opencodeRoots || require('./path-setup').opencodeRoots;
|
|
185
|
+
const readEngineVersion = deps.readEngineVersion || defaultReadEngineVersion;
|
|
130
186
|
const readAmicusMcpConfig = deps.readAmicusMcpConfig
|
|
131
187
|
|| (() => require('./mcp-discovery').readAmicusMcpConfig());
|
|
132
188
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
189
|
+
// Resolve `npm root -g` once and feed that SAME resolver into
|
|
190
|
+
// listAmicusInstalls (below), so the isGlobal recovery here reuses its
|
|
191
|
+
// result instead of spawning a second `npm root -g` process.
|
|
192
|
+
let gRootCache;
|
|
193
|
+
let gRootResolved = false;
|
|
194
|
+
const npmRootGRaw = deps.npmRootG || (() => resolveNpmRootG({ platform }));
|
|
195
|
+
const npmRootGOnce = () => {
|
|
196
|
+
if (!gRootResolved) { gRootCache = safe(() => npmRootGRaw(), null); gRootResolved = true; }
|
|
197
|
+
return gRootCache;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const rawInstalls = listAmicusInstalls({ ...deps, platform, npmRootG: npmRootGOnce });
|
|
201
|
+
const gRoot = npmRootGOnce(); // already resolved by listAmicusInstalls above; this just reads the cache
|
|
202
|
+
|
|
203
|
+
const installs = rawInstalls.map((i) => {
|
|
204
|
+
const roots = opencodeRoots({ pkgDir: i.pkgDir });
|
|
205
|
+
// #133 R-A finding 1: recovers "this IS the global install" for a
|
|
206
|
+
// record whose `kind:'global'` twin was dropped by dedupByRealpath
|
|
207
|
+
// (see isGlobalInstall's docblock). Deliberately NOT stamped inside
|
|
208
|
+
// listAmicusInstalls — its output is pinned exact by toEqual in
|
|
209
|
+
// tests/utils/engine-install-scan.test.js:57 and :89.
|
|
210
|
+
const isGlobal = i.kind !== 'global' && isGlobalInstall({ pkgDir: i.pkgDir, fs, gRoot });
|
|
211
|
+
return {
|
|
212
|
+
...i,
|
|
213
|
+
engineOk: !!hasOpencodeBinary({ pkgDir: i.pkgDir }),
|
|
214
|
+
roots,
|
|
215
|
+
engineVersion: safe(() => readEngineVersion({ pkgDir: i.pkgDir, roots }), undefined),
|
|
216
|
+
...(isGlobal ? { isGlobal: true } : {}),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
138
219
|
const mcpLaunch = classifyLaunch(safe(() => readAmicusMcpConfig(), null));
|
|
139
220
|
return { installs, mcpLaunch };
|
|
140
221
|
}
|
|
141
222
|
|
|
142
|
-
module.exports = {
|
|
223
|
+
module.exports = {
|
|
224
|
+
listAmicusInstalls, scanEngineInstalls, classifyLaunch, resolveNpmRootG,
|
|
225
|
+
};
|
|
@@ -23,11 +23,105 @@ function runningPkgDir() {
|
|
|
23
23
|
return path.join(__dirname, '..', '..');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Parse a semver-shaped string into a [major, minor, patch] triple.
|
|
28
|
+
* Prerelease versions (`1.18.15-beta.1`) return null — treated the same as
|
|
29
|
+
* unparseable, so they sort LAST rather than tying with (and, via a stable
|
|
30
|
+
* sort, sometimes beating) the release they're a prerelease of. Only the
|
|
31
|
+
* exact-release leading triple is a valid donor signal; amicus pins exact
|
|
32
|
+
* release versions, never prereleases.
|
|
33
|
+
* @returns {[number,number,number]|null} null for undefined/non-string/non-semver/prerelease
|
|
34
|
+
*/
|
|
35
|
+
function parseVersionTriple(v) {
|
|
36
|
+
if (typeof v !== 'string') { return null; }
|
|
37
|
+
const trimmed = v.trim();
|
|
38
|
+
if (/^\d+\.\d+\.\d+-/.test(trimmed)) { return null; }
|
|
39
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(trimmed);
|
|
40
|
+
if (!m) { return null; }
|
|
41
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Descending version comparator: newest first, unparseable/absent sorts last
|
|
46
|
+
* (never throws). Two unparseable values compare equal (0).
|
|
47
|
+
*/
|
|
48
|
+
function compareVersionsDesc(a, b) {
|
|
49
|
+
const ta = parseVersionTriple(a);
|
|
50
|
+
const tb = parseVersionTriple(b);
|
|
51
|
+
if (!ta && !tb) { return 0; }
|
|
52
|
+
if (!ta) { return 1; }
|
|
53
|
+
if (!tb) { return -1; }
|
|
54
|
+
for (let i = 0; i < 3; i += 1) {
|
|
55
|
+
if (ta[i] !== tb[i]) { return tb[i] - ta[i]; }
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* TWO TIERS, not one sort. An explicitly-global healthy donor — `kind:'global'`,
|
|
62
|
+
* or `isGlobal:true` when engine-install-scan.js's dedup collapsed the
|
|
63
|
+
* `global` record into `running` (#133 R-A finding 1) — wins OUTRIGHT,
|
|
64
|
+
* regardless of version. Only when no explicit-global donor exists does
|
|
65
|
+
* engineVersion rank the remaining candidates (newest first; ties/absent
|
|
66
|
+
* versions fall back to list order, running-first).
|
|
67
|
+
*
|
|
68
|
+
* Review round 2, finding 2: a single version-first sort (this function's
|
|
69
|
+
* first cut) inverted R-A's own goal three ways, each confirmed by a test in
|
|
70
|
+
* tests/utils/engine-repair.test.js:
|
|
71
|
+
* 1. running (dev tree) newer than global → donated the dev tree. R-A's
|
|
72
|
+
* stated goal is "--fix stops donating the dev engine"; the dev tree
|
|
73
|
+
* running ahead of the pin is the NORMAL direction mid-pin-bump, not a
|
|
74
|
+
* reason to trust it over the global install.
|
|
75
|
+
* 2. running-that-is-global older than a healthy npx sibling → donated the
|
|
76
|
+
* npx sibling — the exact outcome the ORIGINAL (pre-engineVersion)
|
|
77
|
+
* findDonor test says must never happen, reintroduced via the source
|
|
78
|
+
* class this file's own scanEngineInstalls docblock calls LEAST
|
|
79
|
+
* trustworthy (npx-cache copies: optional-dependency skips, AV
|
|
80
|
+
* quarantine on every re-resolve).
|
|
81
|
+
* 3. global's version unresolved, npx sibling versioned → donated the npx
|
|
82
|
+
* copy despite a `global` record existing at all.
|
|
83
|
+
* All three share one cause: ranking by version BEFORE asking "is there an
|
|
84
|
+
* explicit global donor at all". Tiering fixes it without losing what the
|
|
85
|
+
* version ranking was FOR — Task 3's kind-only rule left a residual hole
|
|
86
|
+
* (dev checkout, no npm-global install, broken npx destination, healthy npx
|
|
87
|
+
* sibling — no record has kind:'global' at all) where the old code fell
|
|
88
|
+
* through to `healthy[0]`, the running dev tree, and could donate a
|
|
89
|
+
* version-skewed dev engine over a newer healthy sibling. That hole is
|
|
90
|
+
* exactly the case where tier 1 finds nothing and tier 2's version ranking
|
|
91
|
+
* takes over.
|
|
92
|
+
*
|
|
93
|
+
* A `kind !== 'running'` proxy for tier 1 is wrong: listAmicusInstalls pushes
|
|
94
|
+
* `running` first and `global` second, and dedupByRealpath keeps the FIRST of
|
|
95
|
+
* any two entries that resolve to the same real path. So on an ordinary
|
|
96
|
+
* end-user machine — where the running process IS the global install — the
|
|
97
|
+
* `global` record never survives dedup; that copy is labeled `kind:
|
|
98
|
+
* 'running'` (carrying `isGlobal:true` instead, once scanEngineInstalls has
|
|
99
|
+
* run). A `kind !== 'running'` filter would then skip the good global engine
|
|
100
|
+
* and donate some other (possibly stale) healthy copy, importing the exact
|
|
101
|
+
* version skew this self-heal exists to prevent.
|
|
102
|
+
*
|
|
103
|
+
* Tier 1 (`kind==='global' || isGlobal`) is correct on both topologies: on a
|
|
104
|
+
* dev machine the dev tree and the global install are distinct real paths, so
|
|
105
|
+
* the `global` record survives dedup and wins over the dev tree. On an
|
|
106
|
+
* end-user machine there is no separate `global` record — the running process
|
|
107
|
+
* already IS it, flagged `isGlobal:true` — so tier 1 still finds it. Tier 2
|
|
108
|
+
* (list-order fallback via a stable sort) only applies when tier 1 finds
|
|
109
|
+
* nothing at all — a pure dev checkout or npx-only machine.
|
|
110
|
+
*/
|
|
27
111
|
function findDonor({ installs, destPkgDir, fs }) {
|
|
28
112
|
const norm = (p) => { try { return path.normalize(fs.realpathSync(p)); } catch { return path.normalize(p); } };
|
|
29
113
|
const destReal = norm(destPkgDir);
|
|
30
|
-
|
|
114
|
+
const healthy = installs.filter((i) => i.engineOk && norm(i.pkgDir) !== destReal);
|
|
115
|
+
if (healthy.length === 0) { return null; }
|
|
116
|
+
|
|
117
|
+
const explicitGlobal = healthy.find((i) => i.kind === 'global' || i.isGlobal);
|
|
118
|
+
if (explicitGlobal) { return explicitGlobal; }
|
|
119
|
+
|
|
120
|
+
// No explicit-global donor on this machine at all — rank the remaining
|
|
121
|
+
// healthy candidates by engineVersion (newest first; Array#sort is stable,
|
|
122
|
+
// so ties/absent versions preserve list order, i.e. running-first).
|
|
123
|
+
const sorted = [...healthy].sort((a, b) => compareVersionsDesc(a.engineVersion, b.engineVersion));
|
|
124
|
+
return sorted[0];
|
|
31
125
|
}
|
|
32
126
|
|
|
33
127
|
/** The donor root (nested or hoisted) that actually holds the engine binary. */
|
|
@@ -88,6 +88,35 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
88
88
|
sweepSessionIndexTmp:
|
|
89
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Engine version skew (#133 R-A): a PRESENT engine can still be the WRONG
|
|
93
|
+
* one — the npx-cache copy `npx -y amicus@latest mcp` launches and the
|
|
94
|
+
* global install resolve independently and at different times, and two
|
|
95
|
+
* engine versions writing one shared opencode.db is what produced #133's
|
|
96
|
+
* SQLiteError. Unlike reinstallEngineAv (antivirus quarantine — a MISSING
|
|
97
|
+
* binary), this is a WRONG-VERSION binary, so the remedy has to touch both
|
|
98
|
+
* sides: reinstall the global copy (which re-resolves to the exact pinned
|
|
99
|
+
* opencode-ai/@opencode-ai/sdk version amicus pins as of 4.7.1), and clear
|
|
100
|
+
* the npx-cache tree so the npx copy re-resolves fresh next time it
|
|
101
|
+
* launches instead of replaying a stale cached one.
|
|
102
|
+
*
|
|
103
|
+
* Review round 2, finding 3: the first cut of this hint said
|
|
104
|
+
* `npm cache clean --force`, which does NOT touch the npx tree — verified
|
|
105
|
+
* against npm's own lib/commands/cache.js (npm 11.16.0): `cache clean`
|
|
106
|
+
* removes `flatOptions.cache` (the registry-metadata cache, `<cache
|
|
107
|
+
* dir>/_cacache`), while npx installs live at the SEPARATE
|
|
108
|
+
* `flatOptions.npxCache` (`<cache dir>/_npx`). That command would have left
|
|
109
|
+
* the skewed npx copy byte-for-byte in place. The correct command is npm's
|
|
110
|
+
* own npx-cache subcommand: `npm cache npx rm` with no keys and `--force`
|
|
111
|
+
* empties the whole npx cache (cache.js's `npxRm`: no keys + force →
|
|
112
|
+
* `fs.rm(npxCache, {recursive:true, force:true})`). Since `doctor --fix`
|
|
113
|
+
* has no dedicated skew branch (WARN only — see doctor-engine-check.js),
|
|
114
|
+
* this hint is the ONLY remedy the user gets — it must actually work.
|
|
115
|
+
*/
|
|
116
|
+
engineVersionSkew:
|
|
117
|
+
'npm install -g amicus && npm cache npx rm --force (reinstalls the global engine to the exact pinned opencode-ai version — amicus pins it exactly as of 4.7.1 — '
|
|
118
|
+
+ 'and empties the npx cache, the SEPARATE directory `npm cache clean` does not touch, so the npx-cache copy `npx -y amicus@latest mcp` launches re-resolves fresh to that same pinned version instead of replaying a stale one)',
|
|
119
|
+
|
|
91
120
|
/**
|
|
92
121
|
* Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
|
|
93
122
|
* same producer shape as sweepSessionIndexTmp above, one level down — a
|
|
@@ -91,6 +91,7 @@ function buildWaveResultFromSession(project, waveId) {
|
|
|
91
91
|
legs,
|
|
92
92
|
promptMeta: meta.promptMeta || null,
|
|
93
93
|
...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
|
|
94
|
+
...(meta.tag ? { tag: meta.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
|
|
94
95
|
createdAt: meta.createdAt || null,
|
|
95
96
|
completedAt: meta.completedAt || null,
|
|
96
97
|
});
|
|
@@ -47,6 +47,7 @@ function durationBetween(createdAt, completedAt) {
|
|
|
47
47
|
* @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
|
|
48
48
|
* metadata.pack was recorded (solo session launched via --pack), sourced straight off
|
|
49
49
|
* `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
|
|
50
|
+
* `tag` (v4.7 F8/D13) is additive the same way — present only when metadata.tag was recorded.
|
|
50
51
|
*/
|
|
51
52
|
function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
|
|
52
53
|
const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
|
|
@@ -71,6 +72,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
|
|
|
71
72
|
opencodeSessionId: metadata.opencodeSessionId || null,
|
|
72
73
|
usage: usage !== null ? usage : (metadata.usage || null),
|
|
73
74
|
...(metadata.pack ? { pack: metadata.pack } : {}),
|
|
75
|
+
...(metadata.tag ? { tag: metadata.tag } : {}),
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -127,9 +129,11 @@ function waveExitCode(waveStatus) {
|
|
|
127
129
|
* @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
|
|
128
130
|
* @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
|
|
129
131
|
* additive — present only when the wave was launched via --pack (absent, never null, otherwise).
|
|
132
|
+
* @param {string|null} [opts.tag] - v4.7 F8 (D13): additive — present only when the wave was
|
|
133
|
+
* launched via --tag (absent, never null, otherwise).
|
|
130
134
|
* @returns {object} wave document
|
|
131
135
|
*/
|
|
132
|
-
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
|
|
136
|
+
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null, tag = null }) {
|
|
133
137
|
const { sumWaveUsage } = require('./pricing');
|
|
134
138
|
// Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
|
|
135
139
|
// 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
|
|
@@ -157,6 +161,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
|
|
|
157
161
|
usage: sumWaveUsage(legs),
|
|
158
162
|
notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
|
|
159
163
|
...(pack ? { pack } : {}),
|
|
164
|
+
...(tag ? { tag } : {}),
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -33,9 +33,21 @@ function listSessionIndexTmpFiles() {
|
|
|
33
33
|
return entries
|
|
34
34
|
.filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
|
|
35
35
|
.map((name) => {
|
|
36
|
-
let
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
let st = null;
|
|
37
|
+
// statSync (not lstatSync) is a DECIDED policy, not an oversight (owner
|
|
38
|
+
// ruling, v4.7 PR7 — see BACKLOG.md "session-index-tmp-sweep.js follows
|
|
39
|
+
// symlinks... where its sibling deliberately does not"). This sweep's
|
|
40
|
+
// targets are index tmp files directly in the flat config dir, never
|
|
41
|
+
// traversed through a directory an attacker could redirect — unlike
|
|
42
|
+
// session-metadata-tmp-sweep.js's walk through per-session directories
|
|
43
|
+
// (see that file's "Symlink safety" paragraph for why THAT sweep must
|
|
44
|
+
// never follow). Following here means a symlink named like an orphaned
|
|
45
|
+
// tmp file is still swept (unlink removes only the link); the one real
|
|
46
|
+
// consequence is AGE_THRESHOLD_MS reading the TARGET's mtime, so a fresh
|
|
47
|
+
// link to an old file is swept with no grace window. Filed, not fixed —
|
|
48
|
+
// see the BACKLOG entry above for the full inclusion/unfiled-delta split.
|
|
49
|
+
try { st = fs.statSync(path.join(dir, name)); } catch { /* raced away */ }
|
|
50
|
+
return { name, mtimeMs: st && st.isFile() ? st.mtimeMs : null };
|
|
39
51
|
})
|
|
40
52
|
.filter((f) => f.mtimeMs !== null);
|
|
41
53
|
}
|
|
@@ -52,6 +64,9 @@ function unlinkSessionIndexTmp(name) {
|
|
|
52
64
|
* this in guard() the same way it wires the mcp-legacy check's inspect/migrate.
|
|
53
65
|
* @param {{listSessionIndexTmpFiles: () => Array<{name:string, mtimeMs:number}>,
|
|
54
66
|
* fix?: boolean, now: () => number, unlinkSessionIndexTmp: (name: string) => void}} d
|
|
67
|
+
* Its four `message` strings are byte-identical to
|
|
68
|
+
* session-metadata-tmp-sweep.js's evaluateSessionMetadataTmpSweep by design
|
|
69
|
+
* (only `id`/`name`/`fixDetail` differ) — reword both or neither.
|
|
55
70
|
*/
|
|
56
71
|
function evaluateSessionIndexTmpSweep(d) {
|
|
57
72
|
const id = 'sessions-index-tmp'; const name = 'Session index tmp files';
|
|
@@ -29,6 +29,14 @@
|
|
|
29
29
|
* symlinked taskId or subagents directory could otherwise be traversed and
|
|
30
30
|
* have files unlinked through the link, effectively outside the sessions
|
|
31
31
|
* root; lstat closes that off at zero cost.
|
|
32
|
+
*
|
|
33
|
+
* Consequence of the SR-3 isFile() gate (listTmpIn, below): a SYMLINK whose
|
|
34
|
+
* basename matches the tmp pattern is now excluded from the list entirely —
|
|
35
|
+
* neither swept nor reported. Before SR-3 it was swept (unlink removes the
|
|
36
|
+
* link, never the target — a safe success). Deliberate: this module's
|
|
37
|
+
* never-follow policy applies to the entries it unlinks too. Note the sibling
|
|
38
|
+
* session-index-tmp-sweep.js diverges here — it uses statSync, so a
|
|
39
|
+
* symlink-to-a-file with the matching name IS still swept there.
|
|
32
40
|
*/
|
|
33
41
|
|
|
34
42
|
const fs = require('fs');
|
|
@@ -38,7 +46,14 @@ const HINTS = require('./remediation-hints');
|
|
|
38
46
|
/** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
|
|
39
47
|
const AGE_THRESHOLD_MS = 60 * 1000;
|
|
40
48
|
|
|
41
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The cwd-scoped sessions root: <cwd>/.claude/amicus_sessions.
|
|
51
|
+
* Reads process.cwd() directly, NOT doctor's injected getCwd
|
|
52
|
+
* (cli-handlers-doctor.js's realDeps().getCwd) — the
|
|
53
|
+
* listSessionMetadataTmpFiles/unlinkSessionMetadataTmp deps are wired
|
|
54
|
+
* argument-free in that same realDeps(), so that seam does not reach here.
|
|
55
|
+
* Thread cwd through those deps if a `doctor --cwd <dir>` mode ever lands.
|
|
56
|
+
*/
|
|
42
57
|
function sessionsRoot() {
|
|
43
58
|
const { SESSIONS_DIR } = require('../session-manager');
|
|
44
59
|
return path.join(process.cwd(), '.claude', SESSIONS_DIR);
|
|
@@ -56,9 +71,9 @@ function listTmpIn(dir, root) {
|
|
|
56
71
|
return entries
|
|
57
72
|
.filter(isMetadataTmp)
|
|
58
73
|
.map((basename) => {
|
|
59
|
-
let
|
|
60
|
-
try {
|
|
61
|
-
return { name: path.relative(root, path.join(dir, basename)), mtimeMs };
|
|
74
|
+
let st = null;
|
|
75
|
+
try { st = fs.lstatSync(path.join(dir, basename)); } catch { /* raced away */ }
|
|
76
|
+
return { name: path.relative(root, path.join(dir, basename)), mtimeMs: st && st.isFile() ? st.mtimeMs : null };
|
|
62
77
|
})
|
|
63
78
|
.filter((f) => f.mtimeMs !== null);
|
|
64
79
|
}
|
|
@@ -107,6 +122,11 @@ function unlinkSessionMetadataTmp(name) {
|
|
|
107
122
|
* wraps this in guard() the same way it wires the sibling sessions-index-tmp check.
|
|
108
123
|
* @param {{listSessionMetadataTmpFiles: () => Array<{name:string, mtimeMs:number}>,
|
|
109
124
|
* fix?: boolean, now: () => number, unlinkSessionMetadataTmp: (name: string) => void}} d
|
|
125
|
+
* The four `message` strings below are byte-identical to the index sibling's
|
|
126
|
+
* (session-index-tmp-sweep.js's evaluateSessionIndexTmpSweep, same four
|
|
127
|
+
* ok/warn returns) on purpose — `id`/`name` and the `fixDetail` wording are
|
|
128
|
+
* the only disambiguators between the two rows. Reword one side and the
|
|
129
|
+
* pairing silently breaks: reword both, or neither.
|
|
110
130
|
*/
|
|
111
131
|
function evaluateSessionMetadataTmpSweep(d) {
|
|
112
132
|
const id = 'session-metadata-tmp'; const name = 'Session metadata tmp files';
|
|
@@ -55,13 +55,17 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
|
|
|
55
55
|
* @param {string} [opts.councilName] council name (additive attribution)
|
|
56
56
|
* @param {string} [opts.project] project directory (additive attribution)
|
|
57
57
|
* @param {string} [opts.gateway] resolved gateway ('direct'|'openrouter'|'local', additive attribution)
|
|
58
|
+
* @param {string} [opts.tag] user-chosen tag (v4.7 F8 D16, additive attribution) —
|
|
59
|
+
* nullable dim, same null-not-absent convention as councilRunId/project below;
|
|
60
|
+
* deliberately the OPPOSITE convention from createSessionMetadata's tag (D13,
|
|
61
|
+
* absent-not-null) — see the dim-list comment below.
|
|
58
62
|
* @param {number} [opts.attempt] fallback attempt count (omitted if absent)
|
|
59
63
|
* @param {string} [opts.substitutedFor] substituted model (omitted if absent)
|
|
60
64
|
* @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
|
|
61
65
|
* @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
|
|
62
66
|
*/
|
|
63
67
|
function appendSpend({ taskId, waveId, model, mode, usage,
|
|
64
|
-
op, status, councilRunId, councilName, project, gateway,
|
|
68
|
+
op, status, councilRunId, councilName, project, gateway, tag,
|
|
65
69
|
attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
|
|
66
70
|
if (!usage) { return; }
|
|
67
71
|
try {
|
|
@@ -76,15 +80,18 @@ function appendSpend({ taskId, waveId, model, mode, usage,
|
|
|
76
80
|
mode: mode || null,
|
|
77
81
|
tokens: usage.tokens || null,
|
|
78
82
|
cost: usage.cost || null,
|
|
79
|
-
// v4.3 additive attribution (spec 7.1).
|
|
80
|
-
//
|
|
81
|
-
//
|
|
83
|
+
// v4.3 additive attribution (spec 7.1), extended v4.7 F8 D16 with `tag`.
|
|
84
|
+
// Nullable dimensions (op/status/councilRunId/councilName/project/
|
|
85
|
+
// gateway/tag) default to null (so a row is always groupable); linkage
|
|
86
|
+
// fields are OMITTED unless present (they only exist on fallback/retry
|
|
87
|
+
// rows).
|
|
82
88
|
op: op || null,
|
|
83
89
|
status: status || null,
|
|
84
90
|
councilRunId: councilRunId || null,
|
|
85
91
|
councilName: councilName || null,
|
|
86
92
|
project: project || null,
|
|
87
93
|
gateway: gateway || null,
|
|
94
|
+
tag: tag || null,
|
|
88
95
|
};
|
|
89
96
|
if (attempt !== undefined) { row.attempt = attempt; }
|
|
90
97
|
if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
|
package/src/utils/validators.js
CHANGED
|
@@ -24,6 +24,20 @@ const { PROVIDER_KEY_MAP } = require('./provider-registry');
|
|
|
24
24
|
/** Task ID format: alphanumeric, hyphens, underscores, 1-64 chars */
|
|
25
25
|
const TASK_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
26
26
|
|
|
27
|
+
const TAG_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
28
|
+
/**
|
|
29
|
+
* v4.7 F8 (D13): tag validation REJECTS (unlike sanitizeCouncilName, which
|
|
30
|
+
* cleans) — a stored tag is a user-chosen search key, so silent truncation
|
|
31
|
+
* or charset-stripping would make `--search`/`--group-by tag` miss it.
|
|
32
|
+
* Non-string guards the valueless `--tag` parse (cli.js turns it into true).
|
|
33
|
+
*/
|
|
34
|
+
function validateTag(value) {
|
|
35
|
+
if (typeof value !== 'string' || !TAG_PATTERN.test(value)) {
|
|
36
|
+
return { ok: false, error: 'Invalid --tag: 1-64 chars, letters/digits/_/- only' };
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, tag: value };
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
/**
|
|
28
42
|
* Validate a task ID format (safe for use in file paths)
|
|
29
43
|
* @param {string} taskId
|
|
@@ -249,6 +263,8 @@ module.exports = {
|
|
|
249
263
|
MODEL_THINKING_SUPPORT,
|
|
250
264
|
TASK_ID_PATTERN,
|
|
251
265
|
validateTaskId,
|
|
266
|
+
TAG_PATTERN,
|
|
267
|
+
validateTag,
|
|
252
268
|
safeSessionDir,
|
|
253
269
|
validatePromptContent,
|
|
254
270
|
validateCwdPath,
|