amicus 4.8.0 → 4.9.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.
Files changed (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +10 -5
  4. package/docs/CITATIONS.md +13 -5
  5. package/docs/ROADMAP.md +101 -10
  6. package/docs/configuration.md +55 -5
  7. package/docs/council.md +102 -14
  8. package/docs/troubleshooting.md +9 -2
  9. package/docs/usage.md +128 -12
  10. package/electron/ipc-setup.js +39 -2
  11. package/electron/main.js +46 -3
  12. package/electron/offer-session.js +51 -0
  13. package/electron/setup-ui-model.js +99 -9
  14. package/electron/setup-ui-styles.js +22 -0
  15. package/electron/setup-ui.js +244 -32
  16. package/electron/workspace-ui/live-dead-seats.js +163 -91
  17. package/electron/workspace-ui/live-seats.js +4 -4
  18. package/electron/workspace-ui/workspace-banners.js +30 -7
  19. package/electron/workspace-ui/workspace-matrix.js +23 -3
  20. package/electron/workspace-ui/workspace-seats.js +95 -79
  21. package/package.json +2 -1
  22. package/schemas/council-run.schema.json +2 -2
  23. package/schemas/council-tally.schema.json +17 -1
  24. package/schemas/council-verdict.schema.json +12 -4
  25. package/schemas/run.schema.json +6 -1
  26. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  27. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  28. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  29. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  30. package/skills/second-opinion/SKILL.md +151 -36
  31. package/src/cli-council-run-bench.js +98 -6
  32. package/src/cli-handlers-council-run.js +18 -6
  33. package/src/cli-handlers-council.js +57 -7
  34. package/src/cli-handlers-doctor.js +12 -15
  35. package/src/cli.js +3 -1
  36. package/src/council/anonymize.js +2 -1
  37. package/src/council/briefings-chair-task.js +161 -0
  38. package/src/council/briefings-chair.js +33 -8
  39. package/src/council/briefings-debate.js +79 -13
  40. package/src/council/briefings-stage2-task.js +236 -0
  41. package/src/council/briefings-stage2.js +103 -26
  42. package/src/council/briefings-task.js +167 -0
  43. package/src/council/briefings.js +41 -4
  44. package/src/council/chair-fallback.js +95 -0
  45. package/src/council/debate.js +38 -21
  46. package/src/council/findings.js +3 -2
  47. package/src/council/ledger.js +2 -2
  48. package/src/council/parse-stage2.js +64 -16
  49. package/src/council/report-cost.js +61 -0
  50. package/src/council/report-html.js +26 -4
  51. package/src/council/report-md.js +30 -2
  52. package/src/council/report.js +40 -37
  53. package/src/council/run-assemble.js +21 -6
  54. package/src/council/run-chair.js +44 -95
  55. package/src/council/run-debate-revote.js +81 -49
  56. package/src/council/run-debate.js +51 -34
  57. package/src/council/run-finish.js +5 -3
  58. package/src/council/run-retry-keys.js +4 -4
  59. package/src/council/run-retry-launch.js +4 -4
  60. package/src/council/run-retry-notes.js +72 -15
  61. package/src/council/run-stage1-launch.js +4 -4
  62. package/src/council/run-stage1-rows.js +9 -6
  63. package/src/council/run-stage2.js +81 -47
  64. package/src/council/run-stages.js +9 -21
  65. package/src/council/run-stats-entry.js +46 -1
  66. package/src/council/run.js +28 -13
  67. package/src/council/seats.js +2 -2
  68. package/src/council/stage1-bind.js +3 -2
  69. package/src/council/verdict-seat-loss.js +124 -0
  70. package/src/council/verdict.js +108 -99
  71. package/src/headless.js +256 -49
  72. package/src/mcp-council-bench.js +64 -3
  73. package/src/mcp-council-run.js +10 -3
  74. package/src/mcp-server.js +52 -12
  75. package/src/mcp-tools.js +41 -5
  76. package/src/observe/council-legs.js +2 -2
  77. package/src/opencode-client.js +19 -1
  78. package/src/pack/pack-forward.js +15 -12
  79. package/src/pack/pack-resolve.js +1 -1
  80. package/src/prompt-builder.js +17 -1
  81. package/src/sidecar/fanout-leg.js +26 -0
  82. package/src/sidecar/fanout.js +1 -1
  83. package/src/sidecar/list-council.js +178 -0
  84. package/src/sidecar/list-limit.js +3 -1
  85. package/src/sidecar/list-search.js +2 -1
  86. package/src/sidecar/models.js +8 -1
  87. package/src/sidecar/read.js +34 -10
  88. package/src/sidecar/setup.js +124 -0
  89. package/src/template/render.js +16 -7
  90. package/src/utils/alias-audit.js +81 -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 +16 -8
  95. package/src/utils/degrade.js +12 -5
  96. package/src/utils/doctor-alias-check.js +149 -0
  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 +10 -2
  103. package/src/utils/model-canonicalization.js +64 -0
  104. package/src/utils/model-catalog.js +1 -1
  105. package/src/utils/model-shortlist.js +100 -0
  106. package/src/utils/provider-default-picker.js +93 -45
  107. package/src/utils/provider-default-prompt.js +1 -1
  108. package/src/utils/quick-picks.js +2 -2
  109. package/src/utils/remediation-hints.js +24 -0
  110. package/src/utils/result-schema.js +10 -0
  111. package/src/utils/text-sanitize.js +81 -0
  112. package/src/utils/ttft.js +57 -0
  113. package/src/utils/untrusted-fence.js +111 -1
  114. package/src/workspace/fold-format.js +28 -7
  115. package/src/workspace/live-normalize.js +2 -1
  116. package/src/workspace/matrix-model.js +6 -2
  117. package/src/workspace/run-detail.js +35 -9
  118. package/src/workspace/seat-space.js +10 -6
@@ -353,6 +353,73 @@ async function printDoctorFinale(deps = {}) {
353
353
  }
354
354
  }
355
355
 
356
+ /**
357
+ * #138 second level: after a family pick, let the user name a SPECIFIC model
358
+ * from that vendor. Returns the chosen catalog id, or null to keep the
359
+ * family default (bare Enter, an empty shortlist, or two invalid entries).
360
+ *
361
+ * The prompt deliberately avoids the substring "Pick a number":
362
+ * tests/sidecar/setup.test.js:392,475 branch on that literal and would
363
+ * answer '' here, leaving new coverage green but vacuous.
364
+ * @param {(q: string) => Promise<string>} ask
365
+ * @param {(line: string) => void} print
366
+ * @param {{suggested: Array<object>, rest: Array<object>, total: number}} shortlist
367
+ * @param {string} vendorPath
368
+ * @returns {Promise<string|null>}
369
+ */
370
+ async function promptForVendorModel(ask, print, shortlist, vendorPath) {
371
+ if (!shortlist || shortlist.total === 0) { return null; }
372
+
373
+ let visible = shortlist.suggested;
374
+ const fmt = (r, i) => {
375
+ const price = r.pricePerMInput === null ? 'n/a' : `$${r.pricePerMInput.toFixed(2)}/M in`;
376
+ const ctx = r.contextLength === null || r.contextLength === undefined ? '' : ` · ctx ${r.contextLength}`;
377
+ return ` ${i + 1}) ${r.id}${ctx} · ${price}${r.isRecommended ? ' (recommended)' : ''}`;
378
+ };
379
+
380
+ const render = () => {
381
+ print('');
382
+ print(`Which ${vendorPath} model?`);
383
+ visible.forEach((r, i) => print(fmt(r, i)));
384
+ if (visible.length < shortlist.total) {
385
+ print(` … ${shortlist.total - visible.length} more`);
386
+ }
387
+ print('');
388
+ };
389
+ render();
390
+
391
+ for (let attempt = 0; attempt < 2; attempt++) {
392
+ const hint = visible.length < shortlist.total ? ", 'a' for all" : '';
393
+ const answer = (await ask(
394
+ `Choose 1-${visible.length}${hint}, a full model id, or Enter to keep the default: `
395
+ ) || '').trim();
396
+
397
+ if (answer === '') { return null; }
398
+ if (answer.toLowerCase() === 'a' && visible.length < shortlist.total) {
399
+ visible = shortlist.suggested.concat(shortlist.rest);
400
+ render();
401
+ attempt--; // expanding the list is not a failed attempt
402
+ continue;
403
+ }
404
+ if (/^\d+$/.test(answer)) {
405
+ const n = Number.parseInt(answer, 10);
406
+ if (n >= 1 && n <= visible.length) { return visible[n - 1].id; }
407
+ }
408
+ if (answer.includes('/')) { return answer; }
409
+ // F1 (council review, PR 196): the final attempt used to fall through
410
+ // silently -- the loop just exited and the caller kept the family
411
+ // default with no feedback at all, so the user's last keystroke
412
+ // visibly did nothing. Both attempts now print, but the last one also
413
+ // states the consequence instead of implying a further retry.
414
+ if (attempt === 0) {
415
+ print(`Invalid choice: "${answer}".`);
416
+ } else {
417
+ print(`Invalid choice: "${answer}". Keeping the family default.`);
418
+ }
419
+ }
420
+ return null;
421
+ }
422
+
356
423
  /**
357
424
  * Run the readline-based setup wizard (headless fallback)
358
425
  *
@@ -486,6 +553,62 @@ async function runReadlineSetup() {
486
553
  const fallback = getDefaultAliases()[chosen.alias];
487
554
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
488
555
  }
556
+
557
+ // #138: offer the family -> model second level. `pick.vendorPath` is
558
+ // the vendor whose catalog rows we drill into; a chosen id REPLACES
559
+ // the flagship route for this alias only. Guarded — a picker failure
560
+ // must never abort a setup run that has already collected keys.
561
+ //
562
+ // R4a (fix round 2, supersedes R4's noUpgrade disjunct; wording
563
+ // corrected in the F7 fix wave -- the previous wording claimed the
564
+ // governing rule was "never ask about the same vendor twice in one
565
+ // run", which this guard cannot implement and does not):
566
+ //
567
+ // `vendorAliasesWritten` holds PROVIDER names (runProviderDefaultPickers
568
+ // adds each entry of `foundKeys`, e.g. 'google'/'openai'/'anthropic'/
569
+ // 'deepseek'), while `chosen.alias` is a FAMILY ALIAS name ('gemini',
570
+ // 'gemini-pro', 'gpt', 'opus', 'deepseek'). The two prompts write
571
+ // DIFFERENT config keys -- the per-provider picker writes
572
+ // `config.aliases[provider]` (e.g. `aliases.google`), this drill-down
573
+ // writes `config.aliases[chosen.alias]` (e.g. `aliases.gemini`) -- so
574
+ // `!vendorAliasesWritten.has(chosen.alias)` only skips the drill-down
575
+ // when the alias STRING happens to collide with an already-written
576
+ // provider name. Today that's 'deepseek' alone (alias 'deepseek' ===
577
+ // provider 'deepseek'); every other family alias never collides, so
578
+ // this drill-down still fires for those even after the per-provider
579
+ // phase ran for that family's vendor -- correctly: the two prompts
580
+ // bind different keys, they are not "the same vendor twice".
581
+ //
582
+ // TRAP: re-keying this guard to `pick.vendorPath` (so it tests the
583
+ // actual vendor instead of the alias-name coincidence) looks like the
584
+ // obvious fix for the mismatch above and is NOT one -- it was
585
+ // measured to delete issue 138's feature entirely for every user
586
+ // holding a direct key, by skipping the drill-down for every family
587
+ // whose per-provider picker already ran this session. Leave this
588
+ // guard exactly as it is.
589
+ //
590
+ // `chosen.noUpgrade` does NOT imply no question was asked for this
591
+ // vendor: a user can type a known alias name (noUpgrade=true) for a
592
+ // vendor the per-provider phase already walked through this run, and
593
+ // that must still be skipped. Whenever noUpgrade is true AND the
594
+ // alias is genuinely unasked-about, `!vendorAliasesWritten.has(...)`
595
+ // is already true on its own, so dropping the noUpgrade disjunct
596
+ // loses no legitimate firing case -- only the double-ask.
597
+ if (pick && !vendorAliasesWritten.has(chosen.alias)) {
598
+ try {
599
+ const { buildModelShortlist } = require('../utils/model-shortlist');
600
+ const shortlist = buildModelShortlist(pick.vendorPath, {
601
+ catalog,
602
+ recommendedId: cfg.aliases[chosen.alias],
603
+ });
604
+ const specific = await promptForVendorModel(
605
+ askQuestion.bind(null, rl), console.log, shortlist, pick.vendorPath
606
+ );
607
+ if (specific) { cfg.aliases[chosen.alias] = specific; }
608
+ } catch (err) {
609
+ console.log(`Note: couldn't list ${pick.vendorPath} models (${err.message}).`);
610
+ }
611
+ }
489
612
  } else {
490
613
  cfg.default = chosen.modelId;
491
614
  }
@@ -613,6 +736,7 @@ module.exports = {
613
736
  createDefaultConfig,
614
737
  deriveFreeAlias,
615
738
  detectApiKeys,
739
+ promptForVendorModel,
616
740
  runFreeCouncilBranch,
617
741
  runInteractiveSetup,
618
742
  runReadlineSetup,
@@ -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 };
@@ -1,10 +1,21 @@
1
1
  /**
2
- * Alias Audit (F5) — report + suggest, never auto-repair.
2
+ * Alias Audit (F5) — report + suggest for most classes; doctor --fix auto-repairs one narrow, mechanically-unambiguous class (B3).
3
3
  *
4
4
  * Finds aliases/routes pointing at models absent from the catalog and
5
5
  * suggests current same-vendor replacements. Pure functions over inputs;
6
6
  * collectAliasSources() does the gathering. Consumed by `amicus models
7
7
  * --check` and the npm wrapper scripts.
8
+ *
9
+ * `findFabricatedAliasRepairs()` (B3, council review of PR 198) is the one
10
+ * exception to "never auto-repair": it detects the single class `doctor
11
+ * --fix` can safely rewrite unattended -- a bare id `classifyModel` proves
12
+ * `invalid` on the `direct` gateway AND for which the catalog holds an
13
+ * unambiguous OpenRouter twin (`pairAcrossGateways`, never string
14
+ * concatenation) -- still pure detection here; the actual write lives in
15
+ * `doctor-alias-check.js`'s `repairAlias()`. Every OTHER class this module
16
+ * finds (typo, retired model, ambiguous twin, drifted-but-live) stays
17
+ * report-and-suggest only, same as before -- this module does not become a
18
+ * general auto-repair tool.
8
19
  */
9
20
 
10
21
  'use strict';
@@ -132,7 +143,10 @@ function suggestReplacements(staleModel, catalog, n = 3) {
132
143
  * fresh `amicus setup` would seed today — the v4.6.1 release-gate class
133
144
  * (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
134
145
  * findStaleAliases passes it, no longer what the family resolves to).
135
- * Report + suggest, never auto-repair (this module's charter).
146
+ * Report + suggest only -- unlike `findFabricatedAliasRepairs` below (B3's
147
+ * narrow, mechanically-unambiguous exception), THIS function never
148
+ * auto-repairs: a drifted-but-live target has no single unambiguous
149
+ * "correct" answer to converge on the way a fabricated id does.
136
150
  *
137
151
  * Only user-config rows are checked (defaults/curated follow the catalog by
138
152
  * construction), only for aliases that are quick-pick families (a custom
@@ -178,4 +192,68 @@ function findDriftedStoredAliases(sources, catalog) {
178
192
  .map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
179
193
  }
180
194
 
181
- module.exports = { collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements };
195
+ /**
196
+ * B3 (council review of PR 198, issue 195): the narrow, mechanically-
197
+ * unambiguous class of stored alias `doctor --fix` may repair -- a bare
198
+ * `<vendor>/<model>` id in a 'user-config' row (what's actually persisted to
199
+ * `config.aliases`, the only source a repair can rewrite) that
200
+ * `classifyModel` proves `invalid` on the `direct` gateway AND for which the
201
+ * catalog contains an unambiguous OpenRouter twin (`pairAcrossGateways` --
202
+ * never string-concatenation). This is exactly the class the pre-fix
203
+ * v4.8.0 `chooseRowId`/`applyProviderDefault` could persist (an unconditional
204
+ * `openrouter/` strip with no catalog evidence); PR 198's
205
+ * `directFormIfProven` (model-canonicalization.js) now refuses to write it
206
+ * going forward, so this function finds the ones already ON DISK and
207
+ * converges them onto the same OpenRouter-prefixed answer the picker would
208
+ * offer today, rather than inventing a third one.
209
+ *
210
+ * Deliberately narrower than findStaleAliases: a typo'd, retired, or
211
+ * user-invented id also classifies `invalid`, but `pairAcrossGateways` can
212
+ * only find a twin when the extracted vendor + version token names a REAL
213
+ * model that still exists, under that exact normalized name, in the
214
+ * catalog's OpenRouter namespace -- a typo or a genuinely dead model can
215
+ * never satisfy that. Ambiguous (>1 match) or absent twins are left
216
+ * untouched -- still reported as stale by findStaleAliases, never repaired.
217
+ *
218
+ * DIVERGENT_VENDORS (e.g. anthropic) are excluded on purpose:
219
+ * model-canonicalization.js gates that set FIRST and unconditionally, so its
220
+ * predicates never strip a divergent vendor's prefix in the first place --
221
+ * a bare divergent-vendor alias could not have been produced by the bug
222
+ * this repairs, so treating one as fabricated here would be inventing a new
223
+ * class, not converging on the picker's own answer.
224
+ *
225
+ * Empty/absent catalog -> [] : classifyModel can only return 'unknown' with
226
+ * no catalog rows, and 'unknown' never authorises a repair -- no positive
227
+ * evidence, no write.
228
+ * @param {Array<{alias:string,model:string,source:string}>} sources
229
+ * @param {Array<{id:string}>} catalog
230
+ * @returns {Array<{alias:string,oldId:string,newId:string}>}
231
+ */
232
+ function findFabricatedAliasRepairs(sources, catalog) {
233
+ if (!catalog || catalog.length === 0) { return []; }
234
+ const { classifyModel } = require('./model-classification');
235
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
236
+ const { DIVERGENT_VENDORS } = require('./curated-models');
237
+ const catalogInfo = { models: catalog };
238
+
239
+ const out = [];
240
+ for (const { alias, model, source } of sources) {
241
+ if (source !== 'user-config') { continue; }
242
+ if (typeof model !== 'string' || model.startsWith('openrouter/')) { continue; } // not bare
243
+ const parts = model.split('/');
244
+ if (parts.length < 2) { continue; } // not a <vendor>/<model> shape
245
+ const vendor = parts[0];
246
+ if (DIVERGENT_VENDORS.has(vendor)) { continue; }
247
+ const versionToken = parts.slice(1).join('/');
248
+ if (classifyModel(model, 'direct', catalogInfo) !== 'invalid') { continue; }
249
+ const paired = pairAcrossGateways(vendor, versionToken, catalogInfo);
250
+ if (!paired.openrouter) { continue; } // ambiguous or absent twin -- leave as a warning
251
+ out.push({ alias, oldId: model, newId: paired.openrouter });
252
+ }
253
+ return out;
254
+ }
255
+
256
+ module.exports = {
257
+ collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements,
258
+ findFabricatedAliasRepairs,
259
+ };
@@ -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 };