amicus 4.7.1 → 4.8.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 +882 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +2 -2
- 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/setup-ui-aliases.js +2 -2
- 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 +3 -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 +7 -0
- 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/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 +13 -2
- 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/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +13 -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 +27 -1
- package/src/workspace/seat-space.js +143 -0
package/src/utils/config.js
CHANGED
|
@@ -60,7 +60,16 @@ function saveConfig(configData) {
|
|
|
60
60
|
if (configData && configData.aliases) {
|
|
61
61
|
const cleaned = {};
|
|
62
62
|
for (const [key, value] of Object.entries(configData.aliases)) {
|
|
63
|
-
|
|
63
|
+
// `key === '__proto__'` — v4.8 SI-22.4 fix round 3 (council G-5). Without
|
|
64
|
+
// it the write below (`cleaned[key] = value`) hit Object.prototype's
|
|
65
|
+
// INHERITED `__proto__` setter, which ignores a string, so the alias
|
|
66
|
+
// vanished with NO "Removing invalid alias" notice — the only silent
|
|
67
|
+
// removal in this loop. Rejecting it explicitly puts it on the same
|
|
68
|
+
// footing as the `'null'` key beside it: same branch, same message.
|
|
69
|
+
// (No pollution was possible either way — only strings reach this line,
|
|
70
|
+
// and the setter ignores them — so this is an announcement fix, not a
|
|
71
|
+
// security one. Stated that way on purpose.)
|
|
72
|
+
if (key === 'null' || key === '__proto__' || !value || typeof value !== 'string' || value === 'null') {
|
|
64
73
|
process.stderr.write(
|
|
65
74
|
`Notice: Removing invalid alias '${key}' (value: ${JSON.stringify(value)}) from config.\n`
|
|
66
75
|
);
|
|
@@ -78,7 +87,14 @@ function saveConfig(configData) {
|
|
|
78
87
|
|
|
79
88
|
/** @returns {object} Copy of the default alias map */
|
|
80
89
|
function getDefaultAliases() {
|
|
81
|
-
|
|
90
|
+
// ⚠️ `__proto__: null` must be RESTATED here, not inherited. A spread into a
|
|
91
|
+
// bare `{}` literal produces a plain object again, so fixing the builders in
|
|
92
|
+
// curated-models.js does NOT reach this copy — measured, not assumed. Its
|
|
93
|
+
// consumers index it: `sidecar/setup.js:479` (`getDefaultAliases()[alias]`)
|
|
94
|
+
// and `electron/setup-ui-aliases.js :: buildAliasEditorHTML`
|
|
95
|
+
// (`aliases[key] !== undefined`).
|
|
96
|
+
// Named mutant "BUILDERPROTO" covers this line too.
|
|
97
|
+
return { __proto__: null, ...DEFAULT_ALIASES };
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/**
|
|
@@ -216,7 +232,44 @@ function checkConfigChanged(currentHash) {
|
|
|
216
232
|
function getEffectiveAliases() {
|
|
217
233
|
const config = loadConfig();
|
|
218
234
|
const userAliases = (config && config.aliases) || {};
|
|
219
|
-
|
|
235
|
+
// `__proto__: null` — v4.8 SI-22.4 fix round 2 (council B1). This table is
|
|
236
|
+
// read with BARE INDEXING by five gates, so on a normal object a member
|
|
237
|
+
// literally named 'toString' / 'constructor' / 'valueOf' / 'hasOwnProperty'
|
|
238
|
+
// resolved off Object.prototype to a truthy Function and was treated as a
|
|
239
|
+
// KNOWN ALIAS. Measured, not argued:
|
|
240
|
+
// resolveModel('toString') -> the Function itself,
|
|
241
|
+
// typeof 'function', where every caller expects a model-id STRING
|
|
242
|
+
// (`:111` and `:142` gate on `!== undefined`, which a Function passes)
|
|
243
|
+
// classifyCouncilMembers(['toString '], []) -> ACCEPTED, i.e. runnable
|
|
244
|
+
// The SAME defect class this release already closed at other lookup tables —
|
|
245
|
+
// `tally.js :: VERDICTS`, `report.js :: SYMBOL`, `debate.js :: PAST_TENSE`
|
|
246
|
+
// (all `__proto__: null`), plus `street-cred.js :: perJudgeRank` and
|
|
247
|
+
// `report.js :: ROLE_SUFFIX` (both `Object.create(null)`, the same guarantee
|
|
248
|
+
// in the other spelling). The ALIAS table was not among them.
|
|
249
|
+
// ⚠️ SI-22.4 WIDENED it and that is why it is fixed here: at BASE the padded
|
|
250
|
+
// spelling ('toString ') missed the prototype and was correctly dropped;
|
|
251
|
+
// trimming before the lookup landed it on the inherited property. The
|
|
252
|
+
// unpadded spelling was already accepted, so restoring only the padded case
|
|
253
|
+
// would take more code AND deliberately preserve a known hole.
|
|
254
|
+
// ⚠️ Fixed HERE, not at the call sites — one line closes all five, each
|
|
255
|
+
// MEASURED at its own gate expression with `'toString'` (not inferred from
|
|
256
|
+
// this one): `resolveModel` `:111`/`:142` (`!== undefined` true→false) ·
|
|
257
|
+
// `classifyCouncilMembers` (accepted→dropped, end to end) ·
|
|
258
|
+
// `council/presets-cli.js:41` (`amicus council save`: unresolved false→true) ·
|
|
259
|
+
// `pack/pack-validate.js:71` (`seatOk` true→false) ·
|
|
260
|
+
// `utils/route-launch.js:205` (`isAlias` true→false).
|
|
261
|
+
// No consumer breaks: every reference either indexes (`aliases[key]`) or
|
|
262
|
+
// iterates own-enumerable keys (`Object.entries`/`Object.keys` —
|
|
263
|
+
// `buildProviderModels`, `formatAliasNames`, `mcp-tools.js :: getGuideText`,
|
|
264
|
+
// `sidecar/models.js :: aliasMarks`), and both behave identically on a
|
|
265
|
+
// null-prototype object. NOTHING calls a method ON the object — swept
|
|
266
|
+
// uncapped over `src/` and `electron/` for `aliases.<x>`, `in aliases`,
|
|
267
|
+
// `Object.values`, `JSON.stringify`, spread and `for…in`.
|
|
268
|
+
// A `__proto__` key inside the user's own config.json is copied as an ORDINARY
|
|
269
|
+
// own property by spread (never the setter), so the prototype stays null —
|
|
270
|
+
// measured on both a literal and a `JSON.parse`d source.
|
|
271
|
+
// Named mutant "PROTOALIASES": drop `__proto__: null` from the literal below.
|
|
272
|
+
return { __proto__: null, ...DEFAULT_ALIASES, ...userAliases };
|
|
220
273
|
}
|
|
221
274
|
|
|
222
275
|
/**
|
|
@@ -414,11 +467,42 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
414
467
|
* at the last refresh; the leg itself fails pre-flight with the actionable
|
|
415
468
|
* local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
|
|
416
469
|
* catalog that omits the resolved id is a definitive drop.
|
|
417
|
-
*
|
|
470
|
+
*
|
|
471
|
+
* WHITESPACE (v4.8 SI-22.4). Each member is TRIMMED before it is classified,
|
|
472
|
+
* closing a divergence: `--models` already trimmed
|
|
473
|
+
* (`sidecar/fanout-validate.js :: parseModelsList`, and `cli-council-run-bench.js
|
|
474
|
+
* :: parseList` on the council surface) while `--council` did not, so the same
|
|
475
|
+
* stray space was benign on one flag and, here, converted a typo into a dropped
|
|
476
|
+
* member and a degraded (2) exit. ⚠️ The dominant effect is RESURRECTION, not
|
|
477
|
+
* de-duplication: a padded member that is dropped today starts RUNNING, which
|
|
478
|
+
* is a new paid leg. Where the trim makes two members collide, the bench
|
|
479
|
+
* becomes a real twin and `seats.js :: buildSeats` mints `alias#N` for both.
|
|
480
|
+
* An all-whitespace member trims to `''`, which no alias table names, so gate 1
|
|
481
|
+
* below drops it — the `.filter(Boolean)` half of `parseModelsList`'s shape,
|
|
482
|
+
* reached without a third `reason` string (see the tripwire note below).
|
|
483
|
+
* @param {string[]} members council members as configured — aliases or
|
|
484
|
+
* provider/model ids, trimmed per member here. ⚠️ This is the only place the
|
|
485
|
+
* preset READ path trims — NOT the only place the preset path trims at all,
|
|
486
|
+
* and NOT the only trim a member meets. The WRITE side already trimmed:
|
|
487
|
+
* `council/presets-cli.js:34` (`amicus council save`) stores
|
|
488
|
+
* `.split(',').map(m => m.trim()).filter(Boolean)`, and the only other writer
|
|
489
|
+
* of `cfg.councils` in `src/` (`sidecar/setup.js:593`, the seeded `free`
|
|
490
|
+
* council) composes its members from generated/existing alias KEYS, which
|
|
491
|
+
* cannot carry user padding. So a padded member in `cfg.councils` comes from
|
|
492
|
+
* a hand-edited `config.json`, and that is the case this trim serves.
|
|
493
|
+
* Downstream, both council
|
|
494
|
+
* surfaces re-join the expanded bench and re-parse it downstream —
|
|
495
|
+
* `cli-handlers-fanout.js:91` → `:119` → `sidecar/fanout.js ::
|
|
496
|
+
* validateFanoutModels` → `parseModelsList`, and `mcp-council-run.js:177` →
|
|
497
|
+
* the spawned child's `cli-council-run-bench.js :: parseList` — so a member
|
|
498
|
+
* that somehow kept padding past this point would still be trimmed there
|
|
418
499
|
* @param {Array<{id:string}>} [catalog]
|
|
419
500
|
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
|
|
420
501
|
* `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
|
|
421
502
|
* callers keep working); `droppedMembers` additively pairs each with WHY.
|
|
503
|
+
* ⚠️ Both report the member RAW — untrimmed, byte-for-byte as configured
|
|
504
|
+
* (v4.8 SI-22.4, R22.4-2) — so a user can find the offending string in their
|
|
505
|
+
* own config. Only `models` carries the trimmed value.
|
|
422
506
|
*
|
|
423
507
|
* Standing note (D18, v4.7 PR5): each `droppedMembers` entry is `{member, reason}`
|
|
424
508
|
* (that is the real key — BACKLOG.md's description of this shape had drifted to
|
|
@@ -442,18 +526,29 @@ function classifyCouncilMembers(members, catalog = []) {
|
|
|
442
526
|
const models = [];
|
|
443
527
|
const dropped = [];
|
|
444
528
|
const droppedMembers = [];
|
|
445
|
-
for (const
|
|
529
|
+
for (const raw of members) {
|
|
530
|
+
// v4.8 SI-22.4. Trim BEFORE gate 1 below, never after: a padded ALIAS
|
|
531
|
+
// ('gpt ') must reach the alias table as written in the table, and a padded
|
|
532
|
+
// full id ('openai/gpt-5 ') must reach the catalog lookup clean. Trimming
|
|
533
|
+
// downstream of either gate would leave both misses in place. Non-strings
|
|
534
|
+
// pass through untouched so their `.includes` still throws exactly as it
|
|
535
|
+
// did before this line existed. Named mutant "NOTRIM": drop the `.trim()`.
|
|
536
|
+
const member = typeof raw === 'string' ? raw.trim() : raw;
|
|
446
537
|
const id = member.includes('/') ? member : aliases[member];
|
|
538
|
+
// R22.4-2: `models` gets the TRIMMED value, `dropped`/`droppedMembers` get
|
|
539
|
+
// `raw` — a member still dropped after trimming is reported as the user
|
|
540
|
+
// wrote it, or they cannot grep their own config for it. Named mutant
|
|
541
|
+
// "TRIMDROPPED": report `member` instead of `raw` in the two drop branches.
|
|
447
542
|
if (!id) { // alias no longer resolves
|
|
448
|
-
dropped.push(
|
|
449
|
-
droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
|
|
543
|
+
dropped.push(raw);
|
|
544
|
+
droppedMembers.push({ member: raw, reason: 'alias no longer resolves to a known model' });
|
|
450
545
|
continue;
|
|
451
546
|
}
|
|
452
547
|
const vendor = typeof id === 'string' ? id.split('/')[0] : '';
|
|
453
548
|
if (isLocalProvider(vendor)) { models.push(member); continue; }
|
|
454
549
|
if (known.size > 0 && !known.has(id)) { // delisted model
|
|
455
|
-
dropped.push(
|
|
456
|
-
droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
|
|
550
|
+
dropped.push(raw);
|
|
551
|
+
droppedMembers.push({ member: raw, reason: 'resolved id is not present in the cached model catalog' });
|
|
457
552
|
continue;
|
|
458
553
|
}
|
|
459
554
|
models.push(member);
|
|
@@ -465,8 +560,11 @@ function classifyCouncilMembers(members, catalog = []) {
|
|
|
465
560
|
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
466
561
|
* Unresolvable aliases and delisted ids are dropped with a warning rather than
|
|
467
562
|
* fail-fast-aborting the whole wave (classification: classifyCouncilMembers
|
|
468
|
-
* above). Returns members
|
|
469
|
-
* them again.
|
|
563
|
+
* above). Returns members UNRESOLVED (the alias or id as configured, never the
|
|
564
|
+
* id an alias maps to) — leg-time validation resolves them again. ⚠️ Not
|
|
565
|
+
* byte-identical to the configured string since v4.8 SI-22.4: classification
|
|
566
|
+
* trims each member, so `models[i]` is the configured member minus any
|
|
567
|
+
* surrounding whitespace. `dropped`/`droppedMembers` still carry it raw.
|
|
470
568
|
*
|
|
471
569
|
* Resolution order: user config (`config.councils`) is checked first; when
|
|
472
570
|
* `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
|
|
@@ -111,6 +111,13 @@ const CARDLESS = [
|
|
|
111
111
|
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
|
112
112
|
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
|
|
113
113
|
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
|
|
114
|
+
// inkling added 2026-08-14: the council-review workflow's default bench
|
|
115
|
+
// names it, and a workflow can only use aliases this table ships — a CI
|
|
116
|
+
// runner has no user config, so a locally-defined alias resolves to
|
|
117
|
+
// nothing there. Pinned to the full model, not `inkling-small`: the bench
|
|
118
|
+
// seat wants the flagship's judgment. `:batch` is deliberately not pinned
|
|
119
|
+
// (deferred completion is wrong for an interactive council leg).
|
|
120
|
+
{ alias: 'inkling', routes: { openrouter: 'openrouter/thinkingmachines/inkling' } },
|
|
114
121
|
];
|
|
115
122
|
|
|
116
123
|
/**
|
|
@@ -242,7 +249,14 @@ function directFormProvenance() {
|
|
|
242
249
|
* without corrupting divergent-vendor ids (e.g. Anthropic's dash format).
|
|
243
250
|
*/
|
|
244
251
|
function toGatewayRoutes() {
|
|
245
|
-
|
|
252
|
+
// `__proto__: null` — v4.8 SI-22.4 round 3 (G-1). Read by BARE INDEXING
|
|
253
|
+
// downstream, so a plain `{}` let an alias named 'toString'/'constructor'/
|
|
254
|
+
// 'valueOf'/'hasOwnProperty' resolve off Object.prototype to a truthy
|
|
255
|
+
// Function — including on the auto-repair path (`alias-resolver.js ::
|
|
256
|
+
// autoRepairAlias`), which `getEffectiveAliases`'s own fix could never reach.
|
|
257
|
+
// Full measurement + why THREE seeds were needed:
|
|
258
|
+
// tests/council/preset-trim-mutants.js :: BUILDERPROTO (the named mutant).
|
|
259
|
+
const out = { __proto__: null };
|
|
246
260
|
for (const f of FAMILIES) { out[f.alias] = gatewayRoutesFor(f.vendorPath, f.fallback); }
|
|
247
261
|
for (const e of CARDLESS) { out[e.alias] = gatewayRoutesFor(vendorOf(e.routes.openrouter), e.routes); }
|
|
248
262
|
return out;
|
|
@@ -262,7 +276,8 @@ function toGatewayRoutes() {
|
|
|
262
276
|
* shipped defaults.
|
|
263
277
|
*/
|
|
264
278
|
function toDefaultAliases() {
|
|
265
|
-
|
|
279
|
+
// `__proto__: null` — see toGatewayRoutes above. Becomes DEFAULT_ALIASES.
|
|
280
|
+
const out = { __proto__: null };
|
|
266
281
|
for (const [alias, routes] of Object.entries(toGatewayRoutes())) {
|
|
267
282
|
out[alias] = routes.direct || routes.openrouter;
|
|
268
283
|
}
|
package/src/utils/degrade.js
CHANGED
|
@@ -17,6 +17,11 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
17
17
|
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
18
|
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
19
|
'stage1-retry',
|
|
20
|
+
// v4.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
|
|
21
|
+
// returned legs but none its own; a returned leg matching no roster slot; and (T5.5, `-rv` only)
|
|
22
|
+
// a leg that DID match a slot but whose join key names no judge the wave launched.
|
|
23
|
+
// Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
|
|
24
|
+
'seat-unbound',
|
|
20
25
|
'internal',
|
|
21
26
|
// doctor channels
|
|
22
27
|
'doctor-check-failed', 'doctor-fix',
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
const { envNumber } = require('./env-num');
|
|
22
22
|
|
|
23
|
-
const DEFAULT_NO_OUTPUT_BACKSTOP_MS =
|
|
23
|
+
const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 300000;
|
|
24
24
|
|
|
25
25
|
/** @param {object} [env] test seam; defaults to process.env */
|
|
26
26
|
function resolveNoOutputBackstopMs(env) {
|
|
@@ -130,6 +130,19 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
130
130
|
*/
|
|
131
131
|
sweepSessionMetadataTmp:
|
|
132
132
|
'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stale sessions-index.json entries (R16): session-index.js :: recordSession
|
|
136
|
+
* never removes a row, so a project that is deleted, renamed or moved
|
|
137
|
+
* leaves its taskId -> path entries behind forever, and every
|
|
138
|
+
* recordSession call pays for rewriting the WHOLE index — the per-start
|
|
139
|
+
* cost grows with total sessions ever, not live ones. `doctor --fix`
|
|
140
|
+
* removes only entries whose project path no longer exists on disk
|
|
141
|
+
* (liveness, never age — R16-2): a five-year-old entry for a project that
|
|
142
|
+
* still exists is left alone.
|
|
143
|
+
*/
|
|
144
|
+
pruneSessionIndex:
|
|
145
|
+
'amicus doctor --fix (removes sessions-index.json entries whose project no longer exists on disk — liveness-based, never by age)',
|
|
133
146
|
});
|
|
134
147
|
|
|
135
148
|
module.exports = REMEDIATION_HINTS;
|
|
@@ -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
|