amicus 4.6.1 → 4.6.3

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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +152 -0
  3. package/README.md +7 -8
  4. package/docs/ROADMAP.md +38 -4
  5. package/docs/configuration.md +18 -12
  6. package/docs/council.md +7 -2
  7. package/docs/troubleshooting.md +49 -20
  8. package/docs/usage.md +30 -3
  9. package/electron/setup-ui-aliases.js +2 -2
  10. package/electron/workspace-ui/index.html +3 -0
  11. package/electron/workspace-ui/live-model.js +146 -2
  12. package/electron/workspace-ui/workspace-app.js +8 -3
  13. package/electron/workspace-ui/workspace-panels.js +9 -10
  14. package/electron/workspace-ui/workspace-render.js +9 -3
  15. package/electron/workspace-ui/workspace-seats.js +132 -0
  16. package/electron/workspace-ui/workspace-verbs.js +2 -1
  17. package/electron/workspace-ui/workspace.css +6 -0
  18. package/package.json +1 -1
  19. package/schemas/alias-audit.schema.json +6 -1
  20. package/schemas/council-run.schema.json +14 -0
  21. package/src/cli-handlers-council.js +9 -0
  22. package/src/cli-handlers-doctor.js +25 -7
  23. package/src/cli.js +4 -0
  24. package/src/council/presets-cli.js +6 -2
  25. package/src/council/run-chair.js +55 -6
  26. package/src/headless.js +119 -9
  27. package/src/mcp-council-awareness.js +1 -0
  28. package/src/opencode-client.js +21 -0
  29. package/src/session-manager.js +6 -2
  30. package/src/sidecar/fanout-leg.js +2 -2
  31. package/src/sidecar/fanout.js +1 -1
  32. package/src/sidecar/models-probe.js +119 -0
  33. package/src/sidecar/models.js +81 -6
  34. package/src/utils/alias-audit.js +71 -1
  35. package/src/utils/base-url-classify.js +74 -0
  36. package/src/utils/council-presets.js +6 -2
  37. package/src/utils/curated-models.js +71 -16
  38. package/src/utils/doctor-base-url-check.js +41 -0
  39. package/src/utils/gateway-route-audit.js +16 -3
  40. package/src/utils/model-fetcher.js +9 -6
  41. package/src/utils/model-tiers.js +28 -7
  42. package/src/utils/no-output-backstop.js +48 -0
  43. package/src/utils/remediation-hints.js +14 -0
  44. package/src/utils/result-schema.js +29 -2
  45. package/src/utils/session-metadata-tmp-sweep.js +136 -0
  46. package/src/workspace/live-normalize.js +1 -0
@@ -28,7 +28,13 @@ const { pickCurrent } = require('./quick-picks');
28
28
  const { isDirectProvider } = require('./provider-registry');
29
29
  const { toDefaultAliases, listCuratedRoutes } = require('./curated-models');
30
30
 
31
- /** Tier regex table: pattern matches the model segment after `<vendor>/` (or `openrouter/<vendor>/`). */
31
+ /**
32
+ * Tier pattern table: each tier holds a regex — or an ORDERED regex list,
33
+ * tried first-match-wins — over the model segment after `<vendor>/` (or
34
+ * `openrouter/<vendor>/`). List order expresses preference, which a single
35
+ * regex cannot: ids sort numeric-descending, so `-pro` siblings would
36
+ * otherwise outrank their same-priced base.
37
+ */
32
38
  const TIERS = {
33
39
  anthropic: {
34
40
  economy: /^claude-haiku-/,
@@ -36,9 +42,17 @@ const TIERS = {
36
42
  frontier: /^claude-opus-/,
37
43
  },
38
44
  openai: {
39
- economy: /^gpt-[\d.]+-mini$/,
40
- balanced: /^gpt-[\d.]+$/,
41
- frontier: /^gpt-[\d.]+-pro$/,
45
+ // The 5.6 line renamed the flagship into tiers — luna ($0.10/$0.60 per
46
+ // Mtok in/out), terra ($1/$6), sol ($5/$30), each with a -pro sibling
47
+ // priced at its base tier; no bare/-mini/-pro 5.6 ids exist. Owner
48
+ // ruling 2026-08-04: economy→luna, balanced→terra, frontier→sol, each
49
+ // preferring the base name, then its -pro sibling, then the 5.5-era
50
+ // naming so a stale catalog still resolves. balanced's primary is the
51
+ // same bare-or-terra pattern the `gpt` family uses (curated-models.js),
52
+ // so a future return to bare flagship ids is tracked automatically.
53
+ economy: [/^gpt-[\d.]+-luna$/, /^gpt-[\d.]+-luna-pro$/, /^gpt-[\d.]+-mini$/],
54
+ balanced: [/^gpt-[\d.]+(-terra)?$/, /^gpt-[\d.]+-terra-pro$/],
55
+ frontier: [/^gpt-[\d.]+-sol$/, /^gpt-[\d.]+-sol-pro$/, /^gpt-[\d.]+-pro$/],
42
56
  },
43
57
  google: {
44
58
  economy: /^gemini-[\d.]+-flash-lite/,
@@ -77,9 +91,16 @@ function buildGatewayOnlyAliasMap() {
77
91
 
78
92
  const GATEWAY_ONLY_ALIAS = buildGatewayOnlyAliasMap();
79
93
 
80
- /** Newest catalog id matching `regex` under `vendor`; direct namespace preferred over OpenRouter's. */
81
- function pickForTier(catalog, vendor, regex) {
82
- return pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
94
+ /**
95
+ * First match wins over a tier's ordered patterns; within each pattern the
96
+ * newest matching id is picked, direct namespace preferred over OpenRouter's.
97
+ */
98
+ function pickForTier(catalog, vendor, patterns) {
99
+ for (const regex of [].concat(patterns)) {
100
+ const pick = pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
101
+ if (pick) { return pick; }
102
+ }
103
+ return null;
83
104
  }
84
105
 
85
106
  /** True when the catalog has ANY row (any tier) under this vendor's namespace, in either gateway. */
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @module utils/no-output-backstop
3
+ * v4.6.2 PR2 (spec §5, D4): fail a headless leg fast when the model produces
4
+ * ZERO output, reasoning, and tool calls — the "accepted but not serving"
5
+ * class (the v4.6.1 gemini release-gate incident: requests accepted, zero
6
+ * tokens, three suites burned 130s timeouts each to learn nothing).
7
+ *
8
+ * Pure state machine, loop-driven (no timers of its own — the poll loop
9
+ * ticks it): armed at leg start, DISARMED PERMANENTLY by the first
10
+ * `progressed` tick (a 30-90s cold-prefill local model is never affected),
11
+ * fired when the deadline passes with nothing ever observed. `fired` is
12
+ * terminal. `ms <= 0` never arms — 0 is the documented escape hatch, which
13
+ * is why the env resolver uses envNumber (explicit 0 honored) rather than
14
+ * the `Number(env) || default` idiom.
15
+ *
16
+ * PR3's live probe reuses this with a 30s override — ms is an input; only
17
+ * the exported resolver reads the environment.
18
+ */
19
+ 'use strict';
20
+
21
+ const { envNumber } = require('./env-num');
22
+
23
+ const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 120000;
24
+
25
+ /** @param {object} [env] test seam; defaults to process.env */
26
+ function resolveNoOutputBackstopMs(env) {
27
+ return envNumber('AMICUS_NO_OUTPUT_BACKSTOP_MS', DEFAULT_NO_OUTPUT_BACKSTOP_MS, env);
28
+ }
29
+
30
+ /**
31
+ * @param {{ms:number, startedAt:number}} opts
32
+ * @returns {{tick:(progressed:boolean, nowMs:number)=>string, state:()=>string}}
33
+ */
34
+ function createNoOutputBackstop({ ms, startedAt }) {
35
+ let state = ms > 0 ? 'armed' : 'disarmed';
36
+ const deadline = startedAt + ms;
37
+ return {
38
+ tick(progressed, nowMs) {
39
+ if (state !== 'armed') { return state; }
40
+ if (progressed) { state = 'disarmed'; return state; }
41
+ if (nowMs >= deadline) { state = 'fired'; }
42
+ return state;
43
+ },
44
+ state() { return state; },
45
+ };
46
+ }
47
+
48
+ module.exports = { resolveNoOutputBackstopMs, createNoOutputBackstop, DEFAULT_NO_OUTPUT_BACKSTOP_MS };
@@ -87,6 +87,20 @@ const REMEDIATION_HINTS = Object.freeze({
87
87
  */
88
88
  sweepSessionIndexTmp:
89
89
  'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
90
+
91
+ /**
92
+ * Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
93
+ * same producer shape as sweepSessionIndexTmp above, one level down — a
94
+ * kill between an atomic write's tmp-write and rename leaves a stray temp
95
+ * file in a session directory forever. `doctor --fix` sweeps files older
96
+ * than 60s (never a live writer's ms-lived tmp).
97
+ *
98
+ * Voice ruling (Christian, 2026-08-03, sweepSessionIndexTmp above): applies
99
+ * verbatim here — the cause is definitional, not a guess, so this hint also
100
+ * keeps its confident voice rather than the unverified-cause voice.
101
+ */
102
+ sweepSessionMetadataTmp:
103
+ 'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
90
104
  });
91
105
 
92
106
  module.exports = REMEDIATION_HINTS;
@@ -192,10 +192,32 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
192
192
  * of curated DEFAULT aliases (toGatewayRoutes() vs. the live catalog),
193
193
  * distinct from the flat `stale` audit above. Defaults to [] so existing
194
194
  * callers that omit it are unaffected.
195
+ * `drifted` (v4.6.2 PR1, 2A) is additive: stored user-config aliases whose
196
+ * target is still catalog-live but behind the current quick-pick family
197
+ * resolution (findDriftedStoredAliases). Defaults to [] so existing callers
198
+ * that omit it are unaffected. Paired with an additive `driftedCount`
199
+ * (drifted.length), mirroring the staleCount/stale and
200
+ * gatewayFindingsCount/gatewayFindings pairs above.
201
+ * `probe` (v4.6.2 PR3, spec §6 D5) is additive: the `--live` opt-in per-alias
202
+ * probe outcomes (probeStoredAliases — served/accepted-but-silent/error), one
203
+ * row per stored alias actually probed. Defaults to [] so every call without
204
+ * --live (i.e. every existing caller) is unaffected. Paired with an additive
205
+ * `probeCount` (probe.length), mirroring the driftedCount/drifted pair above.
206
+ * `probeSkipped` (v4.6.2 PR3 Task 4) is additive: null when the probe ran or
207
+ * `--live` wasn't requested; otherwise a stable reason slug (e.g.
208
+ * 'catalog-unavailable') for the one case this function can see — the
209
+ * `--refresh --check --live` skip (runRefresh returns before this is ever
210
+ * called) is announced by the caller instead, since that path emits a
211
+ * model-catalog doc, not this one.
195
212
  * @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean,
196
- * gatewayFindings?: Array<{alias,gateway,kind,model,expected?}>}} opts
213
+ * gatewayFindings?: Array<{alias,gateway,kind,model,expected?}>,
214
+ * drifted?: Array<{alias,stored,current}>,
215
+ * probe?: Array<{alias,target,outcome,detail,cost}>,
216
+ * probeSkipped?: string|null}} opts
197
217
  */
198
- function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
218
+ function buildAuditDoc({
219
+ stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null
220
+ }) {
199
221
  return {
200
222
  schemaVersion: SCHEMA_VERSION,
201
223
  type: 'alias-audit',
@@ -204,6 +226,11 @@ function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
204
226
  stale,
205
227
  gatewayFindingsCount: gatewayFindings.length,
206
228
  gatewayFindings,
229
+ driftedCount: drifted.length,
230
+ drifted,
231
+ probeCount: probe.length,
232
+ probe,
233
+ probeSkipped,
207
234
  };
208
235
  }
209
236
 
@@ -0,0 +1,136 @@
1
+ // src/utils/session-metadata-tmp-sweep.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * v4.6.3 PR3 Task 3 (D8): orphaned per-session metadata.json.*.tmp sweep for
6
+ * `amicus doctor --fix`.
7
+ *
8
+ * A kill between writeFileAtomic's tmp-write and rename (any of the ~30
9
+ * metadata.json write sites — session-manager.js, sidecar/session-finalize.js,
10
+ * etc.) leaves a stray `.metadata.json.<pid>.<hex>.tmp` file in a session
11
+ * directory forever (the B09 orphan class). This module lists and removes
12
+ * them; src/cli-handlers-doctor.js composes the result into a check line.
13
+ * Structurally mirrors utils/session-index-tmp-sweep.js (B15's sibling).
14
+ *
15
+ * Enumeration decision (cwd-scoped, index rejected — recorded at plan time,
16
+ * docs/superpowers/plans/2026-08-05-v463-pr3-cli-doctor-odds.md): this walks
17
+ * `<process.cwd()>/.claude/amicus_sessions/` — each taskId dir plus its
18
+ * `subagents/<id>/` children — rather than consulting sessions-index.json.
19
+ * `amicus doctor` is a per-project surface; the index is best-effort and can
20
+ * point at OTHER projects (issue #40's cross-project fallback exists exactly
21
+ * because the index can be stale). Trusting it here to decide which
22
+ * directories a --fix sweep may unlink from risks touching an unrelated
23
+ * project on stale data — the wrong failure direction for a destructive
24
+ * operation. Walking the cwd-scoped tree directly can only ever find/remove
25
+ * files under the project doctor is already running against.
26
+ *
27
+ * Symlink safety (Task 3 review carry, v4.6.3 PR3): every stat in this walk
28
+ * is lstatSync, never statSync — this module never follows symlinks. A
29
+ * symlinked taskId or subagents directory could otherwise be traversed and
30
+ * have files unlinked through the link, effectively outside the sessions
31
+ * root; lstat closes that off at zero cost.
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const HINTS = require('./remediation-hints');
37
+
38
+ /** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
39
+ const AGE_THRESHOLD_MS = 60 * 1000;
40
+
41
+ /** The cwd-scoped sessions root: <cwd>/.claude/amicus_sessions. */
42
+ function sessionsRoot() {
43
+ const { SESSIONS_DIR } = require('../session-manager');
44
+ return path.join(process.cwd(), '.claude', SESSIONS_DIR);
45
+ }
46
+
47
+ /** True when `basename` is an orphaned metadata tmp file (not e.g. progress.json.*.tmp). */
48
+ function isMetadataTmp(basename) {
49
+ return basename.startsWith('.metadata.json.') && basename.endsWith('.tmp');
50
+ }
51
+
52
+ /** List metadata tmp files directly inside `dir`, named relative to `root`. */
53
+ function listTmpIn(dir, root) {
54
+ let entries;
55
+ try { entries = fs.readdirSync(dir); } catch { return []; }
56
+ return entries
57
+ .filter(isMetadataTmp)
58
+ .map((basename) => {
59
+ let mtimeMs = null;
60
+ try { mtimeMs = fs.lstatSync(path.join(dir, basename)).mtimeMs; } catch { /* raced away — skip below */ }
61
+ return { name: path.relative(root, path.join(dir, basename)), mtimeMs };
62
+ })
63
+ .filter((f) => f.mtimeMs !== null);
64
+ }
65
+
66
+ /**
67
+ * List orphaned per-session metadata.json.*.tmp files under the cwd-scoped
68
+ * sessions root, covering both `<taskId>/` and `<taskId>/subagents/<id>/`.
69
+ * @returns {Array<{name: string, mtimeMs: number}>}
70
+ */
71
+ function listSessionMetadataTmpFiles() {
72
+ const { SUBAGENTS_DIR } = require('../session-manager');
73
+ const root = sessionsRoot();
74
+ let taskIds;
75
+ try { taskIds = fs.readdirSync(root); } catch { return []; }
76
+ const found = [];
77
+ for (const taskId of taskIds) {
78
+ const taskDir = path.join(root, taskId);
79
+ let stat;
80
+ try { stat = fs.lstatSync(taskDir); } catch { continue; }
81
+ if (!stat.isDirectory()) { continue; }
82
+ found.push(...listTmpIn(taskDir, root));
83
+
84
+ const subagentsDir = path.join(taskDir, SUBAGENTS_DIR);
85
+ let subIds;
86
+ try { subIds = fs.readdirSync(subagentsDir); } catch { continue; }
87
+ for (const subId of subIds) {
88
+ const subDir = path.join(subagentsDir, subId);
89
+ let subStat;
90
+ try { subStat = fs.lstatSync(subDir); } catch { continue; }
91
+ if (!subStat.isDirectory()) { continue; }
92
+ found.push(...listTmpIn(subDir, root));
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ /** Delete one orphaned tmp file by name (relative to the sessions root; never an absolute/caller path). */
99
+ function unlinkSessionMetadataTmp(name) {
100
+ const root = sessionsRoot();
101
+ fs.unlinkSync(path.join(root, name));
102
+ }
103
+
104
+ /**
105
+ * Compose the doctor check line for the metadata tmp-orphan sweep. Pure
106
+ * decision logic (list/sweep side effects come in via `d`); src/cli-handlers-doctor.js
107
+ * wraps this in guard() the same way it wires the sibling sessions-index-tmp check.
108
+ * @param {{listSessionMetadataTmpFiles: () => Array<{name:string, mtimeMs:number}>,
109
+ * fix?: boolean, now: () => number, unlinkSessionMetadataTmp: (name: string) => void}} d
110
+ */
111
+ function evaluateSessionMetadataTmpSweep(d) {
112
+ const id = 'session-metadata-tmp'; const name = 'Session metadata tmp files';
113
+ const files = d.listSessionMetadataTmpFiles() || [];
114
+ if (files.length === 0) {
115
+ return { id, name, status: 'ok', message: '0 orphaned tmp files', hint: null };
116
+ }
117
+ if (!d.fix) {
118
+ return { id, name, status: 'warn', message: `${files.length} orphaned tmp file(s) — run with --fix`, hint: HINTS.sweepSessionMetadataTmp };
119
+ }
120
+ const nowMs = d.now();
121
+ const sweepable = files.filter((f) => (nowMs - f.mtimeMs) > AGE_THRESHOLD_MS);
122
+ let swept = 0;
123
+ for (const f of sweepable) {
124
+ try { d.unlinkSessionMetadataTmp(f.name); swept += 1; } catch { /* best-effort — report what we got */ }
125
+ }
126
+ const remaining = files.length - swept;
127
+ if (remaining === 0) {
128
+ const fixFields = swept > 0 ? { fixed: true, fixDetail: `swept ${swept} orphaned session-metadata tmp file(s)` } : {};
129
+ return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null, ...fixFields };
130
+ }
131
+ return { id, name, status: 'warn', message: `swept ${swept}, ${remaining} remaining (too fresh or unremovable)`, hint: HINTS.sweepSessionMetadataTmp };
132
+ }
133
+
134
+ module.exports = {
135
+ AGE_THRESHOLD_MS, listSessionMetadataTmpFiles, unlinkSessionMetadataTmp, evaluateSessionMetadataTmpSweep,
136
+ };
@@ -126,6 +126,7 @@ function normalizeLive(doc) {
126
126
  stageName: active ? active.name : null,
127
127
  stages,
128
128
  seats: legRowsOf(doc).map(seatOf),
129
+ degrades: Array.isArray(doc.degrades) ? doc.degrades : [],
129
130
  // ⚠️ v4.4.1 RN-8 (D1 ruling: delete the promise). `legsTotal`/`legsComplete` used to be
130
131
  // mapped here under a comment promising them as "the honest fallback readout if a seat row
131
132
  // is ever unavailable" — a UI fallback nobody ever wired. Nothing in electron/workspace-ui/