amicus 4.6.2 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +349 -0
- package/README.md +24 -13
- package/bin/amicus.js +31 -0
- package/docs/ROADMAP.md +172 -36
- package/docs/configuration.md +56 -6
- package/docs/council.md +63 -10
- package/docs/doc-system.md +8 -7
- package/docs/schemas.md +10 -1
- package/docs/troubleshooting.md +27 -1
- package/docs/usage.md +68 -15
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +132 -21
- package/electron/workspace-ui/workspace-app.js +20 -4
- 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 +15 -5
- package/electron/workspace-ui/workspace-seats.js +88 -5
- package/electron/workspace-ui/workspace-verbs.js +1 -1
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +5 -2
- 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/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
- package/skills/second-opinion/MODEL-NOTES.md +5 -4
- package/skills/sidecar/SKILL.md +7 -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 +24 -3
- package/src/cli-handlers-doctor.js +9 -3
- package/src/cli-handlers-fanout.js +179 -0
- package/src/cli-handlers-pack.js +24 -10
- 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/presets-cli.js +6 -2
- 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 +70 -11
- package/src/council/run-debate.js +51 -67
- package/src/council/run-launch.js +9 -2
- package/src/council/run-retry.js +4 -1
- 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/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/session-manager.js +6 -2
- package/src/sidecar/budget.js +38 -4
- package/src/sidecar/fanout-budget.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +7 -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/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/alias-audit.js +19 -0
- package/src/utils/cli-preflight.js +27 -1
- package/src/utils/config.js +15 -0
- package/src/utils/curated-models.js +43 -7
- package/src/utils/gateway-route-audit.js +16 -3
- package/src/utils/model-fetcher.js +8 -6
- package/src/utils/remediation-hints.js +14 -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 +156 -0
- package/src/utils/spend-ledger.js +11 -4
- package/src/utils/validators.js +16 -0
|
@@ -8,17 +8,19 @@
|
|
|
8
8
|
const https = require('https');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Hardcoded Anthropic
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* Hardcoded Anthropic floor: the anthropic/ rows a KEYLESS user (or a
|
|
12
|
+
* failed live fetch) gets. Every id here must be one the direct API
|
|
13
|
+
* GENUINELY serves — classifyModel() returns 'valid' on a floor HIT before
|
|
14
|
+
* it ever checks `authoritative`, so a speculative row would mislabel a
|
|
15
|
+
* dead direct-API request as valid. (fable joined 2026-08-05 after live
|
|
16
|
+
* verification — /v1/models lists claude-fable-5 and a direct smoke leg
|
|
17
|
+
* served; v4.6.3 spec §3.)
|
|
17
18
|
*/
|
|
18
19
|
const ANTHROPIC_MODELS = [
|
|
19
20
|
{ id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', contextLength: null, pricing: null },
|
|
20
21
|
{ id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
|
|
21
22
|
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
|
|
23
|
+
{ id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', contextLength: null, pricing: null },
|
|
22
24
|
{ id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
|
|
23
25
|
// Dated snapshot: the id Anthropic's /v1/models actually lists, and the
|
|
24
26
|
// `haiku` direct route curated-models.js authors. Without it the floor
|
|
@@ -87,6 +87,20 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
87
87
|
*/
|
|
88
88
|
sweepSessionIndexTmp:
|
|
89
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
|
|
93
|
+
* same producer shape as sweepSessionIndexTmp above, one level down — a
|
|
94
|
+
* kill between an atomic write's tmp-write and rename leaves a stray temp
|
|
95
|
+
* file in a session directory forever. `doctor --fix` sweeps files older
|
|
96
|
+
* than 60s (never a live writer's ms-lived tmp).
|
|
97
|
+
*
|
|
98
|
+
* Voice ruling (Christian, 2026-08-03, sweepSessionIndexTmp above): applies
|
|
99
|
+
* verbatim here — the cause is definitional, not a guess, so this hint also
|
|
100
|
+
* keeps its confident voice rather than the unverified-cause voice.
|
|
101
|
+
*/
|
|
102
|
+
sweepSessionMetadataTmp:
|
|
103
|
+
'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
|
|
90
104
|
});
|
|
91
105
|
|
|
92
106
|
module.exports = REMEDIATION_HINTS;
|
|
@@ -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';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/utils/session-metadata-tmp-sweep.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* v4.6.3 PR3 Task 3 (D8): orphaned per-session metadata.json.*.tmp sweep for
|
|
6
|
+
* `amicus doctor --fix`.
|
|
7
|
+
*
|
|
8
|
+
* A kill between writeFileAtomic's tmp-write and rename (any of the ~30
|
|
9
|
+
* metadata.json write sites — session-manager.js, sidecar/session-finalize.js,
|
|
10
|
+
* etc.) leaves a stray `.metadata.json.<pid>.<hex>.tmp` file in a session
|
|
11
|
+
* directory forever (the B09 orphan class). This module lists and removes
|
|
12
|
+
* them; src/cli-handlers-doctor.js composes the result into a check line.
|
|
13
|
+
* Structurally mirrors utils/session-index-tmp-sweep.js (B15's sibling).
|
|
14
|
+
*
|
|
15
|
+
* Enumeration decision (cwd-scoped, index rejected — recorded at plan time,
|
|
16
|
+
* docs/superpowers/plans/2026-08-05-v463-pr3-cli-doctor-odds.md): this walks
|
|
17
|
+
* `<process.cwd()>/.claude/amicus_sessions/` — each taskId dir plus its
|
|
18
|
+
* `subagents/<id>/` children — rather than consulting sessions-index.json.
|
|
19
|
+
* `amicus doctor` is a per-project surface; the index is best-effort and can
|
|
20
|
+
* point at OTHER projects (issue #40's cross-project fallback exists exactly
|
|
21
|
+
* because the index can be stale). Trusting it here to decide which
|
|
22
|
+
* directories a --fix sweep may unlink from risks touching an unrelated
|
|
23
|
+
* project on stale data — the wrong failure direction for a destructive
|
|
24
|
+
* operation. Walking the cwd-scoped tree directly can only ever find/remove
|
|
25
|
+
* files under the project doctor is already running against.
|
|
26
|
+
*
|
|
27
|
+
* Symlink safety (Task 3 review carry, v4.6.3 PR3): every stat in this walk
|
|
28
|
+
* is lstatSync, never statSync — this module never follows symlinks. A
|
|
29
|
+
* symlinked taskId or subagents directory could otherwise be traversed and
|
|
30
|
+
* have files unlinked through the link, effectively outside the sessions
|
|
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.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
const HINTS = require('./remediation-hints');
|
|
45
|
+
|
|
46
|
+
/** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
|
|
47
|
+
const AGE_THRESHOLD_MS = 60 * 1000;
|
|
48
|
+
|
|
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
|
+
*/
|
|
57
|
+
function sessionsRoot() {
|
|
58
|
+
const { SESSIONS_DIR } = require('../session-manager');
|
|
59
|
+
return path.join(process.cwd(), '.claude', SESSIONS_DIR);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True when `basename` is an orphaned metadata tmp file (not e.g. progress.json.*.tmp). */
|
|
63
|
+
function isMetadataTmp(basename) {
|
|
64
|
+
return basename.startsWith('.metadata.json.') && basename.endsWith('.tmp');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** List metadata tmp files directly inside `dir`, named relative to `root`. */
|
|
68
|
+
function listTmpIn(dir, root) {
|
|
69
|
+
let entries;
|
|
70
|
+
try { entries = fs.readdirSync(dir); } catch { return []; }
|
|
71
|
+
return entries
|
|
72
|
+
.filter(isMetadataTmp)
|
|
73
|
+
.map((basename) => {
|
|
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 };
|
|
77
|
+
})
|
|
78
|
+
.filter((f) => f.mtimeMs !== null);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List orphaned per-session metadata.json.*.tmp files under the cwd-scoped
|
|
83
|
+
* sessions root, covering both `<taskId>/` and `<taskId>/subagents/<id>/`.
|
|
84
|
+
* @returns {Array<{name: string, mtimeMs: number}>}
|
|
85
|
+
*/
|
|
86
|
+
function listSessionMetadataTmpFiles() {
|
|
87
|
+
const { SUBAGENTS_DIR } = require('../session-manager');
|
|
88
|
+
const root = sessionsRoot();
|
|
89
|
+
let taskIds;
|
|
90
|
+
try { taskIds = fs.readdirSync(root); } catch { return []; }
|
|
91
|
+
const found = [];
|
|
92
|
+
for (const taskId of taskIds) {
|
|
93
|
+
const taskDir = path.join(root, taskId);
|
|
94
|
+
let stat;
|
|
95
|
+
try { stat = fs.lstatSync(taskDir); } catch { continue; }
|
|
96
|
+
if (!stat.isDirectory()) { continue; }
|
|
97
|
+
found.push(...listTmpIn(taskDir, root));
|
|
98
|
+
|
|
99
|
+
const subagentsDir = path.join(taskDir, SUBAGENTS_DIR);
|
|
100
|
+
let subIds;
|
|
101
|
+
try { subIds = fs.readdirSync(subagentsDir); } catch { continue; }
|
|
102
|
+
for (const subId of subIds) {
|
|
103
|
+
const subDir = path.join(subagentsDir, subId);
|
|
104
|
+
let subStat;
|
|
105
|
+
try { subStat = fs.lstatSync(subDir); } catch { continue; }
|
|
106
|
+
if (!subStat.isDirectory()) { continue; }
|
|
107
|
+
found.push(...listTmpIn(subDir, root));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Delete one orphaned tmp file by name (relative to the sessions root; never an absolute/caller path). */
|
|
114
|
+
function unlinkSessionMetadataTmp(name) {
|
|
115
|
+
const root = sessionsRoot();
|
|
116
|
+
fs.unlinkSync(path.join(root, name));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compose the doctor check line for the metadata tmp-orphan sweep. Pure
|
|
121
|
+
* decision logic (list/sweep side effects come in via `d`); src/cli-handlers-doctor.js
|
|
122
|
+
* wraps this in guard() the same way it wires the sibling sessions-index-tmp check.
|
|
123
|
+
* @param {{listSessionMetadataTmpFiles: () => Array<{name:string, mtimeMs:number}>,
|
|
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.
|
|
130
|
+
*/
|
|
131
|
+
function evaluateSessionMetadataTmpSweep(d) {
|
|
132
|
+
const id = 'session-metadata-tmp'; const name = 'Session metadata tmp files';
|
|
133
|
+
const files = d.listSessionMetadataTmpFiles() || [];
|
|
134
|
+
if (files.length === 0) {
|
|
135
|
+
return { id, name, status: 'ok', message: '0 orphaned tmp files', hint: null };
|
|
136
|
+
}
|
|
137
|
+
if (!d.fix) {
|
|
138
|
+
return { id, name, status: 'warn', message: `${files.length} orphaned tmp file(s) — run with --fix`, hint: HINTS.sweepSessionMetadataTmp };
|
|
139
|
+
}
|
|
140
|
+
const nowMs = d.now();
|
|
141
|
+
const sweepable = files.filter((f) => (nowMs - f.mtimeMs) > AGE_THRESHOLD_MS);
|
|
142
|
+
let swept = 0;
|
|
143
|
+
for (const f of sweepable) {
|
|
144
|
+
try { d.unlinkSessionMetadataTmp(f.name); swept += 1; } catch { /* best-effort — report what we got */ }
|
|
145
|
+
}
|
|
146
|
+
const remaining = files.length - swept;
|
|
147
|
+
if (remaining === 0) {
|
|
148
|
+
const fixFields = swept > 0 ? { fixed: true, fixDetail: `swept ${swept} orphaned session-metadata tmp file(s)` } : {};
|
|
149
|
+
return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null, ...fixFields };
|
|
150
|
+
}
|
|
151
|
+
return { id, name, status: 'warn', message: `swept ${swept}, ${remaining} remaining (too fresh or unremovable)`, hint: HINTS.sweepSessionMetadataTmp };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
AGE_THRESHOLD_MS, listSessionMetadataTmpFiles, unlinkSessionMetadataTmp, evaluateSessionMetadataTmpSweep,
|
|
156
|
+
};
|
|
@@ -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,
|