amicus 4.6.3 → 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 +283 -0
- package/README.md +19 -7
- package/bin/amicus.js +31 -0
- package/docs/ROADMAP.md +143 -36
- package/docs/configuration.md +51 -3
- 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 +59 -13
- 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 +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 +17 -5
- 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/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 +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/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/cli-preflight.js +27 -1
- package/src/utils/config.js +15 -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
|
@@ -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,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: [
|
|
@@ -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();
|
|
@@ -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';
|