amicus 4.7.1 → 4.8.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 +988 -1
- package/README.md +5 -4
- package/docs/CITATIONS.md +122 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +179 -32
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/troubleshooting.md +3 -3
- package/docs/usage.md +2 -2
- package/electron/ipc-setup.js +18 -2
- package/electron/main.js +46 -3
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +231 -29
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +4 -1
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/src/cli-handlers-doctor.js +18 -14
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/parse-stage2.js +1 -1
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +45 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +166 -161
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +20 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +1 -1
- package/src/sidecar/setup.js +137 -2
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-alias-check.js +152 -0
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +37 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +30 -4
- package/src/workspace/seat-space.js +143 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// src/utils/session-index-prune.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* R16: sessions-index.json stale-entry prune for `amicus doctor --fix`.
|
|
6
|
+
*
|
|
7
|
+
* session-index.js :: recordSession appends taskId -> canonicalProjectPath on
|
|
8
|
+
* every session start and nothing ever removes one. A project that is
|
|
9
|
+
* deleted, renamed or moved leaves its rows behind forever, and every
|
|
10
|
+
* recordSession call pays for rewriting the WHOLE index (full read -> parse
|
|
11
|
+
* -> mutate -> stringify -> atomic write), so the per-start cost grows with
|
|
12
|
+
* total sessions ever, not live ones (docs/superpowers/plans/
|
|
13
|
+
* 2026-08-22-v48-wave25-r16-index-prune.md §0.1). This module lists and
|
|
14
|
+
* removes the dead rows; src/cli-handlers-doctor.js composes the result into
|
|
15
|
+
* a check line. Structurally mirrors utils/session-index-tmp-sweep.js (R16-1:
|
|
16
|
+
* reuse that whole warn/fix/hint shape rather than inventing a new one).
|
|
17
|
+
*
|
|
18
|
+
* R16-2 (liveness, NEVER age): an entry is stale IFF its project path no
|
|
19
|
+
* longer resolves to a directory on disk. No TTL, no mtime sort — a
|
|
20
|
+
* five-year-old entry for a project that still exists is still a valid
|
|
21
|
+
* lookup target; a one-day-old entry for a deleted project is not. Unlike its
|
|
22
|
+
* tmp-file sibling (which age-gates in `evaluate` so a live writer's ms-lived
|
|
23
|
+
* tmp is never swept), there is no such grace window here by design.
|
|
24
|
+
*
|
|
25
|
+
* R16-3 (probe distinct projects, not entries): the index is
|
|
26
|
+
* taskId -> project and many task ids share one project (measured: `amicus
|
|
27
|
+
* list --all`'s enumerateAllProjects walks every distinct project — 21,145
|
|
28
|
+
* rows in 8,275ms before a manual prune to 187 entries, 132 rows in 53ms
|
|
29
|
+
* after). Probing per-entry would repeat the same statSync for every task id
|
|
30
|
+
* that shares a project; `listStaleSessionIndexEntries` dedupes to the
|
|
31
|
+
* distinct project set FIRST, statSyncs each ONCE, then marks entries whose
|
|
32
|
+
* project is in the dead set. Both counts are reported.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const HINTS = require('./remediation-hints');
|
|
38
|
+
|
|
39
|
+
// The zeroed shape for "checked, nothing stale". Also the base (via spread)
|
|
40
|
+
// for the distinguishable failure shape listStaleSessionIndexEntries returns
|
|
41
|
+
// on an internal error — see that function's catch, below.
|
|
42
|
+
const EMPTY_RESULT = Object.freeze({
|
|
43
|
+
staleTaskIds: [], entryCount: 0, distinctProjectCount: 0, staleProjectCount: 0,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether `project` still resolves to a directory.
|
|
48
|
+
*
|
|
49
|
+
* statSync (not lstatSync) is deliberate: unlike session-index-tmp-sweep.js's
|
|
50
|
+
* DESTRUCTIVE unlink (see that file's symlink-safety comment) or
|
|
51
|
+
* session-metadata-tmp-sweep.js's never-follow walk, nothing here ever
|
|
52
|
+
* deletes anything AT `project` — only the taskId's row in a wholly separate
|
|
53
|
+
* JSON file. A project reached through a symlink is a live lookup target
|
|
54
|
+
* exactly like any other; a DANGLING symlink should read as gone the same way
|
|
55
|
+
* a deleted real directory does, which is what following (statSync), not
|
|
56
|
+
* lstatSync, gives us.
|
|
57
|
+
*
|
|
58
|
+
* Judgment call (R16, left open by the plan on purpose): only ENOENT/ENOTDIR
|
|
59
|
+
* is treated as "confirmed gone". Everything else — EACCES, EPERM, a
|
|
60
|
+
* transient EIO, an unmounted network share timing out — means "cannot
|
|
61
|
+
* confirm", not "confirmed gone", so it is treated as LIVE. This mirrors the
|
|
62
|
+
* ENOENT-only split workspace/artifact-guard.js :: readRunArtifact already
|
|
63
|
+
* uses for the identical ambiguity (its RN-10 fix). Treating "cannot read" as
|
|
64
|
+
* "does not exist" would prune a LIVE entry on a permissions blip — both a
|
|
65
|
+
* liveness-check correctness bug (R16-2) and a silent loss of user state
|
|
66
|
+
* (plan Global Constraint 5).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} project
|
|
69
|
+
* @param {(p: string) => import('fs').Stats} statSync
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
function projectExists(project, statSync) {
|
|
73
|
+
try {
|
|
74
|
+
return statSync(project).isDirectory();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) { return false; }
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List session-index entries whose project no longer exists.
|
|
83
|
+
*
|
|
84
|
+
* Never THROWS: the corrupt/missing-index case is handled exactly as
|
|
85
|
+
* session-index.js :: readIndex already handles it (corrupt/missing -> {}),
|
|
86
|
+
* so it never reaches the catch below. An unexpected internal failure still
|
|
87
|
+
* degrades rather than propagates into `doctor` — but (council R16 fix round
|
|
88
|
+
* A3) the degraded result now carries `error`, which an all-clear result
|
|
89
|
+
* never has, so "checked, 0 stale" and "could not check" can never collapse
|
|
90
|
+
* to the same value. See evaluateSessionIndexPrune for how that distinction
|
|
91
|
+
* is read.
|
|
92
|
+
*
|
|
93
|
+
* @param {{statSync?: (p: string) => import('fs').Stats,
|
|
94
|
+
* readIndex?: () => Record<string,string>}} [deps] - injectable for tests
|
|
95
|
+
* (e.g. to simulate EACCES without needing real OS permissions).
|
|
96
|
+
* @returns {{staleTaskIds: string[], entryCount: number,
|
|
97
|
+
* distinctProjectCount: number, staleProjectCount: number, error?: string}}
|
|
98
|
+
*/
|
|
99
|
+
function listStaleSessionIndexEntries(deps = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const statSync = deps.statSync || fs.statSync;
|
|
102
|
+
const readIndex = deps.readIndex || require('./session-index').readIndex;
|
|
103
|
+
|
|
104
|
+
const index = readIndex() || {};
|
|
105
|
+
const taskIds = Object.keys(index);
|
|
106
|
+
|
|
107
|
+
// R16-3: dedupe to the DISTINCT project set before ever touching the disk.
|
|
108
|
+
const projects = new Set();
|
|
109
|
+
for (const taskId of taskIds) {
|
|
110
|
+
const project = index[taskId];
|
|
111
|
+
if (typeof project === 'string' && project) { projects.add(project); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const deadProjects = new Set();
|
|
115
|
+
for (const project of projects) {
|
|
116
|
+
if (!projectExists(project, statSync)) { deadProjects.add(project); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const staleTaskIds = taskIds.filter((taskId) => {
|
|
120
|
+
const project = index[taskId];
|
|
121
|
+
// A non-string/empty project can never resolve to a lookup target
|
|
122
|
+
// either — dead weight from the same "nothing ever removes a row" gap,
|
|
123
|
+
// and this is the only place that can ever clean it up.
|
|
124
|
+
return !(typeof project === 'string' && project) || deadProjects.has(project);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
staleTaskIds,
|
|
129
|
+
entryCount: taskIds.length,
|
|
130
|
+
distinctProjectCount: projects.size,
|
|
131
|
+
staleProjectCount: deadProjects.size,
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Council R16 fix round (A3): landing here must not read the same as "0
|
|
135
|
+
// stale rows" — this project's stated north star is that a
|
|
136
|
+
// correct-but-SILENT degrade fails the bar as hard as a crash. The
|
|
137
|
+
// expected corrupt/missing-index case never reaches this catch (readIndex
|
|
138
|
+
// itself already guards to {}); what DOES land here is either a real bug
|
|
139
|
+
// or a throwing injected dep (deps.readIndex/deps.statSync are both real
|
|
140
|
+
// injection points). Tag the result so evaluateSessionIndexPrune can tell
|
|
141
|
+
// "checked, found nothing" apart from "could not check" — still never
|
|
142
|
+
// throws into doctor, just stops reporting a failure as an all-clear.
|
|
143
|
+
return { ...EMPTY_RESULT, error: (err && err.message) || String(err) };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove the given taskIds from the index, atomically, through the same
|
|
149
|
+
* writeFileAtomic path session-index.js :: recordSession uses. Re-reads the
|
|
150
|
+
* index fresh (never reuses a list-time snapshot), deletes ONLY the given
|
|
151
|
+
* ids — never any other key a concurrent writer may have added to the
|
|
152
|
+
* freshly-read index (verified: tests/doctor-index-prune.test.js's "removes
|
|
153
|
+
* only the given ids" and the B1 concurrent-add case below) — and writes
|
|
154
|
+
* only when at least one given id is still actually present.
|
|
155
|
+
*
|
|
156
|
+
* `deps.readIndex` plus the `|| {}` guard below mirror
|
|
157
|
+
* listStaleSessionIndexEntries (council R16 fix round A1/A4): that was the
|
|
158
|
+
* one asymmetry between the two siblings — `list` was stub-testable for a
|
|
159
|
+
* corrupt/absent readIndex, `prune` was not testable at all without a real
|
|
160
|
+
* config dir, and an injected readIndex() returning null/undefined would
|
|
161
|
+
* TypeError on `Object.prototype.hasOwnProperty.call(null, ...)`. The REAL
|
|
162
|
+
* readIndex can never return null (session-index.js :: readIndex always
|
|
163
|
+
* yields a validated object or `{}`), so this never fires in production —
|
|
164
|
+
* it closes a tested gap, not a live bug. Still does NOT guard its own
|
|
165
|
+
* THROWS, matching session-index-tmp-sweep.js :: unlinkSessionIndexTmp,
|
|
166
|
+
* which also lets fs errors propagate to its caller; evaluateSessionIndexPrune
|
|
167
|
+
* (below) is that catcher.
|
|
168
|
+
*
|
|
169
|
+
* B1/D1 (council R16 fix round 2 — raised again by two more models;
|
|
170
|
+
* adjudicated, real, pre-existing, still NOT fixed here): the race is
|
|
171
|
+
* TWO-SIDED. `target` is computed before the read so only the in-memory
|
|
172
|
+
* filter below runs between read and write — the narrowest this window gets
|
|
173
|
+
* without real synchronization — but session-index.js :: recordSession
|
|
174
|
+
* performs the IDENTICAL unlocked read-modify-write. Locking only THIS side
|
|
175
|
+
* would be theater: closing the race properly requires locking
|
|
176
|
+
* recordSession too, exactly the hot-start-path cost ruling R16-1 rejected
|
|
177
|
+
* when it chose this doctor-check design over "prune on write". Consequence,
|
|
178
|
+
* unchanged: a session recorded by another process between this read and
|
|
179
|
+
* this write is lost, degrading `amicus read <id>` from another project
|
|
180
|
+
* into a not-found. `src/utils/session-lock.js` already provides atomic
|
|
181
|
+
* PID/staleness lock-file primitives (used today per-session-dir, not for
|
|
182
|
+
* this file) — the natural home for that future lock/CAS, noted so it is
|
|
183
|
+
* not re-derived, but NOT wired in here: its own change, beyond R16.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} staleTaskIds
|
|
186
|
+
* @param {{readIndex?: () => Record<string,string>}} [deps] - injectable for
|
|
187
|
+
* tests (matches listStaleSessionIndexEntries's shape/rationale above).
|
|
188
|
+
* @returns {number} count actually removed (may be less than
|
|
189
|
+
* staleTaskIds.length if an id was already gone by the time this ran).
|
|
190
|
+
*/
|
|
191
|
+
function pruneStaleSessionIndexEntries(staleTaskIds, deps = {}) {
|
|
192
|
+
// Array.isArray, not `|| []` (council R16 fix round 2, A2/d1): the old
|
|
193
|
+
// guard caught null/undefined but let any OTHER truthy value through — a
|
|
194
|
+
// string would iterate per character below, a plain object would throw.
|
|
195
|
+
const ids = Array.isArray(staleTaskIds) ? staleTaskIds : [];
|
|
196
|
+
if (ids.length === 0) { return 0; }
|
|
197
|
+
|
|
198
|
+
// Lazy requires here match session-index-tmp-sweep.js's established
|
|
199
|
+
// convention for these same deps (council R16 fix round 2, A4) — kept, not hoisted.
|
|
200
|
+
const { INDEX_FILENAME, readIndex: realReadIndex } = require('./session-index');
|
|
201
|
+
const readIndex = deps.readIndex || realReadIndex;
|
|
202
|
+
const { getConfigDir } = require('./config');
|
|
203
|
+
const { writeFileAtomic } = require('./atomic-write');
|
|
204
|
+
const target = path.join(getConfigDir(), INDEX_FILENAME);
|
|
205
|
+
|
|
206
|
+
const index = readIndex() || {};
|
|
207
|
+
let removed = 0;
|
|
208
|
+
for (const taskId of ids) {
|
|
209
|
+
if (Object.prototype.hasOwnProperty.call(index, taskId)) {
|
|
210
|
+
delete index[taskId];
|
|
211
|
+
removed += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (removed === 0) { return 0; }
|
|
215
|
+
|
|
216
|
+
writeFileAtomic(target, JSON.stringify(index, null, 2), { mode: 0o600 });
|
|
217
|
+
return removed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Compose the doctor check line for the stale-entry prune. Pure decision
|
|
222
|
+
* logic (list/prune side effects come in via `d`); src/cli-handlers-doctor.js
|
|
223
|
+
* wraps this in guard() the same way it wires the tmp-sweep check beside it.
|
|
224
|
+
* @param {{listStaleSessionIndexEntries: () => {staleTaskIds:string[],
|
|
225
|
+
* entryCount:number, distinctProjectCount:number, staleProjectCount:number,
|
|
226
|
+
* error?:string},
|
|
227
|
+
* fix?: boolean,
|
|
228
|
+
* pruneStaleSessionIndexEntries: (ids: string[]) => number}} d
|
|
229
|
+
*/
|
|
230
|
+
function evaluateSessionIndexPrune(d) {
|
|
231
|
+
const id = 'sessions-index-prune'; const name = 'Session index stale entries';
|
|
232
|
+
const list = d.listStaleSessionIndexEntries();
|
|
233
|
+
|
|
234
|
+
// Council R16 fix round (A3): a listing failure gets its own status, never
|
|
235
|
+
// coalesced into the "0 stale" shape below. `status: 'error'` is the same
|
|
236
|
+
// vocabulary guard() uses for every other doctor check failure —
|
|
237
|
+
// doctor-degrade.js turns it into a 'doctor-check-failed' record; doctor
|
|
238
|
+
// still finishes and prints every other line (loud, not fatal).
|
|
239
|
+
if (!list || list.error) {
|
|
240
|
+
return {
|
|
241
|
+
id, name, status: 'error',
|
|
242
|
+
message: `could not determine stale session-index entries: ${(list && list.error) || 'unknown error'}`,
|
|
243
|
+
hint: null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const { staleTaskIds, entryCount, distinctProjectCount, staleProjectCount } = list;
|
|
247
|
+
const staleCount = staleTaskIds.length;
|
|
248
|
+
|
|
249
|
+
if (staleCount === 0) {
|
|
250
|
+
return {
|
|
251
|
+
id, name, status: 'ok',
|
|
252
|
+
message: `0 stale rows (${entryCount} entries, ${distinctProjectCount} distinct project(s) checked)`,
|
|
253
|
+
hint: null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (!d.fix) {
|
|
257
|
+
return {
|
|
258
|
+
id, name, status: 'warn',
|
|
259
|
+
message: `${staleCount} stale row(s) of ${entryCount} — ${staleProjectCount} of ${distinctProjectCount} distinct project(s) gone — run with --fix`,
|
|
260
|
+
hint: HINTS.pruneSessionIndex,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Council R16 fix round (A2): capture WHY a write underperformed instead of
|
|
265
|
+
// reporting every cause as the same generic guess — a thrown exception
|
|
266
|
+
// (disk full, EACCES, a bug) is a different fact from some ids having
|
|
267
|
+
// already been removed by a racing prune, and swallowing the exception's
|
|
268
|
+
// own message was hiding that difference. Status stays 'warn' either way
|
|
269
|
+
// (never crashes doctor — see the throwing-prune test) but the message no
|
|
270
|
+
// longer lies about which one happened.
|
|
271
|
+
let pruned = 0;
|
|
272
|
+
let writeError = null;
|
|
273
|
+
try { pruned = d.pruneStaleSessionIndexEntries(staleTaskIds) || 0; }
|
|
274
|
+
catch (e) { writeError = (e && e.message) || 'unknown error'; }
|
|
275
|
+
|
|
276
|
+
if (pruned === staleCount && !writeError) {
|
|
277
|
+
return {
|
|
278
|
+
id, name, status: 'ok', message: `pruned ${pruned} stale row(s)`, hint: null,
|
|
279
|
+
fixed: true,
|
|
280
|
+
fixDetail: `pruned ${pruned} stale session-index row(s) (${staleProjectCount} deleted project(s))`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// Council R16 fix round 2 (A3): name the OBSERVATION (fewer removed than
|
|
284
|
+
// listed), not an inferred cause — the count can differ for reasons other
|
|
285
|
+
// than "the index changed" underneath us.
|
|
286
|
+
const remaining = staleCount - pruned;
|
|
287
|
+
const reason = writeError ? `write failed: ${writeError}` : 'fewer entries removed than expected';
|
|
288
|
+
return {
|
|
289
|
+
id, name, status: 'warn',
|
|
290
|
+
message: `pruned ${pruned}, ${remaining} remaining (${reason})`,
|
|
291
|
+
hint: HINTS.pruneSessionIndex,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
listStaleSessionIndexEntries, pruneStaleSessionIndexEntries, evaluateSessionIndexPrune,
|
|
297
|
+
};
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Structurally mirrors utils/session-index-tmp-sweep.js (B15's sibling).
|
|
14
14
|
*
|
|
15
15
|
* Enumeration decision (cwd-scoped, index rejected — recorded at plan time,
|
|
16
|
-
*
|
|
16
|
+
* v4.6.3 D8): this walks
|
|
17
17
|
* `<process.cwd()>/.claude/amicus_sessions/` — each taskId dir plus its
|
|
18
18
|
* `subagents/<id>/` children — rather than consulting sessions-index.json.
|
|
19
19
|
* `amicus doctor` is a per-project surface; the index is best-effort and can
|
|
@@ -14,20 +14,16 @@
|
|
|
14
14
|
|
|
15
15
|
const fsReal = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
|
-
const { sanitizeName } = require('../council/run-launch');
|
|
18
17
|
const { readPointer } = require('./run-scan');
|
|
19
18
|
const { isRealpathContained } = require('../utils/path-fence');
|
|
19
|
+
// v4.8 PR5a: name derivation moved to ./artifact-names (the 300-line gate). The two
|
|
20
|
+
// constants and artifactAllowlist are re-exported below unchanged, so every existing
|
|
21
|
+
// caller and test keeps importing them from here.
|
|
22
|
+
// `isSeatTable` joins them (fix-wave, council A1/B1): run-detail.js has to answer "is
|
|
23
|
+
// this run in seat space?" with THE predicate artifactAllowlist gates on, not a second
|
|
24
|
+
// spelling of it, and this module is already its import surface.
|
|
25
|
+
const { artifactAllowlist, isSeatTable, FIXED_ARTIFACTS, DEBATE_ARTIFACTS } = require('./artifact-names');
|
|
20
26
|
|
|
21
|
-
const FIXED_ARTIFACTS = Object.freeze(['briefing-stage1.md', 'bundle-stage2.md', 'chair-packet.md', 'chair-output.md', 'tally-input.json']);
|
|
22
|
-
// ⚠️ DE-ROT (F28): v4.1's debate stage writes five MORE run-dir artifact kinds the original
|
|
23
|
-
// allowlist never named, so the Workspace hard-refused every `--debate` output with
|
|
24
|
-
// `artifact not allowed: <name>`. Writers: tally-provisional.json = src/council/run.js:199;
|
|
25
|
-
// revote-bundle.md = run-debate.js:119; debate.json = run-debate.js:261; the per-seat
|
|
26
|
-
// rebuttal-/revote- pair = materializeDebate (run-launch.js:127-136).
|
|
27
|
-
// ⚠️ FIVE KINDS, THREE ENTRIES — that is not a miscount (v4.4.1 DOC-7, re-verified). This const
|
|
28
|
-
// holds only the three RUN-LEVEL names; the last two of the five, the rebuttal-/revote- pair, are
|
|
29
|
-
// per BENCH MODEL and are appended inside artifactAllowlist below, next to review-/judge-.
|
|
30
|
-
const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
|
|
31
27
|
const MAX_ARTIFACT_BYTES = 200 * 1024;
|
|
32
28
|
|
|
33
29
|
// isRealpathContained itself now lives in ../utils/path-fence.js (the shared "fence 2"
|
|
@@ -53,108 +49,6 @@ function truncateUtf8(buf, max) {
|
|
|
53
49
|
return buf.subarray(0, end);
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
/**
|
|
57
|
-
* @param {object} run parsed run.json (may be partial)
|
|
58
|
-
* @returns {string[]} the allowlist. When two or more DISTINCT bench entries sanitize to the
|
|
59
|
-
* same artifact name, a non-enumerable-in-spirit (but plain, test-visible) `collisions`
|
|
60
|
-
* array is attached: `[{sanitized, models: [rawA, rawB, ...]}, ...]`. See the R4
|
|
61
|
-
* council-review note below for why this is surfaced rather than silently deduped.
|
|
62
|
-
*/
|
|
63
|
-
function artifactAllowlist(run) {
|
|
64
|
-
const names = [...FIXED_ARTIFACTS];
|
|
65
|
-
const bench = run && Array.isArray(run.bench) ? run.bench : [];
|
|
66
|
-
// ⚠️ DE-ROT (F28): run.json carries a `debate` key ONLY on --debate runs, and it is seeded
|
|
67
|
-
// on the FIRST write (src/council/run.js:74-77), so this gate is safe and keeps the
|
|
68
|
-
// allowlist tight for the common case.
|
|
69
|
-
const debated = !!(run && run.debate);
|
|
70
|
-
if (debated) { names.push(...DEBATE_ARTIFACTS); }
|
|
71
|
-
|
|
72
|
-
// ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): sanitizeName is NOT
|
|
73
|
-
// injective — it maps every character outside [a-zA-Z0-9._-] to '-', so two DISTINCT bench
|
|
74
|
-
// entries ('vendor/a', 'vendor?a') both produce 'vendor-a'. Both models would then request
|
|
75
|
-
// the SAME artifact file, and the renderer's `[data-artifact="..."]` lookup (drillIntoJudge)
|
|
76
|
-
// hands back whichever section matches first — prose silently misattributed to the wrong
|
|
77
|
-
// model. That is a run-integrity defect (this run directory genuinely cannot hold both
|
|
78
|
-
// models' review/judge files under distinct names), not a display quirk, so it must be
|
|
79
|
-
// DETECTED and surfaced, never smoothed away by deduping the resulting name list.
|
|
80
|
-
//
|
|
81
|
-
// A bench with genuinely REPEATED identical entries (['gemini', 'gemini']) is a different,
|
|
82
|
-
// harmless case that must keep collapsing to one set of rows (preserved intent) — collapse
|
|
83
|
-
// those via a Set over the RAW bench values FIRST, so identical entries never even reach
|
|
84
|
-
// the collision check below (only entries that are distinct as raw strings but coincide
|
|
85
|
-
// after sanitizeName count as a collision).
|
|
86
|
-
const uniqueModels = [...new Set(bench)];
|
|
87
|
-
const rawBySanitized = new Map(); // sanitized name -> first raw model seen for it
|
|
88
|
-
const collisionModels = new Map(); // sanitized name -> Set(raw models) once >1 raw maps to it
|
|
89
|
-
for (const m of uniqueModels) {
|
|
90
|
-
const s = sanitizeName(m);
|
|
91
|
-
if (rawBySanitized.has(s)) {
|
|
92
|
-
if (!collisionModels.has(s)) { collisionModels.set(s, new Set([rawBySanitized.get(s)])); }
|
|
93
|
-
collisionModels.get(s).add(m);
|
|
94
|
-
} else {
|
|
95
|
-
rawBySanitized.set(s, m);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ⚠️ Task 18 (RN-1): the collision above is a real run-integrity defect — the run directory
|
|
100
|
-
// physically holds ONE file where two models' artifacts should be, and no renderer trick can
|
|
101
|
-
// recover both. What the renderer CAN stop doing is showing model A's prose under model B's
|
|
102
|
-
// name. Deterministic disambiguation: per colliding sanitized name, sort the RAW models
|
|
103
|
-
// (sorting, not insertion order, is what keeps this reproducible across processes/runs); the
|
|
104
|
-
// first (sorted) keeps the bare sanitized name, the rest get `~2`, `~3`, ... The suffixed
|
|
105
|
-
// names deliberately do not exist on disk — the presence manifest (run-detail.js, via
|
|
106
|
-
// fs.statSync over this same allowlist) marks them absent, so the renderer shows the honest
|
|
107
|
-
// "not written yet" empty state for every model but the first, instead of cross-matching.
|
|
108
|
-
const nameFor = new Map(); // raw model -> its (possibly suffixed) sanitized name
|
|
109
|
-
for (const m of uniqueModels) {
|
|
110
|
-
let s = sanitizeName(m);
|
|
111
|
-
const collision = collisionModels.get(s);
|
|
112
|
-
if (collision) {
|
|
113
|
-
const sortedRaw = [...collision].sort();
|
|
114
|
-
const index = sortedRaw.indexOf(m);
|
|
115
|
-
if (index > 0) { s = `${s}~${index + 1}`; }
|
|
116
|
-
}
|
|
117
|
-
nameFor.set(m, s);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
for (const m of uniqueModels) {
|
|
121
|
-
const s = nameFor.get(m);
|
|
122
|
-
names.push(`review-${s}.md`);
|
|
123
|
-
names.push(`judge-${s}.md`);
|
|
124
|
-
// rebuttal-/revote- are keyed on the same BENCH ALIAS through the same (now possibly
|
|
125
|
-
// suffixed) name — materializeDebate is called with `d.raiser` / the revote leg's model
|
|
126
|
-
// (both aliases), so a colliding pair's debate artifacts are disambiguated the same way.
|
|
127
|
-
if (debated) {
|
|
128
|
-
names.push(`rebuttal-${s}.md`);
|
|
129
|
-
names.push(`revote-${s}.md`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// `uniqueModels` already collapsed genuinely-repeated bench entries, so this final Set is
|
|
133
|
-
// now just a belt-and-suspenders no-op for names — it can no longer mask a real collision,
|
|
134
|
-
// since that path is detected above from the RAW (pre-sanitize) values instead.
|
|
135
|
-
const list = [...new Set(names)];
|
|
136
|
-
if (collisionModels.size) {
|
|
137
|
-
list.collisions = [...collisionModels.entries()].map(([sanitized, models]) => ({
|
|
138
|
-
sanitized, models: [...models],
|
|
139
|
-
}));
|
|
140
|
-
}
|
|
141
|
-
// Consumed by workspace-panels.js (wireLazyPanels' file lists + drillIntoJudge's artifact
|
|
142
|
-
// lookup), which prefers this map over re-deriving names via sanitizeName(model) directly —
|
|
143
|
-
// that re-derivation is exactly what would ignore the suffixing above and misattribute prose.
|
|
144
|
-
// ⚠️ Fix-wave (review finding 1) residual limit this map cannot close: the BARE (unsuffixed)
|
|
145
|
-
// name is still exactly ONE physical file on disk, and its actual bytes belong to whichever
|
|
146
|
-
// colliding model's writer ran LAST — no map can recover which one that was. The guarantee
|
|
147
|
-
// delivered here is narrower than "attribution is fully sound": at most the sorted-first
|
|
148
|
-
// model can still be misattributed under the bare name; artifactCollisions (the run-integrity
|
|
149
|
-
// banner rendered by workspace-app.js's renderBanners) is what covers that residual case.
|
|
150
|
-
list.artifactsByModel = Object.fromEntries(
|
|
151
|
-
[...nameFor].map(([m, s]) => [m, {
|
|
152
|
-
review: `review-${s}.md`, judge: `judge-${s}.md`,
|
|
153
|
-
rebuttal: `rebuttal-${s}.md`, revote: `revote-${s}.md`,
|
|
154
|
-
}]),
|
|
155
|
-
);
|
|
156
|
-
return list;
|
|
157
|
-
}
|
|
158
52
|
|
|
159
53
|
/**
|
|
160
54
|
* @param {string} project
|
|
@@ -241,6 +135,6 @@ function readRunArtifact(project, runId, name, deps = {}) {
|
|
|
241
135
|
}
|
|
242
136
|
|
|
243
137
|
module.exports = {
|
|
244
|
-
artifactAllowlist, readRunArtifact, isRealpathContained,
|
|
138
|
+
artifactAllowlist, isSeatTable, readRunArtifact, isRealpathContained,
|
|
245
139
|
FIXED_ARTIFACTS, DEBATE_ARTIFACTS, MAX_ARTIFACT_BYTES,
|
|
246
140
|
};
|