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
@@ -136,48 +136,85 @@ function fetchModelsFromProvider(provider, key) {
136
136
  }
137
137
 
138
138
  /**
139
- * Perform the HTTPS fetch + normalize for a single configured provider.
140
- * Resolves to `[]` on any non-200 response, network error, timeout, or parse error.
139
+ * Perform the HTTPS fetch + normalize for a single configured provider,
140
+ * REPORTING why it failed (issue #209). The four failure modes used to
141
+ * collapse to a bare `[]`, which made a rejected fetch indistinguishable from
142
+ * a provider that legitimately serves no models -- and that ambiguity is what
143
+ * lets `classifyModel` return 'unknown' for a namespace whose fetch was
144
+ * actually refused (see #208).
141
145
  * @param {string} provider - Key into PROVIDER_FETCH_CONFIG
142
146
  * @param {string} key - API key
143
- * @returns {Promise<Array>} Normalized model rows, or [] on any failure
147
+ * @returns {Promise<{rows: Array, failure: {reason: string, status?: number, detail?: string}|null}>}
144
148
  */
145
- function fetchViaConfig(provider, key) {
149
+ function fetchViaConfigDetailed(provider, key) {
146
150
  const config = PROVIDER_FETCH_CONFIG[provider];
147
151
  const url = config.buildUrl ? config.buildUrl(key) : config.url;
148
152
  const headers = config.authHeader(key);
149
153
 
150
154
  return new Promise((resolve) => {
151
155
  let chunks = '';
156
+ const ok = (rows) => resolve({ rows, failure: null });
157
+ const fail = (failure) => resolve({ rows: [], failure });
158
+
152
159
  const timer = setTimeout(() => {
153
160
  req.destroy();
154
- resolve([]);
161
+ fail({ reason: 'timeout', detail: `no response within ${FETCH_TIMEOUT_MS}ms` });
155
162
  }, FETCH_TIMEOUT_MS);
156
163
 
157
164
  const req = https.get(url, { headers }, (res) => {
158
165
  if (res.statusCode !== 200) {
159
166
  clearTimeout(timer);
160
167
  res.on('data', () => {});
161
- res.on('end', () => resolve([]));
168
+ res.on('end', () => fail({ reason: 'http-status', status: res.statusCode }));
162
169
  return;
163
170
  }
164
171
  res.on('data', (chunk) => { chunks += chunk; });
165
172
  res.on('end', () => {
166
173
  clearTimeout(timer);
167
174
  try {
168
- resolve(config.normalize(chunks));
169
- } catch (_err) {
170
- resolve([]);
175
+ ok(config.normalize(chunks));
176
+ } catch (err) {
177
+ fail({ reason: 'parse-error', detail: err.message });
171
178
  }
172
179
  });
173
180
  });
174
- req.on('error', () => {
181
+ req.on('error', (err) => {
175
182
  clearTimeout(timer);
176
- resolve([]);
183
+ fail({ reason: 'network-error', detail: err.message });
177
184
  });
178
185
  });
179
186
  }
180
187
 
188
+ /**
189
+ * Rows-only view of `fetchViaConfigDetailed`, preserving the historical
190
+ * contract (`[]` on any failure) for existing callers.
191
+ * @param {string} provider @param {string} key @returns {Promise<Array>}
192
+ */
193
+ function fetchViaConfig(provider, key) {
194
+ return fetchViaConfigDetailed(provider, key).then(r => r.rows);
195
+ }
196
+
197
+ /**
198
+ * Per-provider fetch WITH failure reporting. Mirrors
199
+ * `fetchModelsFromProvider`'s special cases exactly:
200
+ * - anthropic without a key: the hardcoded floor, no network, NOT a failure.
201
+ * - anthropic with a key that yields nothing: floor rows, failure reported.
202
+ * - unknown provider: no rows, not a failure (nothing was attempted).
203
+ * @param {string} provider @param {string} key
204
+ * @returns {Promise<{rows: Array, failure: object|null}>}
205
+ */
206
+ function fetchModelsFromProviderDetailed(provider, key) {
207
+ const floor = () => ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }));
208
+ if (provider === 'anthropic') {
209
+ if (!key) { return Promise.resolve({ rows: floor(), failure: null }); }
210
+ return fetchViaConfigDetailed('anthropic', key).then(({ rows, failure }) =>
211
+ (rows.length > 0 ? { rows, failure: null } : { rows: floor(), failure }));
212
+ }
213
+ const config = PROVIDER_FETCH_CONFIG[provider];
214
+ if (!config) { return Promise.resolve({ rows: [], failure: null }); }
215
+ return fetchViaConfigDetailed(provider, key);
216
+ }
217
+
181
218
  /** Providers to fetch: every keyed provider + openrouter (keyless-capable) + anthropic. */
182
219
  function providersToFetch(keys) {
183
220
  const set = new Set(Object.keys(keys).filter(p => keys[p]));
@@ -190,12 +227,16 @@ function providersToFetch(keys) {
190
227
  * Fetch models from all providers that have keys configured; openrouter is
191
228
  * always included (keyless public endpoint) as is anthropic (hardcoded list).
192
229
  * @param {Object<string, string>} keys - Map of provider → API key string
193
- * @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: object|null}>>} Combined model list
230
+ * @returns {Promise<{rows: Array, failures: Array<{provider: string, reason: string, status?: number, detail?: string}>}>}
194
231
  */
195
- async function fetchAllModels(keys) {
232
+ async function fetchAllModelsDetailed(keys) {
196
233
  const providers = providersToFetch(keys);
197
- const results = await Promise.all(providers.map(p => fetchModelsFromProvider(p, keys[p] || '')));
198
- const rows = results.flat();
234
+ const results = await Promise.all(providers.map(p =>
235
+ fetchModelsFromProviderDetailed(p, keys[p] || '').then(r => ({ provider: p, ...r }))));
236
+ const rows = results.flatMap(r => r.rows);
237
+ const failures = results
238
+ .filter(r => r.failure)
239
+ .map(r => ({ provider: r.provider, ...r.failure }));
199
240
  // v4.2 §4.4: append local-provider rows via the scheme-aware probe (5s, [] on failure).
200
241
  try {
201
242
  const { getLocalProviders } = require('./local-providers');
@@ -205,7 +246,17 @@ async function fetchAllModels(keys) {
205
246
  listLocalModels(e, { timeoutMs: 5000, bearer: e.apiKeyEnv ? process.env[e.apiKeyEnv] : undefined })));
206
247
  for (const r of localResults) { rows.push(...r); }
207
248
  } catch (_err) { /* local rows are best-effort — never break the cloud catalog */ }
208
- return rows;
249
+ return { rows, failures };
250
+ }
251
+
252
+ /**
253
+ * Rows-only view of `fetchAllModelsDetailed` — the historical signature, kept
254
+ * so existing callers and their tests are unaffected.
255
+ * @param {Object<string, string>} keys
256
+ * @returns {Promise<Array>} Combined model list
257
+ */
258
+ async function fetchAllModels(keys) {
259
+ return (await fetchAllModelsDetailed(keys)).rows;
209
260
  }
210
261
 
211
262
  /**
@@ -236,6 +287,8 @@ function groupModelsByFamily(models) {
236
287
  module.exports = {
237
288
  fetchModelsFromProvider,
238
289
  fetchAllModels,
290
+ fetchAllModelsDetailed,
291
+ fetchModelsFromProviderDetailed,
239
292
  providersToFetch,
240
293
  groupModelsByFamily,
241
294
  ANTHROPIC_MODELS,
@@ -60,7 +60,8 @@ function compareShortlistRows(a, b) {
60
60
 
61
61
  /**
62
62
  * @param {string} vendor e.g. 'deepseek'
63
- * @param {{catalog?: Array<object>, recommendedId?: string, limit?: number}} [options]
63
+ * @param {{catalog?: Array<object>, recommendedId?: string, limit?: number,
64
+ * providerFailures?: Array<{provider:string}>}} [options]
64
65
  * @returns {{recommendedId: (string|null), suggested: Array<object>,
65
66
  * rest: Array<object>, total: number}}
66
67
  */
@@ -69,7 +70,9 @@ function buildModelShortlist(vendor, options = {}) {
69
70
  const limit = Number.isInteger(options.limit) && options.limit > 0
70
71
  ? options.limit : SHORTLIST_LIMIT;
71
72
 
72
- const { preselectedId, rows } = buildProviderDefaultChoices(vendor, { catalog });
73
+ const { preselectedId, rows } = buildProviderDefaultChoices(vendor, {
74
+ catalog, providerFailures: options.providerFailures,
75
+ });
73
76
  if (!rows || rows.length === 0) {
74
77
  return { recommendedId: null, suggested: [], rest: [], total: 0 };
75
78
  }
@@ -118,9 +118,11 @@ function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
118
118
  * @param {string} vendor
119
119
  * @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
120
120
  */
121
- function buildRows(catalog, vendor) {
121
+ function buildRows(catalog, vendor, providerFailures) {
122
122
  const byId = new Map(catalog.filter(r => r && typeof r.id === 'string').map(r => [r.id, r]));
123
- const catalogInfo = { models: catalog };
123
+ // issue 208: providerFailures MUST ride along -- this rebuilds catalogInfo from a bare
124
+ // array, so dropping it leaves directFormIfSafe's namespace gate dead in production.
125
+ const catalogInfo = { models: catalog, providerFailures: providerFailures || [] };
124
126
  const directPrefix = `${vendor}/`;
125
127
  const orPrefix = `openrouter/${vendor}/`;
126
128
  // Hoisted: `vendor` is fixed for the whole call, so this is decided once
@@ -229,7 +231,7 @@ function buildProviderDefaultChoices(vendor, options = {}) {
229
231
  if (typeof vendor !== 'string' || !vendor) { return { preselectedId: null, rows: [] }; }
230
232
 
231
233
  const catalog = Array.isArray(options.catalog) ? options.catalog : [];
232
- const rows = buildRows(catalog, vendor);
234
+ const rows = buildRows(catalog, vendor, options.providerFailures);
233
235
  if (rows.length === 0) { return { preselectedId: null, rows: [] }; }
234
236
 
235
237
  const preselectedId = computePreselectedId(vendor, options.tier, catalog, rows);
@@ -271,6 +273,7 @@ function buildProviderDefaultChoices(vendor, options = {}) {
271
273
  * @returns {{alias: string, setAsDefault: boolean}}
272
274
  */
273
275
  function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
276
+ // Council C4 (PR 215): NO providerFailures -- directFormIfProven strips only on POSITIVE evidence, so a failed/empty namespace ('unknown') already returns chosenId untouched.
274
277
  const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
275
278
  const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
276
279
 
@@ -10,7 +10,8 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { getFamilies, toDefaultAliases, toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
13
+ const { getFamilies, toDefaultAliases, DIVERGENT_VENDORS } = require('./curated-models');
14
+ const { directFormIfSafe } = require('./model-canonicalization');
14
15
 
15
16
  const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
16
17
 
@@ -84,12 +85,20 @@ function resolveQuickPicks(catalog) {
84
85
  * @param {{vendorPath?:string, routes?:Object<string,string>}} pick
85
86
  * @returns {string|undefined}
86
87
  */
87
- function toStorableRoute(pick) {
88
+ function toStorableRoute(pick, catalogInfo) {
88
89
  const routes = (pick && pick.routes) || {};
89
90
  if (pick && DIVERGENT_VENDORS.has(pick.vendorPath)) {
90
91
  return routes[pick.vendorPath] || routes.openrouter;
91
92
  }
92
- return toCanonicalDefault(routes.openrouter || Object.values(routes)[0]);
93
+ const route = routes.openrouter || Object.values(routes)[0];
94
+ if (!route) { return undefined; }
95
+ // issue 214 remedy 1: this value is PERSISTED (sidecar/setup.js writes it into
96
+ // config.aliases; toLiveSeedAliases seeds a fresh config with it), so it must
97
+ // not be a blind prefix strip. directFormIfSafe keeps the optimism for a
98
+ // namespace that was never fetched while refusing for one the catalog
99
+ // disproves OR whose fetch was rejected -- the gap #208 closed on the picker
100
+ // path and left open here.
101
+ return directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
93
102
  }
94
103
 
95
104
  /**
@@ -98,15 +107,44 @@ function toStorableRoute(pick) {
98
107
  * overlaid value is not a raw prefix strip.
99
108
  * @returns {Object<string,string>}
100
109
  */
101
- function toLiveSeedAliases(catalog) {
110
+ function toLiveSeedAliases(catalogOrInfo) {
111
+ // Accepts the bare models array (historical callers) or a full catalogInfo.
112
+ // issue 214: the evidence was always handed in and then discarded.
113
+ const info = Array.isArray(catalogOrInfo)
114
+ ? { models: catalogOrInfo }
115
+ : (catalogOrInfo || { models: [] });
102
116
  const seeds = toDefaultAliases();
103
- for (const r of resolveQuickPicks(catalog || [])) {
117
+ for (const r of resolveQuickPicks(info.models || [])) {
104
118
  if (r.source === 'live' && r.routes.openrouter) {
105
- const stored = toStorableRoute(r);
119
+ const stored = toStorableRoute(r, info);
106
120
  if (stored) { seeds[r.alias] = stored; }
107
121
  }
108
122
  }
109
123
  return seeds;
110
124
  }
111
125
 
112
- module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
126
+
127
+ /**
128
+ * Per-provider SAFE storable form for a resolved quick pick (issue 214).
129
+ *
130
+ * The wizard renderer used to derive this itself, via a hand-copy of
131
+ * `stripGatewayPrefix` (`toBareIfDirect`) that dropped both of the real
132
+ * primitive's guards: it stripped `openrouter/` for DIVERGENT_VENDORS
133
+ * (fabricating anthropic's dot id, which the direct API rejects) and stripped
134
+ * for a namespace whose fetch had failed. The renderer cannot `require()`, so
135
+ * the decision is made here -- once, with the catalog in hand -- and shipped
136
+ * to the page as data.
137
+ * @param {{vendorPath: string, routes: Object<string,string>}} pick
138
+ * @param {{models: Array<{id:string}>, providerFailures?: Array<{provider:string}>}} catalogInfo
139
+ * @returns {Object<string,string>} provider -> safe storable id
140
+ */
141
+ function canonicalRoutesFor(pick, catalogInfo) {
142
+ const out = {};
143
+ const routes = (pick && pick.routes) || {};
144
+ for (const [provider, route] of Object.entries(routes)) {
145
+ out[provider] = directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
146
+ }
147
+ return out;
148
+ }
149
+
150
+ module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
@@ -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 } : {}),
@@ -221,7 +231,8 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
221
231
  * probeSkipped?: string|null}} opts
222
232
  */
223
233
  function buildAuditDoc({
224
- stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null
234
+ stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null,
235
+ providerFailures = []
225
236
  }) {
226
237
  return {
227
238
  schemaVersion: SCHEMA_VERSION,
@@ -236,6 +247,11 @@ function buildAuditDoc({
236
247
  probeCount: probe.length,
237
248
  probe,
238
249
  probeSkipped,
250
+ // issue 209: providers ATTEMPTED and REJECTED for the catalog in hand. An
251
+ // empty namespace is otherwise indistinguishable from a provider that
252
+ // genuinely serves nothing.
253
+ providerFailuresCount: providerFailures.length,
254
+ providerFailures,
239
255
  };
240
256
  }
241
257
 
@@ -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));