amicus 4.8.1 → 4.9.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.
Files changed (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +316 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +41 -18
  10. package/electron/main.js +19 -5
  11. package/electron/offer-session.js +51 -0
  12. package/electron/setup-ui.js +46 -39
  13. package/electron/workspace-ui/live-dead-seats.js +163 -91
  14. package/electron/workspace-ui/workspace-banners.js +30 -7
  15. package/electron/workspace-ui/workspace-matrix.js +23 -3
  16. package/electron/workspace-ui/workspace-seats.js +95 -79
  17. package/package.json +1 -1
  18. package/schemas/council-run.schema.json +2 -2
  19. package/schemas/council-tally.schema.json +17 -1
  20. package/schemas/council-verdict.schema.json +12 -4
  21. package/schemas/run.schema.json +6 -1
  22. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  23. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  24. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  25. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  26. package/skills/second-opinion/SKILL.md +151 -36
  27. package/src/cli-council-run-bench.js +98 -6
  28. package/src/cli-handlers-council-run.js +18 -6
  29. package/src/cli-handlers-council.js +57 -7
  30. package/src/cli-handlers-doctor.js +1 -1
  31. package/src/cli.js +3 -1
  32. package/src/council/anonymize.js +2 -1
  33. package/src/council/briefings-chair-task.js +161 -0
  34. package/src/council/briefings-chair.js +33 -8
  35. package/src/council/briefings-debate.js +79 -13
  36. package/src/council/briefings-stage2-task.js +236 -0
  37. package/src/council/briefings-stage2.js +103 -26
  38. package/src/council/briefings-task.js +167 -0
  39. package/src/council/briefings.js +41 -4
  40. package/src/council/chair-fallback.js +95 -0
  41. package/src/council/debate.js +38 -21
  42. package/src/council/findings.js +3 -2
  43. package/src/council/ledger.js +2 -2
  44. package/src/council/parse-stage2.js +63 -15
  45. package/src/council/report-cost.js +61 -0
  46. package/src/council/report-html.js +26 -4
  47. package/src/council/report-md.js +30 -2
  48. package/src/council/report.js +40 -37
  49. package/src/council/run-assemble.js +21 -6
  50. package/src/council/run-chair.js +44 -95
  51. package/src/council/run-debate-revote.js +81 -49
  52. package/src/council/run-debate.js +51 -34
  53. package/src/council/run-finish.js +5 -3
  54. package/src/council/run-retry-keys.js +4 -4
  55. package/src/council/run-retry-launch.js +4 -4
  56. package/src/council/run-retry-notes.js +72 -15
  57. package/src/council/run-stage1-launch.js +4 -4
  58. package/src/council/run-stage1-rows.js +9 -6
  59. package/src/council/run-stage2.js +81 -47
  60. package/src/council/run-stages.js +9 -21
  61. package/src/council/run-stats-entry.js +46 -1
  62. package/src/council/run.js +28 -13
  63. package/src/council/seats.js +2 -2
  64. package/src/council/stage1-bind.js +3 -2
  65. package/src/council/verdict-seat-loss.js +124 -0
  66. package/src/council/verdict.js +108 -99
  67. package/src/headless.js +256 -49
  68. package/src/mcp-council-bench.js +64 -3
  69. package/src/mcp-council-run.js +10 -3
  70. package/src/mcp-server.js +52 -12
  71. package/src/mcp-tools.js +41 -5
  72. package/src/observe/council-legs.js +2 -2
  73. package/src/opencode-client.js +19 -1
  74. package/src/pack/pack-forward.js +15 -12
  75. package/src/pack/pack-resolve.js +1 -1
  76. package/src/prompt-builder.js +17 -1
  77. package/src/sidecar/fanout-leg-fallback.js +2 -1
  78. package/src/sidecar/fanout-leg.js +26 -0
  79. package/src/sidecar/fanout.js +1 -1
  80. package/src/sidecar/list-council.js +178 -0
  81. package/src/sidecar/list-limit.js +3 -1
  82. package/src/sidecar/list-search.js +2 -1
  83. package/src/sidecar/models-render.js +71 -0
  84. package/src/sidecar/models.js +19 -45
  85. package/src/sidecar/read.js +34 -10
  86. package/src/sidecar/reopen-spend.js +2 -1
  87. package/src/sidecar/setup.js +13 -4
  88. package/src/sidecar/start.js +2 -1
  89. package/src/template/render.js +16 -7
  90. package/src/utils/alias-audit.js +10 -3
  91. package/src/utils/alias-shadow-writer.js +220 -0
  92. package/src/utils/alias-shadow.js +294 -0
  93. package/src/utils/config.js +1 -1
  94. package/src/utils/curated-models.js +19 -9
  95. package/src/utils/degrade.js +12 -5
  96. package/src/utils/doctor-alias-check.js +2 -5
  97. package/src/utils/engine-log-parse.js +289 -0
  98. package/src/utils/engine-log-tail.js +114 -0
  99. package/src/utils/engine-log.js +250 -0
  100. package/src/utils/engine-skew-records.js +146 -0
  101. package/src/utils/engine-skew.js +300 -0
  102. package/src/utils/gateway-router.js +21 -3
  103. package/src/utils/model-canonicalization.js +55 -6
  104. package/src/utils/model-catalog.js +27 -9
  105. package/src/utils/model-fetcher.js +69 -16
  106. package/src/utils/model-shortlist.js +5 -2
  107. package/src/utils/provider-default-picker.js +6 -3
  108. package/src/utils/quick-picks.js +45 -7
  109. package/src/utils/result-schema.js +17 -1
  110. package/src/utils/text-sanitize.js +81 -0
  111. package/src/utils/ttft.js +57 -0
  112. package/src/utils/untrusted-fence.js +111 -1
  113. package/src/workspace/fold-format.js +28 -7
  114. package/src/workspace/live-normalize.js +2 -1
  115. package/src/workspace/matrix-model.js +6 -2
  116. package/src/workspace/run-detail.js +33 -7
  117. package/src/workspace/seat-space.js +10 -6
@@ -21,25 +21,11 @@ const { getFamilies } = require('../utils/curated-models');
21
21
  const { pickCurrent } = require('../utils/quick-picks');
22
22
  const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
23
23
  const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
24
+ const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
24
25
 
25
26
  const CHECK_EXIT_CAP = 100;
26
27
 
27
- /** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
28
- function perMtok(perToken) {
29
- if (perToken === null || perToken === undefined) { return '—'; }
30
- const n = Number(perToken);
31
- if (Number.isNaN(n) || n < 0) { return '—'; }
32
- return (n * 1e6).toFixed(2);
33
- }
34
28
 
35
- function fmtRow(m, aliasesById) {
36
- const alias = aliasesById.get(m.id);
37
- const aliasCol = alias ? `[${alias}] ` : '';
38
- const ctx = m.contextLength ?? '—';
39
- const pIn = perMtok(m.pricing && m.pricing.prompt);
40
- const pOut = perMtok(m.pricing && m.pricing.completion);
41
- return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
42
- }
43
29
 
44
30
  /** alias marks: id → comma-joined alias names (effective user aliases) */
45
31
  function aliasMarks() {
@@ -130,47 +116,31 @@ async function runRefresh(args) {
130
116
  return 0;
131
117
  }
132
118
 
133
- /** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
134
- function fmtGatewayFinding(f) {
135
- if (f.kind === 'stale') {
136
- return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
137
- }
138
- if (f.kind === 'divergent-missing') {
139
- return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
140
- }
141
- return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
142
- }
143
119
 
144
- const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
145
120
 
146
- /** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
147
- * a probe result's `cost` is a bare number (models-probe.js doesn't carry the
148
- * reported/estimated source tag), so this never claims a precision it can't back. */
149
- function fmtProbeCost(cost) {
150
- if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
151
- return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
152
- }
153
121
 
154
- /** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
155
- * class prefix padded to a fixed column, two-space indent — mirrors the STALE/
156
- * DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
157
- function fmtProbeLine(r) {
158
- const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
159
- if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
160
- if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (accepted but not serving)`; }
161
- return `${head} — ${r.detail}`;
162
- }
163
122
 
164
123
  async function runCheck(args) {
124
+ // v4.9 W13 Task B (BACKLOG C5). FIRST — ahead of the catalog-unavailable return
125
+ // below, exactly when an offline user most needs to know what their aliases bind
126
+ // to. No name list, so the subject is curated ∩ configured — NOT "the whole
127
+ // configured set", the overstatement round 3's B2 struck from findAliasShadows'
128
+ // docstring and whose twin here outlived it (round 5, D2). stderr only, so
129
+ // `--json` stays byte-clean; `audit…` opens a fresh scope (PR #203 A5).
130
+ require('../utils/alias-shadow').auditAliasShadows();
165
131
  const catalogInfo = await getCatalogInfo();
166
132
  const catalog = catalogInfo.models;
133
+ const providerFailures = Array.isArray(catalogInfo.providerFailures) ? catalogInfo.providerFailures : [];
167
134
  if (!catalog || catalog.length === 0) {
168
135
  const probeSkipped = args.live ? 'catalog-unavailable' : null;
169
136
  if (args.json) {
170
137
  process.stdout.write(JSON.stringify(buildAuditDoc({
171
- stale: [], catalogAvailable: false, probeSkipped
138
+ stale: [], catalogAvailable: false, probeSkipped, providerFailures
172
139
  }), null, 2) + '\n');
173
140
  } else {
141
+ // Council C2 (PR 215): "catalog unavailable" is precisely when the user
142
+ // needs to know WHICH provider refused them.
143
+ for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
174
144
  process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
175
145
  if (probeSkipped) { process.stdout.write(fmtLiveSkipped(probeSkipped) + '\n'); }
176
146
  }
@@ -179,7 +149,7 @@ async function runCheck(args) {
179
149
  const sources = collectAliasSources();
180
150
  const stale = findStaleAliases(sources, catalog)
181
151
  .map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
182
- const drifted = findDriftedStoredAliases(sources, catalog);
152
+ const drifted = findDriftedStoredAliases(sources, catalogInfo);
183
153
  // Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
184
154
  // (toGatewayRoutes()) — additive to the flat audit above. Informational by
185
155
  // default; --strict promotes it to a build-breaking exit code (CI gate).
@@ -214,10 +184,14 @@ async function runCheck(args) {
214
184
 
215
185
  if (args.json) {
216
186
  process.stdout.write(JSON.stringify(buildAuditDoc({
217
- stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults
187
+ stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults, providerFailures
218
188
  }), null, 2) + '\n');
219
189
  return exitCode;
220
190
  }
191
+ // issue 209: report REJECTED provider fetches before the alias findings -- an
192
+ // empty namespace explains stale/absent aliases downstream, and staying
193
+ // silent about it is the original defect.
194
+ for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
221
195
  const driftLines = buildFallbackDriftReport(catalog);
222
196
  if (stale.length === 0 && drifted.length === 0) {
223
197
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
@@ -15,6 +15,10 @@ const { fenceSidecarOutput } = require('../utils/untrusted-fence');
15
15
  // this module's own searchSessions re-export below.
16
16
  const { searchSessions } = require('./list-search');
17
17
  const { normalizeLimit, truncationNotice } = require('./list-limit');
18
+ // v4.9 W12: council runs are first-class rows on this surface too — the merge
19
+ // amicus_list has done since v4.0 §8, split out for the same line-budget reason
20
+ // as the two modules above. Owns the MODEL cell's rendering and width.
21
+ const { padModel, modelCell, mergeCouncilRows, councilScopeNotice } = require('./list-council');
18
22
 
19
23
  /**
20
24
  * Format a timestamp as relative age
@@ -134,16 +138,28 @@ function enumerateAllProjects(opts = {}) {
134
138
  */
135
139
  async function listSidecars(options) {
136
140
  const { status, all, json, search, limit, project = process.cwd() } = options;
137
- // Mirrors models.js:279-281's valueless-flag shape.
141
+ // Mirrors the valueless-flag shape of `sidecar/models.js :: handleModels`'s
142
+ // first guard (was `:279-281`; re-anchored by symbol in v4.9 W13).
138
143
  if (search === true) { throw new Error('--search requires a value'); }
139
144
  const cap = normalizeLimit(limit); // throws on valueless/non-integer/negative
140
145
 
141
146
  let sessions = all
142
147
  ? enumerateAllProjects({ status, project })
143
148
  : enumerateSessions(project, { status });
149
+ // Round 3 (B1): the merge's catch degrades to the session rows and used to do
150
+ // it in SILENCE. It now hands the failure out through this sink and the human
151
+ // branch says it — seam rationale at list-council.js :: mergeCouncilRows.
152
+ let unavailable = null;
153
+ sessions = mergeCouncilRows(sessions, project,
154
+ { status, all, onUnavailable: (note) => { unavailable = note; } });
144
155
  if (search) { sessions = searchSessions(sessions, search, { project }); }
145
156
  if (sessions.length === 0) {
146
157
  console.log('No amicus sessions found.');
158
+ // A1's disclosure as under the table below — an empty `--all` is where the
159
+ // omission is loudest — and B1's note on the same gate for the same reason:
160
+ // this early return is the one line both modes share, and --json cannot move.
161
+ if (!json && unavailable) { console.log(unavailable); }
162
+ if (all && !json) { console.log(councilScopeNotice()); }
147
163
  return;
148
164
  }
149
165
 
@@ -157,9 +173,11 @@ async function listSidecars(options) {
157
173
 
158
174
  if (json) {
159
175
  console.log(JSON.stringify(sessions, null, 2));
176
+ // No silent caps — but on stderr, so stdout stays one parseable document.
177
+ if (truncated) { console.error(truncationNotice(cap, total)); }
160
178
  } else {
161
179
  console.log(
162
- 'ID'.padEnd(10) + 'MODEL'.padEnd(23) + 'STATUS'.padEnd(11) +
180
+ 'ID'.padEnd(10) + padModel('MODEL') + 'STATUS'.padEnd(11) +
163
181
  'TAG'.padEnd(12) + 'AGE'.padEnd(12) + 'BRIEFING' +
164
182
  (all ? ' PROJECT' : '')
165
183
  );
@@ -170,7 +188,7 @@ async function listSidecars(options) {
170
188
  ((s.briefing?.length > 30) ? '...' : '');
171
189
  console.log(
172
190
  `${(s.id || '').padEnd(10)}` +
173
- `${(s.type === 'wave' ? `wave(${s.legCount ?? 0} legs)` : (s.model || '')).padEnd(23)}` +
191
+ `${padModel(modelCell(s))}` +
174
192
  `${(s.status || 'unknown').padEnd(11)}` +
175
193
  `${(s.tag || '').padEnd(12)}` +
176
194
  `${age.padEnd(12)}` +
@@ -178,13 +196,19 @@ async function listSidecars(options) {
178
196
  (all ? ` ${s.project || ''}` : '')
179
197
  );
180
198
  });
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); }
199
+ // THE TRAILING ORDER IS LOAD-BEARING (round 3, B4): docs/usage.md says every
200
+ // human `--all` listing ENDS with the scope note, and truncation used to
201
+ // print after the fork after it so `--all --limit N` made that false.
202
+ // Narrowest qualifier first; the standing limit closes.
203
+ if (truncated) { console.log(truncationNotice(cap, total)); }
204
+ if (unavailable) { console.log(unavailable); }
205
+ // Round 2 (A1): under `--all` the session rows span every indexed project
206
+ // and the council rows only ever span this one — say so. THIS is the seam:
207
+ // `all`, the `mergeCouncilRows` call above and the human table are all
208
+ // visible from here, and the merge itself cannot print. Inside the human
209
+ // branch on purpose — `--json` is a shape contract, and unlike the
210
+ // truncation notice there is no cap here for a script to raise.
211
+ if (all) { console.log(councilScopeNotice()); }
188
212
  }
189
213
  }
190
214
 
@@ -20,7 +20,8 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
20
20
  metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
21
21
  try {
22
22
  const { appendSpend } = require('../utils/spend-ledger');
23
- const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
23
+ const { gatewayOf } = require('../utils/gateway-router');
24
+ const gateway = metadata.gateway || gatewayOf(model);
24
25
  // v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
25
26
  // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
26
27
  appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
@@ -475,9 +475,17 @@ async function runReadlineSetup() {
475
475
  await warnOnLowOpenRouterCredit();
476
476
  }
477
477
 
478
- const { getCatalog } = require('../utils/model-catalog');
478
+ const { getCatalogInfo } = require('../utils/model-catalog');
479
479
  let catalog = [];
480
- try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
480
+ // #208: carry the per-provider fetch outcomes alongside the rows, so the
481
+ // vendor shortlist below cannot synthesise a bare direct id for a
482
+ // namespace whose fetch was rejected rather than never attempted.
483
+ let providerFailures = [];
484
+ try {
485
+ const info = await getCatalogInfo();
486
+ catalog = info.models;
487
+ providerFailures = info.providerFailures || [];
488
+ } catch (_err) { /* offline: pinned */ }
481
489
 
482
490
  // Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
483
491
  // provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
@@ -537,7 +545,7 @@ async function runReadlineSetup() {
537
545
  }
538
546
 
539
547
  // Read-modify-write — never rebuild the alias table (no-clobber rule).
540
- const cfg = loadConfig() || { aliases: toLiveSeedAliases(catalog) };
548
+ const cfg = loadConfig() || { aliases: toLiveSeedAliases({ models: catalog, providerFailures }) };
541
549
  if (!cfg.aliases) { cfg.aliases = {}; }
542
550
  if (chosen.alias) {
543
551
  cfg.default = chosen.alias;
@@ -548,7 +556,7 @@ async function runReadlineSetup() {
548
556
  // choice), but the alias's VALUE must stay the vendor phase's tier choice --
549
557
  // skip the curated-flagship upgrade so it isn't discarded.
550
558
  if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
551
- cfg.aliases[chosen.alias] = toStorableRoute(pick);
559
+ cfg.aliases[chosen.alias] = toStorableRoute(pick, { models: catalog, providerFailures });
552
560
  } else if (cfg.aliases[chosen.alias] === undefined) {
553
561
  const fallback = getDefaultAliases()[chosen.alias];
554
562
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
@@ -599,6 +607,7 @@ async function runReadlineSetup() {
599
607
  const { buildModelShortlist } = require('../utils/model-shortlist');
600
608
  const shortlist = buildModelShortlist(pick.vendorPath, {
601
609
  catalog,
610
+ providerFailures,
602
611
  recommendedId: cfg.aliases[chosen.alias],
603
612
  });
604
613
  const specific = await promptForVendorModel(
@@ -219,6 +219,7 @@ async function startSidecar(options) {
219
219
  try {
220
220
  const { appendSpend } = require('../utils/spend-ledger');
221
221
  const { statusFromResult } = require('../utils/result-schema');
222
+ const { gatewayOf } = require('../utils/gateway-router');
222
223
  appendSpend({
223
224
  taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
224
225
  op: 'start', status: statusFromResult(result), project: effectiveProject,
@@ -227,7 +228,7 @@ async function startSidecar(options) {
227
228
  // inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
228
229
  // best-effort catch swallows → EVERY start-mode spend row silently dropped + start-json.test.js
229
230
  // goes red. Use an in-scope value (spec-complete for direct/openrouter):
230
- gateway: String(model).startsWith('openrouter/') ? 'openrouter' : 'direct',
231
+ gateway: gatewayOf(model),
231
232
  // (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
232
233
  // cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
233
234
  // v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
@@ -14,12 +14,25 @@
14
14
  */
15
15
 
16
16
  const VAR_RE = /\{\{\s*([A-Za-z_][\w.]*)\s*\}\}/g;
17
+ // Single source: validation and rendering both derive from this set — adding an
18
+ // entry here is sufficient for both. A simple variable {{foo_bar}} reads
19
+ // data.fooBar (snake_case slot, camelCase data key).
17
20
  const KNOWN_VARIABLES = ['prompt', 'artifact', 'artifact_path', 'date', 'project', 'var.<key>'];
18
21
 
19
22
  function knownList() {
20
23
  return KNOWN_VARIABLES.map((v) => `{{${v}}}`).join(', ');
21
24
  }
22
25
 
26
+ function isSimpleVariable(name) {
27
+ // Reads KNOWN_VARIABLES live, not a load-time copy, so extending the
28
+ // exported set is sufficient (VAR_RE can never match the 'var.<key>' entry).
29
+ return name !== 'var.<key>' && KNOWN_VARIABLES.includes(name);
30
+ }
31
+
32
+ function dataKeyFor(name) {
33
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
34
+ }
35
+
23
36
  /**
24
37
  * Render a template with strict typo-safety rules:
25
38
  * unknown variable -> error; slot present without its data -> error; data
@@ -46,7 +59,7 @@ function renderTemplate(text, data) {
46
59
  }
47
60
  continue;
48
61
  }
49
- if (!['prompt', 'artifact', 'artifact_path', 'date', 'project'].includes(name)) {
62
+ if (!isSimpleVariable(name)) {
50
63
  return { error: `Error: Unknown template variable {{${name}}}. Known: ${knownList()}` };
51
64
  }
52
65
  }
@@ -76,12 +89,8 @@ function renderTemplate(text, data) {
76
89
  }
77
90
 
78
91
  const rendered = String(text).replace(VAR_RE, (_, name) => {
79
- if (name === 'prompt') { return data.prompt; }
80
- if (name === 'artifact') { return data.artifact; }
81
- if (name === 'artifact_path') { return data.artifactPath; }
82
- if (name === 'date') { return data.date; }
83
- if (name === 'project') { return data.project; }
84
- return vars[name.slice(4)];
92
+ if (name.startsWith('var.')) { return vars[name.slice(4)]; }
93
+ return data[dataKeyFor(name)];
85
94
  });
86
95
 
87
96
  return { text: rendered, notices };
@@ -172,13 +172,20 @@ function suggestReplacements(staleModel, catalog, n = 3) {
172
172
  * @param {Array<{id:string}>} catalog
173
173
  * @returns {Array<{alias:string,stored:string,current:string}>}
174
174
  */
175
- function findDriftedStoredAliases(sources, catalog) {
176
- if (!catalog || catalog.length === 0) { return []; }
175
+ function findDriftedStoredAliases(sources, catalogOrInfo) {
176
+ // Council #216 A1/B2/C1: accepts catalogInfo (or a bare array, for existing
177
+ // callers). Passing models WITHOUT providerFailures made this compute the bare
178
+ // direct form for a REJECTED namespace while sidecar/setup.js persists the
179
+ // gateway form -- reporting drift that does not exist, and suggesting a repair
180
+ // that writes back the unservable direct id issue 208 removed.
181
+ const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
182
+ const catalog = info.models || [];
183
+ if (catalog.length === 0) { return []; }
177
184
  const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
178
185
  const current = new Map();
179
186
  for (const r of resolveQuickPicks(catalog)) {
180
187
  if (r.source !== 'live') { continue; }
181
- const stored = toStorableRoute(r);
188
+ const stored = toStorableRoute(r, info);
182
189
  if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
183
190
  }
184
191
  const byProvider = idsByProvider(catalog);
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @module utils/alias-shadow-writer
3
+ * The alias-shadow notice's WRITE half: say it without ever sinking the run.
4
+ *
5
+ * EXTRACTED from src/utils/alias-shadow.js (PR #207 round 4). That file already
6
+ * separated the CHECK (read two alias tables, compare canonical forms) from the
7
+ * WRITE (get one advisory line onto a stream that may be a closed pipe), and
8
+ * rounds 2 and 3 put two independent hardenings on the write half alone —
9
+ * `safeWrite`'s synchronous guard and `armStream`'s asynchronous one. Round 4's
10
+ * B1 landed a third. The seam was already there; the file was at 286/300, and
11
+ * shaving the measurement prose that makes these guards auditable would have
12
+ * been the wrong economy, so the write half moved out whole.
13
+ *
14
+ * Nothing here was ever part of `alias-shadow.js`'s `module.exports` — these are
15
+ * internal helpers, not a public API — so there is no re-export shim and no
16
+ * import path anywhere in the tree that changes.
17
+ *
18
+ * Named mutants live with their red sets in tests/alias-shadow.test.js:
19
+ * "WRITERFATAL" (drop `safeWrite`'s try/catch), "STREAMFATAL" (drop
20
+ * `armStream`'s attach-once 'error' handler) and "STREAMDEAF" (round 5 — put
21
+ * the PURE no-op handler back, so the arming goes deaf to every class again
22
+ * instead of only the benign one).
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ /**
28
+ * Write a notice without ever letting the writer sink the run (round 2, B1).
29
+ *
30
+ * The 'never throws' contract used to cover only the CHECK: the guard wrapped
31
+ * `findAliasShadows`, so a writer that threw — a caller-supplied collector that
32
+ * rejects, a stream whose write throws — escaped and killed the launch this
33
+ * diagnosis exists to protect. Worse, on the failure branch it escaped a second
34
+ * time, because that branch announced itself through the SAME broken writer. A
35
+ * notice must never be fatal to what it is describing, so the write is swallowed
36
+ * here and nowhere else.
37
+ *
38
+ * ⚠️ This covers the SYNCHRONOUS half only. A piped stderr fails on a later turn
39
+ * and never throws from `write()` at all — see `armStream` below, which is the
40
+ * other half of the same contract.
41
+ * @param {(line: string) => void} out
42
+ * @param {string} line
43
+ */
44
+ function safeWrite(out, line) {
45
+ try { out(line); } catch { /* a diagnosis must never sink the run it diagnoses */ }
46
+ }
47
+
48
+ /**
49
+ * The armed marker, keyed ON THE STREAM (PR #207 round 4, B1).
50
+ *
51
+ * It used to be a module-scoped `WeakSet`, which made "attach once" true only
52
+ * once per MODULE INSTANCE. `jest.resetModules()` builds a fresh registry, the
53
+ * fresh copy of this module starts with an empty set, and the SAME
54
+ * `process.stderr` collects a second listener — measured at SEVEN across
55
+ * tests/alias-shadow.test.js alone, against Node's 10-listener
56
+ * MaxListenersExceededWarning. That warning is emitted ASYNCHRONOUSLY onto
57
+ * `process.stderr.write`, which is the method several of this feature's absence
58
+ * controls replace and exact-match on, so the accumulation was a
59
+ * nondeterministic flake waiting for two more fixtures. A marker that lives on
60
+ * the stream is immune: it is the same object either way.
61
+ *
62
+ * `Symbol.for`, not `Symbol()`: the well-known-symbol registry is per REALM, so
63
+ * every instance of this module — reset registry, second resolved path, npx
64
+ * copy loaded beside a global one — computes the SAME key and therefore agrees
65
+ * about what is already armed. A module-local `Symbol()` would be a fresh key
66
+ * per instance and would re-arm exactly like the WeakSet did.
67
+ *
68
+ * It also retains nothing: a swapped-out stream (tests do this) is dropped with
69
+ * its own marker attached, where the WeakSet had to be weak on purpose.
70
+ */
71
+ const ARMED = Symbol.for('amicus.alias-shadow.armed');
72
+
73
+ /**
74
+ * The self-report marker (PR #207 round 5, A3+B2+D1+C2). Same `Symbol.for`
75
+ * registry discipline, and the same reason, as `ARMED` above: one report per
76
+ * STREAM, agreed on by every instance of this module.
77
+ */
78
+ const REPORTED = Symbol.for('amicus.alias-shadow.reported');
79
+
80
+ /**
81
+ * Stream failures that mean "the reader went away", and nothing more.
82
+ *
83
+ * This is the class the arming exists for: `amicus … | head`, a closed MCP
84
+ * client, a terminal that went away mid-write. There is nobody left to tell, so
85
+ * telling is pointless — these are swallowed in silence. Everything else is a
86
+ * real fault and gets said out loud (see `armStream`).
87
+ *
88
+ * `EOF` is here beside `EPIPE` because that is what a severed pipe reports on
89
+ * Windows, where this is developed; `ERR_STREAM_DESTROYED` and
90
+ * `ERR_STREAM_WRITE_AFTER_END` are Node's own spellings of the same "the sink is
91
+ * gone" fact for a destroyed/ended stream.
92
+ */
93
+ const BENIGN_STREAM_ERRORS = new Set([
94
+ 'EPIPE', 'EOF', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END',
95
+ ]);
96
+
97
+ /**
98
+ * Is this the reader-went-away class? A code-less error is NOT assumed benign —
99
+ * nothing says it is, and the honest default for an unrecognised fault is to
100
+ * report it.
101
+ * @param {*} err
102
+ */
103
+ function isBenignStreamError(err) {
104
+ return !!err && BENIGN_STREAM_ERRORS.has(err.code);
105
+ }
106
+
107
+ /**
108
+ * Make a stream's write failures non-fatal, once — WITHOUT going deaf (round 3
109
+ * A1; round 5 A3+B2+D1, and the contested C2, same mechanism).
110
+ *
111
+ * MEASURED, node v24.18.0 on Windows, against a REAL closed pipe (parent spawns
112
+ * a child with `stdio: ['ignore','ignore','pipe']` and destroys the read end;
113
+ * the child then writes to `process.stderr`):
114
+ *
115
+ * · `write(line)` returns FALSE and throws NOTHING. `safeWrite`'s try/catch
116
+ * sees nothing at all. The EPIPE arrives on a LATER turn, as an 'error'
117
+ * event; with no listener, EventEmitter throws it, and that throw is an
118
+ * uncaughtException that ends the process (measured: exit code 7).
119
+ * · Passing a write CALLBACK does NOT fix it. The callback received the EPIPE
120
+ * AND the 'error' event still fired unhandled — same exit 7. This is why
121
+ * there is no callback here: it would observe the failure without disarming
122
+ * it, and read like a guard while being none.
123
+ * · Attaching for the duration of the write and detaching after is not merely
124
+ * racy, it is always WRONG: delivery is always on a later turn, so the
125
+ * detach always wins (measured: exit 7 again, with the handler's own log
126
+ * line showing it was removed before the error landed).
127
+ * · A persistent listener absorbs it, and a SECOND write raises a SECOND
128
+ * 'error' — so this must be `on`, never `once`.
129
+ *
130
+ * Hence: attach once, per stream object, and leave it.
131
+ *
132
+ * ⚠️ THE SCOPE IS THE WHOLE PROCESS, PERMANENTLY, and the round-3 text undersold
133
+ * it. From the first notice onward this handler receives EVERY 'error' raised on
134
+ * `process.stderr` by ANY producer in the process — not only the EPIPE from this
135
+ * feature's own write, and not only for the duration of the notice. It is
136
+ * sharpest in the MCP server, which imports this in-tree and then outlives every
137
+ * individual run. A pure no-op there would silently discard unrelated stderr
138
+ * faults for the life of the server, which is precisely the correct-but-SILENT
139
+ * degrade the product principle forbids. So the handler DISCRIMINATES: the
140
+ * benign reader-went-away class above is swallowed, and anything else is
141
+ * reported through the house logger.
142
+ *
143
+ * ⚠️ WHY THE REPORT IS BOUNDED TO ONE. MEASURED: `utils/logger.js` writes with
144
+ * `console.error`, i.e. onto `process.stderr` — the same stream that just
145
+ * failed (its own header: "Outputs JSON-formatted logs to stderr"). An unbounded
146
+ * report could therefore provoke the next 'error', handle it, report again, and
147
+ * spin the event loop forever — round 3's B1 defect ("the announcement becomes
148
+ * the next escape") in its asynchronous form. One report per stream, wrapped in
149
+ * its own try/catch, is what makes speaking up safe. The report is best-effort
150
+ * BY CONSTRUCTION and is documented as such rather than promised.
151
+ *
152
+ * Checked rather than assumed: nothing in src/, bin/ or scripts/ attaches to or
153
+ * depends on `process.stderr`'s 'error' event (the `.on('error')` hits in the
154
+ * tree are all on CHILD process streams), and adding a listener removes nobody
155
+ * else's — any handler another module attaches still runs alongside this one.
156
+ * `electron/main.js` installs a listener on the same stream for the same
157
+ * round-3 reason, but its handler is silent for EVERY code; this one
158
+ * deliberately departs from that precedent for the non-benign class, which is
159
+ * exactly why the bound above had to exist first.
160
+ *
161
+ * ⚠️ Scoped to the module's OWN default writer. An INJECTED writer (every test
162
+ * collector, and the MCP notices array) never reaches here, so nothing is armed
163
+ * on its behalf.
164
+ * @param {NodeJS.WritableStream} stream
165
+ * @param {(msg: string, ctx: object) => void} [log] the diagnostic seam —
166
+ * injected by tests; defaults to the house logger's `error`.
167
+ */
168
+ function armStream(stream, log) {
169
+ if (!stream || typeof stream.on !== 'function' || stream[ARMED]) { return; }
170
+ try {
171
+ // `configurable` so a test that borrows a real stream can still take it
172
+ // back; symbol-keyed, so it is invisible to `Object.keys`, `JSON.stringify`
173
+ // and anything else that walks the stream object we are borrowing.
174
+ Object.defineProperty(stream, ARMED, { value: true, configurable: true });
175
+ } catch {
176
+ // A stream nobody can MARK cannot be armed exactly once, and arming it
177
+ // without a mark would trade round 3's unhandled-'error' hazard for an
178
+ // unbounded-listener one on that same stream. Node never freezes
179
+ // `process.stderr`, so this is a guard, not a path.
180
+ return;
181
+ }
182
+ stream.on('error', (err) => {
183
+ // The reader went away. Nobody to tell; a diagnosis must never sink the run
184
+ // it diagnoses, and here it must not natter at a dead pipe either.
185
+ if (isBenignStreamError(err)) { return; }
186
+ if (stream[REPORTED]) { return; }
187
+ try {
188
+ Object.defineProperty(stream, REPORTED, { value: true, configurable: true });
189
+ } catch {
190
+ // Unreachable in practice: `ARMED` was defined on this same object moments
191
+ // ago, so the object takes symbol properties. Bounded-over-loud if it ever
192
+ // is reached — an unbounded report is the one failure mode worse than a
193
+ // missed one.
194
+ return;
195
+ }
196
+ try {
197
+ const report = log || require('./logger').logger.error;
198
+ // The message rides in the CONTEXT, not the format string: the logger
199
+ // JSON.stringifies the entry, and JSON.stringify escapes every C0 byte
200
+ // (MEASURED: ESC serializes as the six characters \u001b), so its text
201
+ // cannot repaint a terminal from here the way `formatAliasShadow`'s
202
+ // config fragments could.
203
+ report('alias-shadow notice: unexpected error on the notice stream', {
204
+ code: (err && err.code) || null,
205
+ error: err && err.message ? String(err.message) : String(err),
206
+ });
207
+ } catch { /* a diagnosis must never sink the run it diagnoses */ }
208
+ });
209
+ }
210
+
211
+ /** The default writer: stderr, armed against its own asynchronous failure. */
212
+ function writeNoticeToStderr(line) {
213
+ const stream = process.stderr;
214
+ armStream(stream);
215
+ stream.write(line);
216
+ }
217
+
218
+ // `ARMED` is deliberately NOT exported: it is this module's private marker, and
219
+ // a second holder of the key is a second thing that could clear it.
220
+ module.exports = { safeWrite, armStream, writeNoticeToStderr };