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
@@ -17,8 +17,11 @@
17
17
  const { resolveTier } = require('./model-tiers');
18
18
  const { getCostTier, loadConfig, saveConfig } = require('./config');
19
19
  const { pairAcrossGateways } = require('./gateway-route-catalog');
20
- const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
21
20
  const { isLocalProvider } = require('./local-providers');
21
+ // directFormIfSafe (list-building) / directFormIfProven (persistence): see
22
+ // model-canonicalization.js's module docstring for why these are two
23
+ // separately-named functions rather than one with an optional mode.
24
+ const { directFormIfSafe, directFormIfProven } = require('./model-canonicalization');
22
25
 
23
26
  /**
24
27
  * @param {{pricing?: {prompt?: string|number|null}|null}|null|undefined} orRow
@@ -49,8 +52,10 @@ function vendorRowsIn(catalog, vendor) {
49
52
  }
50
53
 
51
54
  /**
52
- * Choose the verbatim catalog id a single catalog `row` should surface as in
53
- * the picker -- NEVER fabricates an id.
55
+ * Choose the id a single catalog `row` should surface as in the picker.
56
+ * Prefers a real, verbatim catalog id; only SYNTHESISES one (delegating to
57
+ * `directFormIfSafe`, model-canonicalization.js -- see its module docstring
58
+ * for the guard) for a non-divergent vendor with no direct twin at all.
54
59
  *
55
60
  * A direct-namespace row always keeps its own (real, direct-callable) id,
56
61
  * regardless of what `pairAcrossGateways` could resolve for it -- this is
@@ -60,35 +65,55 @@ function vendorRowsIn(catalog, vendor) {
60
65
  * and must not be dropped or silently merged.
61
66
  *
62
67
  * An OpenRouter-namespace row is collapsed onto a direct id only when
63
- * `pairAcrossGateways` found exactly one (unambiguous) direct twin -- that's
64
- * a real catalog id, safe to reuse for dedup. Otherwise: for
68
+ * `pairAcrossGateways` found exactly one (unambiguous) direct twin. For
65
69
  * `DIVERGENT_VENDORS` (direct ids don't share a string form with
66
- * OpenRouter's -- e.g. anthropic's dash-vs-dot versioning), the row keeps
67
- * its OpenRouter-prefixed id as-is; stripping the prefix would fabricate a
68
- * non-direct-callable dot-form id no row actually carries. Non-divergent
69
- * vendors keep the pre-existing strip via `toCanonicalDefault`, which is
70
- * safe because their direct and OpenRouter ids are identical once the
71
- * `openrouter/<vendor>/` prefix is removed (mirrors curated-models.js's
72
- * `directFormFor` policy).
70
+ * OpenRouter's, e.g. anthropic's dash-vs-dot versioning), the row keeps its
71
+ * OpenRouter-prefixed id as-is -- `directFormIfSafe` gates that set FIRST
72
+ * and internally (model-canonicalization.js), so stripping is never even
73
+ * attempted on their ids, which is not a string-safe operation.
73
74
  * @param {string} vendor
74
75
  * @param {boolean} isDirect whether `row.id` is itself a direct-namespace id
75
76
  * @param {{id:string}} row
76
77
  * @param {{direct?:string, openrouter?:string}} paired
78
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
77
79
  * @returns {string|null}
78
80
  */
79
- function chooseRowId(vendor, isDirect, row, paired) {
81
+ function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
80
82
  if (isDirect) { return row.id; }
81
83
  if (paired.direct) { return paired.direct; }
82
84
  if (!paired.openrouter) { return null; }
83
- return DIVERGENT_VENDORS.has(vendor) ? paired.openrouter : toCanonicalDefault(paired.openrouter);
85
+ return directFormIfSafe(vendor, paired.openrouter, catalogInfo);
84
86
  }
85
87
 
86
88
  /**
87
89
  * Dedupe `vendor`'s catalog rows across gateways into one row per logical
88
90
  * model, reusing `pairAcrossGateways` (never re-deriving the pairing logic).
89
- * Every row id is verbatim from the catalog (see `chooseRowId`) -- never
90
- * fabricated, and a direct row is never dropped even when its OpenRouter
91
- * twin is ambiguous.
91
+ * A direct row is never dropped even when its OpenRouter twin is ambiguous.
92
+ *
93
+ * Row ids are verbatim catalog ids whenever a real row carries them: a
94
+ * direct row keeps its own id, and an OpenRouter row collapses onto a
95
+ * direct twin only when `pairAcrossGateways` found exactly one. For a
96
+ * NON-divergent vendor with no direct twin at all, `chooseRowId` derives
97
+ * the bare form via `directFormIfSafe` -- SYNTHESISED, not verbatim, only
98
+ * when the vendor's namespace can't prove it invalid (issue 195). Measured
99
+ * 2026-08-24 against a real 601-row catalog: all 14 `deepseek` rows derive
100
+ * this way (empty namespace); `google` (19/69) and `openai` (51/175) rows
101
+ * used to derive the same unconditional way despite a populated,
102
+ * authoritative namespace omitting that specific id, and now stay
103
+ * OpenRouter-prefixed instead. Once a bare id IS synthesized (the deepseek
104
+ * case), it is safe for routing, but not for the reason an earlier version
105
+ * of this comment claimed: a bare id is NOT failure-routed direct-first-
106
+ * with-OpenRouter-fallback. gateway-router.js's auto-mode step 7 is
107
+ * `if (rq.keys[vendor] && hasForm(rq, 'direct'))`, and `hasForm` is
108
+ * `!req.gatewayIds || req.gatewayIds[gateway] !== undefined` -- a bare id
109
+ * carries no `gatewayIds` at all, so `hasForm(rq, 'direct')` is VACUOUSLY
110
+ * true on this path; the only real gate is `rq.keys[vendor]`. The fallback
111
+ * to OpenRouter is therefore KEY-ABSENCE-driven (no key for `vendor`), not
112
+ * a check that the direct form actually resolves -- that check only fires
113
+ * on the ALIAS path, where `gatewayIds` IS attached and `hasForm` can
114
+ * genuinely be false. It is not a catalog-confirmed direct id either way,
115
+ * and callers that need that distinction should consult
116
+ * `curated-models.js`'s `directFormProvenance()`.
92
117
  * @param {Array<{id:string,name:string,contextLength:(number|null)}>} catalog
93
118
  * @param {string} vendor
94
119
  * @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
@@ -109,7 +134,7 @@ function buildRows(catalog, vendor) {
109
134
  const isDirect = row.id.startsWith(directPrefix);
110
135
  const token = isDirect ? row.id.slice(directPrefix.length) : row.id.slice(orPrefix.length);
111
136
  const paired = pairAcrossGateways(vendor, token, catalogInfo);
112
- const chosenId = chooseRowId(vendor, isDirect, row, paired);
137
+ const chosenId = chooseRowId(vendor, isDirect, row, paired, catalogInfo);
113
138
  if (!chosenId || seenIds.has(chosenId)) { continue; }
114
139
  seenIds.add(chosenId);
115
140
 
@@ -124,7 +149,7 @@ function buildRows(catalog, vendor) {
124
149
  // pricing (`sourceRow`, which for a local/direct row IS the catalog row
125
150
  // itself) instead. Gated on `isLocal`, NOT on "no OpenRouter twin", so a
126
151
  // direct (non-local) row with no twin still renders `pricePerMInput:
127
- // null` (tests/provider-default-picker.test.js:59, pinned).
152
+ // null` (tests/provider-default-picker.test.js:60, pinned).
128
153
  const localPrice = isLocal ? pricePerMInputFrom(sourceRow) : null;
129
154
 
130
155
  rows.push({
@@ -140,22 +165,22 @@ function buildRows(catalog, vendor) {
140
165
 
141
166
  /**
142
167
  * Canonicalize `resolveTier`'s verbatim catalog-id output the same way row
143
- * ids are built (`chooseRowId`), so it can be matched against `rows`
144
- * exactly. `resolveTier` already prefers a real direct id when one matches
145
- * the tier pattern (`model-tiers.js`'s `pickForTier` tries the direct
146
- * namespace first), so this only has an effect when it fell back to an
147
- * OpenRouter id (no matching direct-namespace row at all). For
148
- * `DIVERGENT_VENDORS`, that OpenRouter id must stay OpenRouter-prefixed --
149
- * stripping it would fabricate a non-direct-callable dot-form id that no row
150
- * carries. Non-divergent vendors keep the pre-existing strip, safe because
151
- * their direct and OpenRouter ids are identical once the prefix is removed.
168
+ * ids are built (`chooseRowId`/`directFormIfSafe`), so it matches `rows`
169
+ * exactly. Only matters when `resolveTier` fell back to an OpenRouter id (no
170
+ * matching direct-namespace row for the tier). `directFormIfSafe` gates
171
+ * `DIVERGENT_VENDORS` itself and keeps that id OpenRouter-prefixed;
172
+ * non-divergent vendors go through the same `classifyModel`-guarded strip
173
+ * `chooseRowId` uses (issue 195) -- an unconditional strip here would
174
+ * produce a canonical id `rows` no longer carries whenever `directFormIfSafe`
175
+ * kept the row itself OpenRouter-prefixed, defeating the match below.
152
176
  * @param {string} vendor
153
177
  * @param {string|null} resolved verbatim catalog id from `resolveTier`, or null
178
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
154
179
  * @returns {string|null}
155
180
  */
156
- function canonicalizeResolved(vendor, resolved) {
181
+ function canonicalizeResolved(vendor, resolved, catalogInfo) {
157
182
  if (!resolved) { return null; }
158
- return DIVERGENT_VENDORS.has(vendor) ? resolved : toCanonicalDefault(resolved);
183
+ return directFormIfSafe(vendor, resolved, catalogInfo);
159
184
  }
160
185
 
161
186
  /**
@@ -173,7 +198,9 @@ function canonicalizeResolved(vendor, resolved) {
173
198
  function computePreselectedId(vendor, tier, catalog, rows) {
174
199
  const effectiveTier = tier || getCostTier();
175
200
  const resolved = resolveTier(vendor, effectiveTier, catalog);
176
- const canonical = canonicalizeResolved(vendor, resolved);
201
+ // F2 (B5): guard the same way applyProviderDefault does, so classifyModel
202
+ // never sees a non-array `models` regardless of what a caller passes.
203
+ const canonical = canonicalizeResolved(vendor, resolved, { models: Array.isArray(catalog) ? catalog : [] });
177
204
  if (canonical && rows.some(r => r.id === canonical)) { return canonical; }
178
205
 
179
206
  const priced = rows.filter(r => r.pricePerMInput !== null);
@@ -217,22 +244,35 @@ function buildProviderDefaultChoices(vendor, options = {}) {
217
244
  * `config.default` on first use. Read-modify-write, NO-CLOBBER -- preserves
218
245
  * an already-set `config.default` and every other existing alias/key.
219
246
  *
220
- * `chosenId` is a verbatim catalog id straight from `buildProviderDefaultChoices`
221
- * (via `chooseRowId`/`computePreselectedId`): a real direct id, or -- for a
222
- * `DIVERGENT_VENDORS` vendor with no direct twin -- an `openrouter/`-prefixed
223
- * id. **Never** run `toCanonicalDefault` on a divergent vendor's id: that
224
- * would strip the `openrouter/` prefix and fabricate a non-direct-callable
225
- * dot-form id no row actually carries (the exact bug just fixed in Task 4's
226
- * `chooseRowId`/`canonicalizeResolved`). Non-divergent vendors' direct and
227
- * OpenRouter ids are identical once the prefix is stripped, so
228
- * `toCanonicalDefault` is safe there.
247
+ * `chosenId` comes straight from `buildProviderDefaultChoices`: a real
248
+ * direct id; an `openrouter/`-prefixed id (`DIVERGENT_VENDORS`, or -- since
249
+ * issue 195 -- a non-divergent vendor whose bare form would classify
250
+ * `invalid`); or a bare id already SYNTHESISED via `directFormIfSafe`. By
251
+ * the time this function runs, `chosenId`'s prefix (or lack of one) IS the
252
+ * decision -- PERSISTENCE therefore uses `directFormIfProven`, not
253
+ * `directFormIfSafe`: it strips the `openrouter/` prefix only on POSITIVE
254
+ * evidence (`classifyModel` returns `valid` -- the bare id is an actual
255
+ * catalog row), never merely because the catalog couldn't disprove it.
256
+ *
257
+ * This matters because `directFormIfSafe`'s optimistic default is correct
258
+ * for LIST-BUILDING (an empty/absent catalog can't assert absence, so a
259
+ * bare guess is reasonable to offer) but wrong here: a catalog fetch that
260
+ * failed or came back empty (`catalog` omitted, or `[]`) must never be read
261
+ * as license to fabricate a direct id `chooseRowId` never actually offered
262
+ * -- that reintroduces the exact bug issue 195 fixed, silently, on every
263
+ * degraded fetch. `directFormIfProven` preserves `chosenId` verbatim
264
+ * whenever the catalog can't prove the bare form valid, so an empty/absent
265
+ * `catalog` option is inert (no canonicalization at all), not a fallback to
266
+ * the old unconditional strip.
229
267
  * @param {string} vendor e.g. 'anthropic'
230
- * @param {string} chosenId verbatim catalog id from the picker
231
- * @param {{seedDefaultIfAbsent?: boolean}} [options]
268
+ * @param {string} chosenId id from the picker (see above -- not always
269
+ * catalog-verbatim)
270
+ * @param {{seedDefaultIfAbsent?: boolean, catalog?: Array<{id:string}>}} [options]
232
271
  * @returns {{alias: string, setAsDefault: boolean}}
233
272
  */
234
- function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } = {}) {
235
- const storedId = DIVERGENT_VENDORS.has(vendor) ? chosenId : toCanonicalDefault(chosenId);
273
+ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
274
+ const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
275
+ const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
236
276
 
237
277
  const config = loadConfig() || {};
238
278
  if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
@@ -246,4 +286,12 @@ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } =
246
286
  return { alias: vendor, setAsDefault };
247
287
  }
248
288
 
249
- module.exports = { buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom };
289
+ // directFormIfSafe/directFormIfProven are re-exported (not just used
290
+ // internally) so a caller that wants the canonicalization primitives
291
+ // directly doesn't need to know they now live in model-canonicalization.js
292
+ // -- see tests/model-canonicalization.test.js for the identity pin proving
293
+ // this is a re-export, not a second, divergeable copy.
294
+ module.exports = {
295
+ buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom,
296
+ directFormIfSafe, directFormIfProven,
297
+ };
@@ -111,7 +111,7 @@ async function runProviderDefaultFlow(provider, options = {}) {
111
111
  chosenId = await promptForChoice(ask, print, choices);
112
112
  }
113
113
 
114
- const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
114
+ const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
115
115
  const summaryLine = `\`amicus start --model ${provider}\` → ${chosenId}` +
116
116
  (setAsDefault ? ', set as your default model' : '');
117
117
 
@@ -79,8 +79,8 @@ function resolveQuickPicks(catalog) {
79
79
  * Stripping the prefix there fabricates an id the direct API rejects, which
80
80
  * `amicus doctor` then reports as a stale alias. The row's own direct route is
81
81
  * used verbatim, falling back to the intact `openrouter/` form when the
82
- * catalog offered no direct pick. Mirrors the guard already used at
83
- * `provider-default-picker.js:82,143,220`.
82
+ * catalog offered no direct pick. Mirrors the DIVERGENT_VENDORS-first guard
83
+ * `model-canonicalization.js :: directFormIfSafe` uses internally.
84
84
  * @param {{vendorPath?:string, routes?:Object<string,string>}} pick
85
85
  * @returns {string|undefined}
86
86
  */
@@ -143,6 +143,30 @@ const REMEDIATION_HINTS = Object.freeze({
143
143
  */
144
144
  pruneSessionIndex:
145
145
  'amicus doctor --fix (removes sessions-index.json entries whose project no longer exists on disk — liveness-based, never by age)',
146
+
147
+ /**
148
+ * Fabricated bare alias (B3, council review of PR 198 / issue 195): v4.8.0
149
+ * could persist a `<vendor>/<model>` id no catalog row carries. `doctor
150
+ * --fix` rewrites ONLY the narrow, mechanically-unambiguous class -- an
151
+ * alias that classifies `invalid` on the `direct` gateway AND has an
152
+ * unambiguous OpenRouter twin (`pairAcrossGateways`, alias-audit.js's
153
+ * `findFabricatedAliasRepairs`) -- to that twin. Every other stale/drifted
154
+ * alias (typo, retired model, user-invented id) is left for `amicus
155
+ * models --check` instead, never guessed at.
156
+ */
157
+ repairFabricatedAlias:
158
+ 'amicus doctor --fix (rewrites a fabricated bare alias to its catalog-confirmed OpenRouter id — safe only for the narrow class doctor can prove; use `amicus models --check` for anything else)',
159
+
160
+ /**
161
+ * A3 (council review of PR 198): same repairable class as
162
+ * `repairFabricatedAlias` above, but the cached catalog `evaluateAliasesCheck`
163
+ * would repair FROM is itself stale (older than doctor's own `catalog`
164
+ * check's freshness window) -- a stale catalog can be missing rows that
165
+ * would make a "fabricated" id look repairable when it is merely unfetched,
166
+ * so the repair declines rather than write on unverified evidence.
167
+ */
168
+ repairFabricatedAliasStaleCatalog:
169
+ 'amicus models --refresh, then amicus doctor --fix (the alias looks fabricated but the cached catalog is stale — refresh it first so the repair rests on current data, not a possibly-incomplete snapshot)',
146
170
  });
147
171
 
148
172
  module.exports = REMEDIATION_HINTS;
@@ -8,6 +8,8 @@
8
8
  * split out so ./abort-result.js can depend on it without a circular require).
9
9
  */
10
10
  const { SCHEMA_VERSION } = require('./result-schema-version');
11
+ // v4.9 W13 Task A (PR #207 round 3, B3): the shared ttftMs honesty predicate.
12
+ const { isMeasuredTtft } = require('./ttft');
11
13
 
12
14
  /** Leg/run statuses that count as terminal for wave aggregation. */
13
15
  const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
@@ -70,6 +72,14 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
70
72
  durationMs,
71
73
  sessionDir,
72
74
  opencodeSessionId: metadata.opencodeSessionId || null,
75
+ // v4.9 W13 Task A (probe only, R12): time-to-first-token, sourced straight
76
+ // off `metadata` like `opencodeSessionId` above. ADDITIVE and emit-when-set
77
+ // (the pack/tag spread form below, not the `|| null` coercion above): an
78
+ // absent ttft means "no substantive tick was observed", which is neither
79
+ // zero nor null, so it must not be coerced into either. PR #207 round 3
80
+ // (B3): emit-when-VALID via the shared predicate — `metadata` is read off
81
+ // disk, so NaN/±Infinity/negatives/fractions all reach here. See ./ttft.js.
82
+ ...(isMeasuredTtft(metadata.ttftMs) ? { ttftMs: metadata.ttftMs } : {}),
73
83
  usage: usage !== null ? usage : (metadata.usage || null),
74
84
  ...(metadata.pack ? { pack: metadata.pack } : {}),
75
85
  ...(metadata.tag ? { tag: metadata.tag } : {}),
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @module utils/text-sanitize
3
+ * One third-party string, safe to render: no escapes, no bidi, one short line.
4
+ *
5
+ * EXTRACTED from src/utils/engine-log-parse.js (v4.9 W10 round 3). It was built
6
+ * there for the engine-log excerpt (round-2 review B2), but reviews B1+C2 put a
7
+ * SECOND caller on it — `utils/engine-skew.js` runs server-reported version
8
+ * strings through the same pass before they reach the stderr notice and the
9
+ * death-report clause. Leaving it in the parse module meant the skew detector
10
+ * had to depend on the log parser to sanitize a version number, which is a
11
+ * dependency edge that says nothing true about either module. Here, neither
12
+ * caller owns it and both just use it.
13
+ *
14
+ * `engine-log-parse.js` RE-EXPORTS both names, so every existing import path
15
+ * stays valid and the extraction pins can assert these are the SAME function
16
+ * objects rather than a second copy — the same shape as the round-2 splits.
17
+ *
18
+ * THE RULE, in one line: this is the ONLY sanitizer. A second implementation
19
+ * would be a second set of holes, and the holes are the point — every finding
20
+ * that has landed here (ANSI in round 2, bidi in round 3) was a class the
21
+ * previous pass could not see.
22
+ *
23
+ * PURE: no I/O, no throwing paths, no state.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ /** One short line: long enough for a real engine error, short enough to ride
29
+ * inside an error string that already carries the backstop's own sentence. */
30
+ const MAX_EXCERPT_CHARS = 200;
31
+
32
+ /** An ANSI escape sequence: CSI (`ESC [ … final`), OSC (`ESC ] … BEL/ST`), or a
33
+ * bare two-character escape. */
34
+ // eslint-disable-next-line no-control-regex
35
+ const ANSI_SEQUENCE = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-_])/g;
36
+ /** C0 and C1 control characters, DEL included. */
37
+ // eslint-disable-next-line no-control-regex
38
+ const CONTROL_CHAR = /[\u0000-\u001f\u007f-\u009f]/g;
39
+ /**
40
+ * Unicode BIDI controls: the embedding/override set, the isolates, the marks.
41
+ *
42
+ * Round-3 review A2. PRINTABLE-range code points, so the C0/C1 sweep above never
43
+ * saw them — and each reorders the characters that follow, letting a third
44
+ * party's error string rewrite the sentence it is quoted into (a RIGHT-TO-LEFT
45
+ * OVERRIDE renders the rest of the line backwards). Formatting, so DROPPED.
46
+ */
47
+ const BIDI_CONTROL = /[\u202a-\u202e\u2066-\u2069\u200e\u200f\u061c]/g;
48
+
49
+ /**
50
+ * One line, no control characters, no bidi controls, at most `maxChars`.
51
+ *
52
+ * SANITIZED, not just collapsed (round-2 review B2). This is quoted verbatim
53
+ * from a third party's text into output that reaches terminals and MCP
54
+ * surfaces; engine lines can embed provider output, colour codes and all.
55
+ * Escape sequences and bidi controls are DROPPED (formatting: removing them
56
+ * keeps `<esc>[31mred<esc>[0m` as `red`, not as spaced-out text); every
57
+ * remaining control byte becomes a space, which the collapse below tidies.
58
+ *
59
+ * THE CAP IS A PARAMETER because this is the house sanitizer for any
60
+ * third-party string we render, not only log excerpts: `utils/engine-skew.js`
61
+ * runs SERVER-SUPPLIED version strings through it at a shorter cap before they
62
+ * enter the stderr notice and the death-report clause (round-3 reviews B1+C2).
63
+ * One sanitizer, one dialect — a second implementation is a second set of holes.
64
+ * @param {*} text
65
+ * @param {number} [maxChars]
66
+ * @returns {string}
67
+ */
68
+ function collapseExcerpt(text, maxChars = MAX_EXCERPT_CHARS) {
69
+ const oneLine = String(text === undefined || text === null ? '' : text)
70
+ .replace(ANSI_SEQUENCE, '')
71
+ .replace(BIDI_CONTROL, '')
72
+ .replace(CONTROL_CHAR, ' ')
73
+ .replace(/\s+/g, ' ').trim();
74
+ if (oneLine.length <= maxChars) { return oneLine; }
75
+ return `${oneLine.slice(0, maxChars - 1)}…`;
76
+ }
77
+
78
+ module.exports = {
79
+ collapseExcerpt,
80
+ MAX_EXCERPT_CHARS,
81
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @module utils/ttft
3
+ * The one honesty predicate for the time-to-first-token probe (v4.9 W13).
4
+ *
5
+ * ⚠️ The JSDoc leads this file, ahead of `'use strict'`, matching
6
+ * `utils/text-sanitize.js` / `utils/engine-skew.js` / `utils/alias-shadow.js`:
7
+ * `scripts/generate-docs.js` only reads a block comment that starts at byte
8
+ * zero, so a `// path` line above it would leave this module's CLAUDE.md row
9
+ * blank the way `utils/result-schema.js`'s already is.
10
+ *
11
+ * `ttftMs` is produced once — in `src/headless.js`'s poll loop, as a
12
+ * `Date.now()` delta — and then passes four EMIT GATES on its way to a
13
+ * document: `headless.js`'s three returns, `sidecar/fanout-leg.js`'s leg patch,
14
+ * `utils/result-schema.js :: buildRunResult`, and
15
+ * `council/run-stats-entry.js :: buildRunStatsEntry`. Every gate used to spell
16
+ * its own `typeof x === 'number'` test, which is four chances to disagree and
17
+ * four ways to publish a value both schemas forbid.
18
+ *
19
+ * ⚠️ `typeof` is not the schema's contract. `schemas/run.schema.json` and
20
+ * `schemas/council-tally.schema.json` both declare this field
21
+ * `integer, minimum 0`, and `typeof` admits four families that violate it:
22
+ * · NaN — `JSON.stringify` writes it as `null`, so the artifact breaks its own
23
+ * schema while LOOKING like the honest absence the emit-when-set rule means.
24
+ * · ±Infinity — not hypothetical from an artifact: `JSON.parse('1e999')` is
25
+ * `Infinity` (MEASURED), and it also serializes to `null`.
26
+ * · Negative — the probe is a wall-clock delta, so a backward jump (NTP
27
+ * correction, a VM resuming from suspend, a manual clock set) between the
28
+ * clock origin and the first substantive poll measures below zero.
29
+ * · Fractional — a hand-edited leg document.
30
+ *
31
+ * ⚠️ DROP, DO NOT CLAMP. A skewed −5 s reading clamped to `0` would publish
32
+ * "first token inside the first poll" — the most consequential value in the
33
+ * distribution the C2 derivation will read — for a leg that measured nothing of
34
+ * the kind. Emit-when-VALID is the same discipline as emit-when-set: absence
35
+ * already means "no honest measurement was made", and a dishonest number is
36
+ * exactly that. `0` itself stays a real, emittable measurement.
37
+ *
38
+ * ⚠️ Three of the four gates import this. The fourth,
39
+ * `council/run-stats-entry.js`, is pinned REQUIRE-FREE (P3,
40
+ * tests/council/run-stats-entry.test.js — the pin fires on the character
41
+ * sequence anywhere in that file, comments included) so require-free consumers
42
+ * can import it, so it spells the same expression by hand. The structural pins
43
+ * that keep the hand-spelled copy in step live in that same test file.
44
+ */
45
+
46
+ 'use strict';
47
+
48
+ /**
49
+ * Is this a real time-to-first-token measurement, fit to ride a document?
50
+ * @param {*} value
51
+ * @returns {boolean} true only for a non-negative integer millisecond count.
52
+ */
53
+ function isMeasuredTtft(value) {
54
+ return Number.isInteger(value) && value >= 0;
55
+ }
56
+
57
+ module.exports = { isMeasuredTtft };
@@ -17,6 +17,16 @@
17
17
  * stays intact inside the fence. It must NOT be applied to CLI --json stdout
18
18
  * (the byte-parseable programmatic channel) or amicus_read mode=metadata —
19
19
  * structured data a caller parses, where wrapping would break the contract.
20
+ *
21
+ * This module ALSO hosts the OUTBOUND family's tag neutralizer
22
+ * (`defangOutboundFenceTags`, PR #200 tails B2/C2; close tags only until PR
23
+ * #206 round 3 B3b took it to open tags too). It lives here rather than
24
+ * beside either outbound builder because there are two of them — the council
25
+ * briefing tail (src/council/briefings-stage2-task.js :: fenceBriefing) and the
26
+ * parent-conversation section (src/prompt-builder.js :: buildContextSection) —
27
+ * in two directories that do not import each other, and this is the one module
28
+ * in the tree whose subject is fences and which both can reach without either
29
+ * depending on the other.
20
30
  */
21
31
  'use strict';
22
32
 
@@ -36,4 +46,104 @@ ${body}
36
46
  </untrusted_sidecar_output>`;
37
47
  }
38
48
 
39
- module.exports = { fenceSidecarOutput };
49
+ /**
50
+ * Every OUTBOUND house fence's tag name. ONE list, deliberately: both surfaces
51
+ * run the neutralizer over their whole vocabulary, not just their own tag, so a
52
+ * fence added to the family is covered everywhere by editing this array — and a
53
+ * body that carries the SIBLING surface's tags (either end, since round 3)
54
+ * cannot smuggle them through the surface that does not happen to emit them.
55
+ * @see src/council/briefings-stage2-task.js :: BRIEFING_FENCE_CLOSE
56
+ * @see src/prompt-builder.js :: buildContextSection
57
+ */
58
+ const OUTBOUND_FENCE_TAGS = ['council_briefing', 'previous_conversation'];
59
+
60
+ /**
61
+ * `</council_briefing>` / `</previous_conversation>`, case-insensitively and
62
+ * tolerating whitespace ANYWHERE inside the angle brackets — including between
63
+ * the `<` and the `/` (round 3, C1: the round-2 pattern required those two to
64
+ * be adjacent, so `< /council_briefing>` rode through untouched, and a reading
65
+ * model honours that spelling exactly as readily as the tight one). The slash
66
+ * and its surrounding space are INSIDE the captured group, so the author's own
67
+ * spelling survives into the defanged form and nothing is hidden.
68
+ * ⚠️ Module-level and `g`-flagged: safe ONLY because its sole use is
69
+ * `String#replace`, which resets `lastIndex` around the call. A `.test()` on
70
+ * this object would carry `lastIndex` between calls and skip matches — build a
71
+ * fresh regex if one is ever needed. Same warning for the OPEN pattern below.
72
+ */
73
+ const OUTBOUND_FENCE_CLOSE_RE =
74
+ new RegExp(`<(\\s*\\/\\s*(?:${OUTBOUND_FENCE_TAGS.join('|')})\\s*)>`, 'gi');
75
+
76
+ /**
77
+ * The same families' OPEN tags, attributes and all (round 3, B3b).
78
+ *
79
+ * `\\b` after the name is what keeps this off tags that merely START with a
80
+ * house name — `<council_briefingx>` is somebody else's markup — and
81
+ * `[^<>]*` carries whatever attributes the author wrote through into the
82
+ * escaped form. It cannot match a CLOSE tag: `\\s*` does not consume the `/`.
83
+ */
84
+ const OUTBOUND_FENCE_OPEN_RE =
85
+ new RegExp(`<(\\s*(?:${OUTBOUND_FENCE_TAGS.join('|')})\\b[^<>]*)>`, 'gi');
86
+
87
+ /**
88
+ * Neutralize any outbound house fence tag inside a body about to be embedded in
89
+ * one (PR #200 tails B2/C2; round 3 B3b/C1 widened it from closes to both ends).
90
+ *
91
+ * Without this, untrusted text that contains the close tag ends the fence early
92
+ * in the reading model's eyes and everything after it reads as the engine
93
+ * speaking — the fence's entire purpose, undone by a string the author types.
94
+ * The replacement is an entity escape (`&lt;/council_briefing&gt;`), so the
95
+ * text stays legible as what the author wrote while no longer being a tag:
96
+ * nothing is deleted and nothing is silently swallowed.
97
+ *
98
+ * OPEN TAGS TOO, and round 2's reason for skipping them was wrong (B3b). That
99
+ * reason — "an open tag inside a fence cannot escape it" — holds for a STRICT
100
+ * parser and fails for the reader this fence is actually addressed to. A model
101
+ * that balances tags reads the attacker's `<council_briefing …>` and the
102
+ * engine's REAL `</council_briefing>` as one pair: the attacker's tag gets the
103
+ * close, and the engine's fence is left unterminated. Everything after the
104
+ * attacker's open reads as fenced material, and everything the ENGINE writes
105
+ * after the real close reads as still inside a fence. That is the same escape
106
+ * one tag along. MEASURED before widening (W12, re-verified round 3): nothing
107
+ * in the tree PARSES these tags — the only occurrences outside test assertions
108
+ * are the two producers' own literals — so escaping opens breaks no consumer.
109
+ *
110
+ * ⚠️ THE BOUNDARY IS SOFT, and this is a disclosure, not a caveat. An entity
111
+ * escape is a convention about how a READING MODEL should interpret bytes, not
112
+ * a parser guarantee: some models decode `&lt;/council_briefing&gt;` back to
113
+ * the tag while reading, and a model that does is not fenced by this. What this
114
+ * buys is defense in depth — it removes the LITERAL tag, so the escape stops
115
+ * being free and starts depending on a decoding step the attacker does not
116
+ * control. The load-bearing protection is still the preamble both fences carry
117
+ * (the enclosed text is reference material, not instructions); this hardens it,
118
+ * and does not replace it. Deleting the tag outright would be a stronger
119
+ * boundary and a worse product — the reader would silently lose text the author
120
+ * wrote, which is the failure the whole fence exists to avoid.
121
+ *
122
+ * Total over non-strings (returns its argument unchanged): callers hand it
123
+ * whatever they were given, and a missing body must never be a throw inside a
124
+ * prompt builder.
125
+ * @param {string} text the untrusted body about to be fenced
126
+ * @returns {string} the same bytes when it carries no house tag
127
+ */
128
+ function defangOutboundFenceTags(text) {
129
+ if (typeof text !== 'string') { return text; }
130
+ // Closes first: once a close is escaped its `<` is gone, so the OPEN pattern
131
+ // cannot see it. (It could not match one anyway — see the pattern's note —
132
+ // but the order makes that independent of the pattern staying that way.)
133
+ return text
134
+ .replace(OUTBOUND_FENCE_CLOSE_RE, '&lt;$1&gt;')
135
+ .replace(OUTBOUND_FENCE_OPEN_RE, '&lt;$1&gt;');
136
+ }
137
+
138
+ // FUNCTIONS FIRST, constant last — the PR 201 round-2 workaround for
139
+ // `scripts/generate-docs-helpers.js :: extractExports`, which renders EVERY
140
+ // export as `name()` and keeps only the first five. Ordering conceals a
141
+ // constant behind that cap; MEASURED here, it cannot, because this module
142
+ // exports three names and the cap never fires. So CLAUDE.md's Key Exports cell
143
+ // reads `OUTBOUND_FENCE_TAGS()` for what is an array — a known-defect instance
144
+ // of the generator bug, not a claim this module makes, and the second one below
145
+ // the workaround's floor after `utils/text-sanitize.js`. It goes away with the
146
+ // generator ruling filed in BACKLOG.md; padding the export list to five to hide
147
+ // it would not be a fix, and the list itself must stay exported —
148
+ // tests/utils/outbound-fence-defang.test.js reads it as the LIVE vocabulary.
149
+ module.exports = { fenceSidecarOutput, defangOutboundFenceTags, OUTBOUND_FENCE_TAGS };
@@ -5,10 +5,11 @@
5
5
  * (src/headless.js `formatFoldOutput`, exported from src/headless.js and
6
6
  * re-exported from src/index.js) — byte-for-byte the same **7-line** head:
7
7
  * marker, Model, Session, Client, CWD, Mode, `---`. ⚠️ v4.4.1 DOC-5: this said
8
- * "8-line" for two releases. Line 8 (`VERDICT:`) is council's OWN addition and
8
+ * "8-line" for two releases. Line 8 (the terminal line — `VERDICT:` on a review
9
+ * run, `ANSWER:` on a task one since v4.9 W8) is council's OWN addition and
9
10
  * has no counterpart in formatFoldOutput, whose 8th element is the summary
10
11
  * body. Only the first 7 lines are the shared contract; anyone changing the
11
- * shared format must sync those and leave `VERDICT:` alone.
12
+ * shared format must sync those and leave the terminal line alone.
12
13
  * src/headless.js is the SOURCE OF TRUTH; keep the head in sync with it.
13
14
  * (Line numbers deliberately omitted — the previous `:775-789`/`:797` citation
14
15
  * had drifted by ~500 lines.) The duplication is deliberate:
@@ -24,7 +25,7 @@
24
25
  * The chair body is UNTRUSTED model text: it passes through stripFoldMarkers
25
26
  * before embedding, so chair prose containing a marker can never truncate or
26
27
  * spoof the fold (the exact hazard the nonce closure exists for).
27
- * Degradation mirrors the engine's ladder: no chair → VERDICT: none + tally
28
+ * Degradation mirrors the engine's ladder: no chair → `<terminal>: none` + tally
28
29
  * summary; pre-tally → stage/status summary. Never blocked, always labeled.
29
30
  */
30
31
  'use strict';
@@ -63,14 +64,34 @@ function buildFoldText(o) {
63
64
  // amicus_verdict MCP path types overallVerdict as a bare z.string().nullable()
64
65
  // — mcp-tools.js :: amicus_verdict), so a multi-line or marker-bearing value
65
66
  // must never reach the head verbatim: an embedded '\n' would shift every line below
66
- // VERDICT: (a raw string containing '\n' becomes several elements once the
67
+ // the terminal line (a raw string containing '\n' becomes several elements once the
67
68
  // head array is '\n'-joined), and an embedded marker could spoof the fold.
68
- // Safe on the shipped engine path (parseChairVerdict returns a canonical
69
- // CHAIR_VERDICTS phrase) this is defense-in-depth, not a fix for a real
69
+ // Safe on the shipped engine path (`run-chair.js :: runChair` parses the chair's
70
+ // terminal line with `parse-stage2.js :: parseChairTerminal`, which returns a
71
+ // canonical phrase of the run's OWN scale — CHAIR_VERDICTS on a review run,
72
+ // CHAIR_ANSWERS on a task one) — this is defense-in-depth, not a fix for a real
70
73
  // producer.
74
+ // ⚠️ DE-ROT (v4.9 W8): this said "parseChairVerdict returns a canonical
75
+ // CHAIR_VERDICTS phrase", true only until W7 added the second scale — after
76
+ // which it named the wrong parser for every task run.
71
77
  const overall = verdict && verdict.overallVerdict
72
78
  ? stripFoldMarkers(String(verdict.overallVerdict)).replace(/[\r\n]+/g, ' ').trim()
73
79
  : null;
80
+ // v4.9 W8: the terminal line names the SCALE its phrase belongs to. The two
81
+ // scales are disjoint by pinned construction (tests/council/chair-scale-drift.test.js),
82
+ // so labelling a CHAIR_ANSWERS phrase `VERDICT:` asserts a scale it is not on.
83
+ // `intent` is emit-when-'task' (verdict.js :: buildVerdict, the W5 ruling), so
84
+ // absence — and an explicit 'review' — keep the review fold byte-identical.
85
+ // ⚠️ v4.9 fix round 2 (council B2): `run.intent` is the SECOND carrier, and it
86
+ // is what covers the DEGRADED fold — the one a user reaches for precisely when
87
+ // a task run went wrong. With no verdict.json (or a parse-failed one) there is
88
+ // no verdict.intent, and the fold headed `VERDICT: none` on a run that was
89
+ // never on that scale. `o.run` is the parsed run.json, which checkpoints
90
+ // `intent: 'task'` at start (src/council/run.js :: runCouncil):
91
+ // electron/ipc-workspace.js passes `run: detail.run` and run-detail.js ::
92
+ // getRunDetail reads it straight from `<runDir>/run.json`.
93
+ // PR #200 round-5 B3: parens on the whole disjunction — behaviour-identical.
94
+ const terminalLabel = ((verdict && verdict.intent === 'task') || run.intent === 'task') ? 'ANSWER' : 'VERDICT';
74
95
  const tierCounts = (verdict && verdict.tierCounts) || (tally && tally.tierCounts) || null;
75
96
  const cost = run.usage && run.usage.cost ? run.usage.cost : null;
76
97
 
@@ -82,7 +103,7 @@ function buildFoldText(o) {
82
103
  `CWD: ${o.project}`,
83
104
  'Mode: council',
84
105
  '---',
85
- `VERDICT: ${overall || 'none'}`,
106
+ `${terminalLabel}: ${overall || 'none'}`,
86
107
  ];
87
108
  if (tierCounts) {
88
109
  head.push(tierLine(tierCounts));