auxilo-mcp 0.9.26 → 0.9.28

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.
@@ -32,6 +32,12 @@ const fs = require('fs');
32
32
  const path = require('path');
33
33
  const os = require('os');
34
34
  const crypto = require('crypto');
35
+ const { invocationGate } = require('./route-binding.js');
36
+
37
+ const KNOWN_DISALLOWED_ITEM_TYPES = Object.freeze([
38
+ 'command_execution', 'file_change', 'mcp_tool_call',
39
+ 'collab_tool_call', 'web_search', 'dynamic_tool_call',
40
+ ]);
35
41
 
36
42
  const { SCRUBBED_CLIENT_ENV_VARS } = require('./claude-code.js');
37
43
 
@@ -264,12 +270,12 @@ function makeOutputLocation(opts, mode) {
264
270
 
265
271
  function classifySpawnError(error, bin) {
266
272
  if (error && error.code === 'ENOENT') {
267
- return { reasonCode: 'cli-not-installed', reason: `codex binary not found (${bin}) — run \`npm install -g @openai/codex\` or the platform's install path` };
273
+ return { reasonCode: 'cli-not-installed', reason: 'codex binary not found' };
268
274
  }
269
275
  if (error && (error.code === 'ETIMEDOUT' || error.code === 'ETIMEOUT')) {
270
- return { reasonCode: 'cli-timeout', reason: `codex exec timed out (${bin}): ${error.message}` };
276
+ return { reasonCode: 'cli-timeout', reason: 'codex exec timed out' };
271
277
  }
272
- return { reasonCode: 'unknown', reason: `spawn failed (${bin}): ${error ? error.message : 'no process result'}` };
278
+ return { reasonCode: 'unknown', reason: 'codex spawn failed' };
273
279
  }
274
280
 
275
281
  /**
@@ -304,6 +310,7 @@ function invoke(opts, mode) {
304
310
  usage: null,
305
311
  reason: 'codex CLI is not authenticated in this context (run `codex login` once); skipping deterministic extraction',
306
312
  reasonCode: 'cli-unauthenticated',
313
+ refusal: 'pre-invocation',
307
314
  authStatus: 'logged-out',
308
315
  };
309
316
  }
@@ -319,8 +326,9 @@ function invoke(opts, mode) {
319
326
  ok: false,
320
327
  text: '',
321
328
  usage: null,
322
- reason: `codex system configuration is present at ${systemConfigPath}`,
329
+ reason: 'codex system configuration is present',
323
330
  reasonCode: 'isolation-precondition',
331
+ refusal: 'pre-invocation',
324
332
  authStatus: 'unknown',
325
333
  };
326
334
  }
@@ -351,6 +359,8 @@ function invoke(opts, mode) {
351
359
  ];
352
360
 
353
361
  let res;
362
+ const gate = invocationGate(opts);
363
+ if (gate) return gate;
354
364
  try {
355
365
  res = spawnSyncImpl(bin, args, {
356
366
  input: stdin,
@@ -365,7 +375,7 @@ function invoke(opts, mode) {
365
375
  return { ok: false, text: '', usage: null, authStatus: 'unknown', ...classified };
366
376
  }
367
377
  if (!res) {
368
- return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus: 'unknown' };
378
+ return { ok: false, text: '', usage: null, reason: 'codex spawn returned no process result', reasonCode: 'unknown', authStatus: 'unknown' };
369
379
  }
370
380
  if (res.error) {
371
381
  const classified = classifySpawnError(res.error, bin);
@@ -374,7 +384,7 @@ function invoke(opts, mode) {
374
384
  // spawnSync signals a timeout via `signal` (no `error`) when the child was
375
385
  // killed for exceeding `timeout` and didn't set its own exit code.
376
386
  if (res.signal && res.status === null) {
377
- return { ok: false, text: '', usage: null, reason: `codex exec timed out (${bin}), signal ${res.signal}`, reasonCode: 'cli-timeout', authStatus: 'unknown' };
387
+ return { ok: false, text: '', usage: null, reason: 'codex exec timed out', reasonCode: 'cli-timeout', authStatus: 'unknown' };
378
388
  }
379
389
 
380
390
  const stdout = String(res.stdout || '');
@@ -395,7 +405,7 @@ function invoke(opts, mode) {
395
405
  ok: false,
396
406
  text: '',
397
407
  usage: null,
398
- reason: `codex exec exited ${res.status}: ${stderr.slice(0, 160)}`,
408
+ reason: 'codex exec failed',
399
409
  reasonCode: 'model-error',
400
410
  authStatus: 'unknown',
401
411
  };
@@ -422,7 +432,7 @@ function invoke(opts, mode) {
422
432
  ok: false,
423
433
  text: '',
424
434
  usage: null,
425
- reason: `codex exec emitted disallowed item type: ${itemType}`,
435
+ reason: `codex exec emitted disallowed item type: ${KNOWN_DISALLOWED_ITEM_TYPES.includes(itemType) ? itemType : 'unrecognized'}`,
426
436
  reasonCode: 'isolation-violation',
427
437
  authStatus: 'unknown',
428
438
  };
@@ -472,6 +482,9 @@ function invoke(opts, mode) {
472
482
  const identity = {
473
483
  provider: 'codex-cli',
474
484
  model: null, // the route intentionally preserves its existing identity contract
485
+ requested_model: null,
486
+ observed_model: null,
487
+ identity_unresolved: 'missing',
475
488
  version: getCodexVersion(opts),
476
489
  vendor: null,
477
490
  };
@@ -503,7 +516,8 @@ function invoke(opts, mode) {
503
516
  /** runModel(opts) — the provider.interface.js contract. */
504
517
  async function runModel(opts = {}) {
505
518
  const mode = opts.mode === 'judge' ? 'judge' : 'extract';
506
- return invoke(opts, mode);
519
+ try { return invoke(opts, mode); }
520
+ catch { return { ok: false, text: '', usage: null, reason: 'codex invocation failed', reasonCode: 'unknown', authStatus: 'unknown' }; }
507
521
  }
508
522
 
509
523
  module.exports = {
@@ -511,6 +525,8 @@ module.exports = {
511
525
  detect,
512
526
  resolveCodexBin,
513
527
  readAuthMode,
528
+ checkAuthStatus: opts => readAuthMode(opts) ? 'logged-in' : 'logged-out',
529
+ KNOWN_DISALLOWED_ITEM_TYPES,
514
530
  codexChildEnv,
515
531
  getCodexVersion,
516
532
  neutralizeSkillMentions,
@@ -1,41 +1,19 @@
1
1
  'use strict';
2
2
  /*
3
- * scripts/providers/index.js — provider registry + selection
4
- * (EXTRACT-PER-CLIENT W1 PART A; selection fall-through added in the W1 P1
5
- * fix — PUNCH-LIST).
6
- *
7
- * resolveProvider(): which ONE provider to try first. Selection order:
8
- * AUXILO_EXTRACTION_PROVIDER env override (wins unconditionally, never
9
- * persisted — "wins, never writes") → else the persisted `selected` choice
10
- * from ~/.auxilo/providers.json if it is STILL usable (re-verified via its
11
- * own detect() every call, never trusted blindly — a stale persisted choice
12
- * falls through to a full re-scan rather than failing) → else the first
13
- * provider whose detect() is true, in automatic order (claude-code →
14
- * byo-key) → else ok:false, with a reason naming every automatic provider
15
- * tried. codex-cli remains available only through the explicit env override.
16
- *
17
- * runModel(): resolves via resolveProvider(), then actually RUNS it. A
18
- * non-override resolution that fails with a reasonCode meaning "this
19
- * provider cannot run at all" (NON_RETRYABLE_FOR_THIS_PROVIDER — e.g.
20
- * unauthenticated, not installed, a billing helper is configured) falls
21
- * through to the next provider in AUTOMATIC_PROVIDER_ORDER rather than
22
- * reporting a hard failure; a working provider that merely failed once
23
- * (timeout, model error) does not fall through — that is still the builder's
24
- * chosen provider having a bad run, not a reason to switch under them. An
25
- * explicit env override, including codex-cli, never falls through, honoring
26
- * the operator's explicit choice. Every automatic provider exhausted →
27
- * reasonCode 'no-usable-provider' with every attempt's reason summarized in
28
- * `reason`.
29
- *
30
- * codex-cli.js and byo-key.js don't exist yet (PART B/C). loadOptionalProvider()
31
- * degrades a missing module into a "not installed yet" stub so this file — and
32
- * everything that calls into it — never throws on a fresh PART A checkout; PART
33
- * B/C only need to add their files, not touch this registration.
3
+ * EPC2-2 A+B: routing is pinned per job; initial automatic precedence is
4
+ * unchanged with Codex dark. An existing binding wins over new configuration.
5
+ * Only a fresh, continuously owned automatic attempt with no invocation may
6
+ * fall back on an explicit pre-invocation refusal. CLI model consistency is
7
+ * observed, not enforced. Runner ownership does not prove a prior remote
8
+ * request stopped; a recovered request can consume quota or bill a key again.
34
9
  */
35
10
 
36
11
  const fs = require('fs');
37
12
  const path = require('path');
38
13
  const os = require('os');
14
+ const crypto = require('crypto');
15
+ const bindings = require('./route-binding.js');
16
+ const SOURCE_ROUTES = Object.freeze({ 'claude-code': 'claude-code' });
39
17
 
40
18
  const claudeCode = require('./claude-code.js');
41
19
 
@@ -52,6 +30,7 @@ function notInstalledProvider(id) {
52
30
  usage: null,
53
31
  reasonCode: 'provider-not-installed',
54
32
  reason: `${id} support is not installed yet`,
33
+ refusal: 'pre-invocation',
55
34
  authStatus: 'unknown',
56
35
  };
57
36
  },
@@ -121,6 +100,7 @@ function persistSelected(providerId, opts) {
121
100
  try {
122
101
  if (typeof byoKey.writeProvidersStateAtomic !== 'function') return; // PART C not installed yet
123
102
  const state = readProvidersState(statePath);
103
+ state.origin = 'auto';
124
104
  state.selected = providerId; // merge only this field — `byo` (if present) passes through as-read
125
105
  byoKey.writeProvidersStateAtomic(state, opts);
126
106
  } catch {
@@ -142,16 +122,18 @@ function persistSelected(providerId, opts) {
142
122
  const defaultCache = {};
143
123
 
144
124
  async function resolveProvider(opts = {}) {
125
+ const registry = opts.providerRegistry || PROVIDERS;
126
+ const sourceMap = opts.sourceMap || SOURCE_ROUTES;
145
127
  const env = opts.env || process.env;
146
128
  const override = env.AUXILO_EXTRACTION_PROVIDER;
147
129
  if (override) {
148
- if (!Object.prototype.hasOwnProperty.call(PROVIDERS, override)) {
130
+ if (!Object.prototype.hasOwnProperty.call(registry, override)) {
149
131
  return {
150
132
  ok: false,
151
133
  reason: `AUXILO_EXTRACTION_PROVIDER="${override}" is not a known provider (expected one of: ${KNOWN_PROVIDER_IDS.join(', ')})`,
152
134
  };
153
135
  }
154
- return { ok: true, id: override, module: PROVIDERS[override] };
136
+ return { ok: true, id: override, module: registry[override] };
155
137
  }
156
138
 
157
139
  // Fast path (EXTRACT-PER-CLIENT W1 FIX, PUNCH-LIST P1, item 3): the
@@ -206,8 +188,8 @@ async function resolveProvider(opts = {}) {
206
188
  if (cache.resolved && cache.resolved.id === 'codex-cli') delete cache.resolved;
207
189
 
208
190
  const persistedId = persistedState.selected;
209
- if (typeof persistedId === 'string' && Object.prototype.hasOwnProperty.call(PROVIDERS, persistedId)) {
210
- const persistedMod = PROVIDERS[persistedId];
191
+ if (typeof persistedId === 'string' && Object.prototype.hasOwnProperty.call(registry, persistedId)) {
192
+ const persistedMod = registry[persistedId];
211
193
  let stillUsable = false;
212
194
  try {
213
195
  stillUsable = await persistedMod.detect(opts);
@@ -223,8 +205,10 @@ async function resolveProvider(opts = {}) {
223
205
  }
224
206
 
225
207
  const tried = [];
226
- for (const id of AUTOMATIC_PROVIDER_ORDER) {
227
- const mod = PROVIDERS[id];
208
+ const native = sourceMap[opts.source];
209
+ const order = [...new Set([...(AUTOMATIC_PROVIDER_ORDER.includes(native) ? [native] : []), ...AUTOMATIC_PROVIDER_ORDER])];
210
+ for (const id of order) {
211
+ const mod = registry[id];
228
212
  tried.push(id);
229
213
  let available = false;
230
214
  try {
@@ -242,27 +226,13 @@ async function resolveProvider(opts = {}) {
242
226
  return { ok: false, reason: `no extraction model provider available — tried: ${tried.join(', ')}` };
243
227
  }
244
228
 
245
- /**
246
- * reasonCodes meaning "this provider cannot run at all right now" — safe to
247
- * try the NEXT provider in AUTOMATIC_PROVIDER_ORDER rather than reporting a hard
248
- * failure (EXTRACT-PER-CLIENT W1 FIX, PUNCH-LIST P1, item 2). Everything
249
- * else (timeouts, model errors, malformed output, rate limits) means the
250
- * chosen provider DID run and failed on THIS call — that is not a signal to
251
- * silently switch providers out from under the builder, so those propagate
252
- * as-is (a working provider that failed once is still the builder's chosen
253
- * provider).
254
- */
229
+ /** Bounded message classification only. Reason codes never authorize fallback. */
255
230
  const NON_RETRYABLE_FOR_THIS_PROVIDER = new Set([
256
231
  'cli-unauthenticated',
257
232
  'cli-not-installed',
258
233
  'cli-billing-helper-configured',
259
234
  'provider-not-configured',
260
235
  'providers-file-mode-unsafe',
261
- // EXTRACTION-CHILD-HOOKS (0.9.15): the resolved claude-code CLI doesn't
262
- // support --setting-sources, so it can never run isolated — same
263
- // "cannot run at all right now" class as the codes above, safe to try the
264
- // next automatic provider rather than reporting a hard failure. codex-cli
265
- // remains reachable only through the explicit override, which never walks.
266
236
  'cli-settings-isolation-unsupported',
267
237
  ]);
268
238
 
@@ -289,51 +259,23 @@ function hasUsableIdentity(identity) {
289
259
  );
290
260
  }
291
261
 
292
- /**
293
- * Derive an identity for the module actually invoked as `id`, used only when
294
- * that module's own result didn't already carry one. The derivation differs
295
- * by outcome, because a SUCCESS identity can reach a published learning and
296
- * the clean-lane calibration gate, while a FAILURE identity is purely
297
- * diagnostic (extract-local.js returns before stamping anything onto a
298
- * candidate on `ok:false` — see its `:824-834`):
299
- *
300
- * - claude-code: always gets a real, provider-specific identity — it
301
- * already has `cliVersion` in hand on every returned result (success or
302
- * failure), richer than the null/null/null triple this used to guess, and
303
- * claude-code is the one provider documented to never self-stamp
304
- * `identity` at all, so this is filling a KNOWN, structural gap, not
305
- * papering over a violated contract.
306
- * - Every other provider on a FAILURE: `{provider:id, model:null,
307
- * version:null, vendor:null}` — naming the module we actually invoked is
308
- * a plain structural fact (we chose to call it), not a guess, and it is
309
- * what lets the per-run `[providers]` log name the provider that actually
310
- * ran even when that provider's own failure return carries no identity
311
- * (codex-cli and byo-key only self-stamp on their SINGLE success return —
312
- * the automatic fall-through failure case is now: claude-code skipped,
313
- * byo-key ran and failed, no identity of its own, and the label must name
314
- * byo-key rather than be re-guessed as claude-code by the old fallback).
315
- * - Every other provider on a SUCCESS: `{provider:'unknown', ...}` —
316
- * byo-key.js and codex-cli.js both self-stamp `identity` on their only
317
- * success return BY CONTRACT; a success with none means that contract was
318
- * violated, so this registry has no provider-reported basis for the
319
- * claim. Confidently naming the module here would look like provenance
320
- * without being backed by anything the provider itself reported — since
321
- * this stamp CAN reach a published learning, the fail-closed, honest
322
- * answer is 'unknown', never a guess dressed up as a fact.
323
- */
262
+ /** Report only known provider/version facts; never infer an observed model. */
324
263
  function deriveIdentity(id, result) {
325
264
  if (id === 'claude-code') {
326
265
  return {
327
266
  provider: 'claude-code',
328
267
  model: null,
268
+ requested_model: null,
269
+ observed_model: null,
270
+ identity_unresolved: 'missing',
329
271
  version: (result && result.cliVersion) || null,
330
272
  vendor: 'anthropic',
331
273
  };
332
274
  }
333
275
  if (!(result && result.ok)) {
334
- return { provider: id, model: null, version: null, vendor: null };
276
+ return { provider: id, model: null, requested_model: null, observed_model: null, identity_unresolved: 'missing', version: null, vendor: null };
335
277
  }
336
- return { provider: 'unknown', model: null, version: null, vendor: null };
278
+ return { provider: 'unknown', model: null, requested_model: null, observed_model: null, identity_unresolved: 'missing', version: null, vendor: null };
337
279
  }
338
280
 
339
281
  /**
@@ -352,88 +294,133 @@ function withIdentity(id, result) {
352
294
  return { ...result, identity: deriveIdentity(id, result) };
353
295
  }
354
296
 
355
- /**
356
- * runModel(opts) — resolve a starting provider via resolveProvider(), then
357
- * walk AUTOMATIC_PROVIDER_ORDER from there, calling each candidate's OWN runModel()
358
- * (never a separate detect() pre-check — a provider's runModel() already
359
- * performs the equivalent authoritative check internally and returns a
360
- * specific, accurate reason, so a second detect() call would only add a
361
- * redundant probe without adding information). Falls through to the next
362
- * provider when the current one's failure reasonCode is in
363
- * NON_RETRYABLE_FOR_THIS_PROVIDER; when resolveProvider itself found no
364
- * usable provider at all, the walk starts at AUTOMATIC_PROVIDER_ORDER's
365
- * beginning so every automatic provider still gets an actual runModel()
366
- * call and contributes its own reason (not just a single generic "no
367
- * provider available"). An explicit AUXILO_EXTRACTION_PROVIDER override,
368
- * including codex-cli, never falls through — it is the operator's explicit
369
- * choice, so its own failure reason is reported as-is. When every provider
370
- * tried is exhausted, returns reasonCode
371
- * 'no-usable-provider' with a bounded summary of every provider's reason in
372
- * `reason` (no secrets — each provider's own reason string is already
373
- * secret-free by contract) and NO identity — nothing actually ran to
374
- * completion, so the caller (extract-local.js) stamps `provider:'unknown'`
375
- * rather than have this registry guess one. Never throws.
376
- */
377
- async function runModel(opts = {}) {
378
- const mode = opts.mode === 'judge' ? 'judge' : 'extract';
379
- const env = opts.env || process.env;
380
- const override = env.AUXILO_EXTRACTION_PROVIDER;
381
-
382
- if (override) {
383
- const resolved = await resolveProvider(opts);
384
- if (!resolved.ok) {
385
- return {
386
- ok: false,
387
- text: '',
388
- usage: null,
389
- reasonCode: 'no-model-provider-available',
390
- reason: resolved.reason,
391
- authStatus: 'unknown',
392
- };
297
+ /** Credential-free binding destination, calculated exactly as the adapter does. */
298
+ function routeMetadata(id, opts, origin) {
299
+ let destinationFingerprint = null;
300
+ let cliFingerprint = null;
301
+ let cliVersion = null;
302
+ if (id === 'byo-key') {
303
+ const config = byoKey.readByoConfig(opts);
304
+ if (config) {
305
+ const vendor = byoKey.resolveVendor(config.provider);
306
+ destinationFingerprint = bindings.hash(vendor + '\n' + byoKey.baseUrlFor(vendor, config.base_url) + '\n' + config.model);
393
307
  }
394
- const result = await resolved.module.runModel({ ...opts, mode });
395
- return withIdentity(resolved.id, result);
308
+ } else if (id === 'claude-code') {
309
+ const bin = opts.claudeBin || claudeCode.resolveClaudeBin(opts);
310
+ cliFingerprint = bindings.hash(bin);
311
+ cliVersion = claudeCode.getClaudeCliVersion(bin, opts);
312
+ } else if (id === 'codex-cli') {
313
+ const bin = opts.codexBin || codexCli.resolveCodexBin(opts);
314
+ cliFingerprint = bindings.hash(bin);
315
+ // getCodexVersion is --version, never a model-capable request.
316
+ cliVersion = codexCli.getCodexVersion(opts);
396
317
  }
318
+ return { route: id, origin, billingMode: id === 'byo-key' ? 'byo-key' : 'cli-login', destinationFingerprint, cliFingerprint, cliVersion };
319
+ }
397
320
 
398
- const log = typeof opts.log === 'function' ? opts.log : console.error;
399
- const resolved = await resolveProvider(opts);
400
- const startIdx = resolved.ok ? AUTOMATIC_PROVIDER_ORDER.indexOf(resolved.id) : -1;
401
- const order = startIdx === -1
402
- ? AUTOMATIC_PROVIDER_ORDER.slice()
403
- : AUTOMATIC_PROVIDER_ORDER.slice(startIdx);
321
+ function dispositionResult(disposition) {
322
+ const code = disposition.hold || disposition.deferred;
323
+ return { ok: false, text: '', usage: null, reasonCode: code, reason: 'job route is held or deferred', authStatus: 'unknown', ...disposition };
324
+ }
404
325
 
326
+ function finishJob(context) {
327
+ if (!context || !context.store) return {};
328
+ const existing = context.store.disposition;
329
+ if (existing.hold || existing.deferred) return existing;
330
+ if (context.hold) return { hold: context.hold };
331
+ const result = context.store.complete();
332
+ return result.hold ? { hold: result.hold } : {};
333
+ }
334
+
335
+ async function runModel(opts = {}) {
336
+ const mode = opts.mode === 'judge' ? 'judge' : 'extract';
337
+ const registry = opts.providerRegistry || PROVIDERS;
338
+ const context = opts.routeContext || {};
339
+ const standalone = !opts.routeContext;
340
+ if (context.hold) return dispositionResult({ hold: context.hold });
341
+ if (!context.store) {
342
+ const identity = {
343
+ source: opts.source || '', sessionId: opts.sessionId || opts.runId || crypto.randomUUID(),
344
+ jobSha: opts.jobSha || bindings.hash(String(opts.input || opts.prompt || '')),
345
+ };
346
+ context.store = bindings.createStore(identity, opts);
347
+ const acquired = await context.store.acquire(async () => {
348
+ const resolved = await resolveProvider({ ...opts, providerCache: opts.providerCache || {} });
349
+ const explicit = Boolean((opts.env || process.env).AUXILO_EXTRACTION_PROVIDER);
350
+ if (!resolved.ok && explicit) return { hold: 'pinned-route-unusable' };
351
+ return routeMetadata(resolved.ok ? resolved.id : 'claude-code', opts, explicit ? 'explicit' : 'auto');
352
+ });
353
+ if (acquired.hold || acquired.deferred) return dispositionResult(context.store.disposition);
354
+ }
355
+ const store = context.store;
356
+ if (store.disposition.hold || store.disposition.deferred) return dispositionResult(store.disposition);
357
+ const record = store.record;
358
+ const current = routeMetadata(record.route, opts, record.origin);
359
+ if (['destinationFingerprint', 'cliFingerprint', 'cliVersion', 'billingMode'].some(k => current[k] !== record[k])) {
360
+ const release = store.hold('pinned-route-changed');
361
+ context.hold = release.hold || 'pinned-route-changed';
362
+ return dispositionResult({ hold: context.hold });
363
+ }
364
+ const configuredByo = Boolean(byoKey.readByoConfig(opts));
365
+ // A retained BYO cache refusal rescans the ordinary order, including BYO.
366
+ const order = [record.route, ...AUTOMATIC_PROVIDER_ORDER.filter(id =>
367
+ (id !== record.route || record.route === 'byo-key') && (id !== 'byo-key' || configuredByo))];
405
368
  const attempts = [];
369
+ let firstFailure = null;
406
370
  for (const id of order) {
407
- const mod = PROVIDERS[id];
408
- // eslint-disable-next-line no-await-in-loop
409
- const rawResult = await mod.runModel({ ...opts, mode });
410
- const result = withIdentity(id, rawResult);
411
- if (result.ok) return result;
412
- attempts.push({ id, reasonCode: result.reasonCode, reason: result.reason });
413
- if (!NON_RETRYABLE_FOR_THIS_PROVIDER.has(result.reasonCode)) {
371
+ if (id !== store.record.route) {
372
+ const updated = store.mutate(routeMetadata(id, opts, 'auto'));
373
+ if (updated.hold) return dispositionResult(store.disposition);
374
+ }
375
+ const mod = registry[id];
376
+ let called = false;
377
+ const beforeModelInvocation = () => {
378
+ if (called) return false;
379
+ called = true;
380
+ return store.beforeInvocation(mode, opts.timeoutMs || 120000);
381
+ };
382
+ let raw;
383
+ try {
384
+ raw = mod ? await mod.runModel({ ...opts, mode, beforeModelInvocation }) :
385
+ { ok: false, text: '', usage: null, reason: 'provider is not installed', reasonCode: 'provider-not-installed', refusal: 'pre-invocation' };
386
+ } catch {
387
+ raw = { ok: false, text: '', usage: null, reason: 'provider invocation failed', reasonCode: 'unknown' };
388
+ }
389
+ let result = withIdentity(id, raw);
390
+ const stopped = store.disposition;
391
+ if (stopped.hold || stopped.deferred) return { ...result, ...stopped };
392
+ if (result.ok) {
393
+ const observed = store.observe(result.identity && result.identity.observed_model);
394
+ if (observed && observed.hold) return { ...result, hold: observed.hold };
395
+ if (standalone) result = { ...result, ...finishJob(context) };
414
396
  return result;
415
397
  }
416
- log(`[providers] ${id} unusable (${result.reasonCode}); trying next provider`);
398
+ if (!attempts.length) firstFailure = result;
399
+ attempts.push(id + '=' + (NON_RETRYABLE_FOR_THIS_PROVIDER.has(result.reasonCode) ? result.reasonCode : 'unavailable'));
400
+ if (result.refusal !== 'pre-invocation' || !store.canFallback()) {
401
+ const release = store.hold('pinned-route-unusable');
402
+ context.hold = release.hold || 'pinned-route-unusable';
403
+ return { ...result, hold: context.hold };
404
+ }
405
+ (opts.log || console.error)('[providers] ' + id + ' refused before invocation; trying next provider');
417
406
  }
418
-
419
- const summary = attempts
420
- .map((a) => `${a.id}=${a.reasonCode || 'unknown'}`)
421
- .join('; ')
422
- .slice(0, 480);
423
- const lastAttempt = attempts[attempts.length - 1];
424
- const lastReason = (lastAttempt && lastAttempt.reason) || resolved.reason || 'no provider available';
425
- return {
426
- ok: false,
427
- text: '',
428
- usage: null,
429
- reasonCode: 'no-usable-provider',
430
- reason: `${lastReason} (tried: ${summary})`.slice(0, 600),
431
- authStatus: 'unknown',
432
- };
407
+ const released = store.hold('pinned-route-unusable');
408
+ context.hold = released.hold || 'pinned-route-unusable';
409
+ if (!configuredByo && !attempts.some(attempt => attempt.startsWith('byo-key='))) attempts.push('byo-key=provider-not-configured');
410
+ // EXT-0806c: a generic 'no-usable-provider' reads as UNKNOWN downstream and never alerts. When the
411
+ // first-choice provider was refused for an unauthenticated CLI, the chain already knows the
412
+ // actionable cause, so surface it; the attempts list stays in `reason`. Read from the first
413
+ // attempt's result object, never from the attempts strings.
414
+ const unauthenticated = Boolean(firstFailure) && firstFailure.reasonCode === 'cli-unauthenticated';
415
+ return { ok: false, text: '', usage: null, authStatus: unauthenticated ? 'logged-out' : 'unknown',
416
+ reasonCode: unauthenticated ? 'cli-unauthenticated' : 'no-usable-provider',
417
+ reason: 'no usable extraction provider (tried: ' + attempts.join('; ') + ')', hold: context.hold };
433
418
  }
434
419
 
435
420
  module.exports = {
436
421
  runModel,
422
+ finishJob,
423
+ SOURCE_ROUTES,
437
424
  resolveProvider,
438
425
  KNOWN_PROVIDER_IDS,
439
426
  AUTOMATIC_PROVIDER_ORDER,
@@ -17,10 +17,8 @@
17
17
  * @typedef {'extract'|'judge'} RunModelMode
18
18
  * 'extract' (default) — draft learnings from a transcript.
19
19
  * 'judge' — binary anchored-dedup decision against previously captured lessons.
20
- * A CLI provider may pick a different argv/parsing variant per mode (Claude Code
21
- * does — extraction and judge invocations differ today). A provider that has no
22
- * mode-specific behavior (e.g. codex-cli, PART B) may ignore `mode` and always use
23
- * the same invocation shape.
20
+ * Claude Code uses the same isolated JSON transport and strict decoder in
21
+ * both modes. Prompt bytes and candidate parsing are separate from transport.
24
22
  */
25
23
 
26
24
  /**
@@ -44,6 +42,15 @@
44
42
  * @property {Function} [fetchImpl] - Injectable `fetch` replacement (HTTP providers
45
43
  * only) — the test-injection seam.
46
44
  * @property {RunModelMode} [mode] - 'extract' (default) or 'judge'.
45
+ * @property {Function} beforeModelInvocation - Required at every model-capable
46
+ * adapter boundary. Called exactly once immediately before spawn/fetch; only
47
+ * literal true authorizes it. The registry supplies the owned binding hook.
48
+ * Pre-invocation refusals return without consulting the hook.
49
+ * @property {string} [source] - Transcript source (unknown means no native route).
50
+ * @property {string} [sessionId] - Source session identifier.
51
+ * @property {string} [jobSha] - SHA256 of the exact received transcript bytes.
52
+ * @property {object} [routeContext] - Job-local owned binding shared by stages.
53
+ * @property {string} [routeBindingsDir] - Test seam only; never read from env.
47
54
  * @property {object} [schema] - Optional JSON-Schema hint a provider MAY use to
48
55
  * constrain its output. Claude Code ignores this — `extractJsonValue`'s
49
56
  * fence-strip + brace-scan parser is already model-agnostic and needs no schema
@@ -63,21 +70,21 @@
63
70
  * (e.g. 'cli-unauthenticated', 'cli-billing-helper-configured', 'model-error',
64
71
  * 'isolation-precondition', 'isolation-unverified', 'isolation-violation', 'output-schema-rejected',
65
72
  * 'unknown'). Present on both success and failure paths where applicable.
66
- * @property {string|null} [reason] - Human-readable reason, present when !ok.
73
+ * @property {string|null} [reason] - Fixed classification, present when !ok;
74
+ * never raw output, errors, local paths or configured URLs.
75
+ * @property {'pre-invocation'} [refusal] - Positive pre-invocation refusal only.
76
+ * @property {string} [hold] - Blocks further inference, not candidate submission.
77
+ * @property {string} [deferred] - Ownership or recovery-wait disposition.
67
78
  * @property {string} [authStatus] - 'logged-in' | 'logged-out' | 'unknown', when
68
79
  * the provider has a meaningful concept of local auth state.
69
- * @property {object} [identity] - {provider, model, version, vendor} — which
70
- * provider/model actually ran this call, for the extraction_model stamp
71
- * (scripts/extract-local.js's resolveExtractionModelIdentity reads THIS
72
- * field, not any other name). byo-key.js always sets it (full identity,
73
- * read from its stored config). codex-cli.js sets it (provider/version;
74
- * model/vendor null — codex exposes no per-call model id without --json).
75
- * claude-code.js does not set one yet; resolveExtractionModelIdentity
76
- * falls back to the resolved provider id alone when absent (EXTRACT-PER-
77
- * CLIENT W1 FIX GATE-A item (a) — codex-cli.js used to export this same
78
- * data under the field name `extraction_model`, which nothing here ever
79
- * read, so its real version silently never reached the stamp; that field
80
- * name survives one more release as a deprecated alias of `identity`).
80
+ * @property {object} [identity] - {provider, model, requested_model,
81
+ * observed_model, version, vendor, identity_unresolved?}. Unknown is null.
82
+ * requested_model is the BYO configuration, never evidence of what ran.
83
+ * observed_model comes only from the vendor response or Claude canonicalModel;
84
+ * zero distinct canonical values means missing, several means ambiguous.
85
+ * Codex remains unobserved (missing). Legacy model is observed_model or null.
86
+ * version is the actual CLI version. Usage never implies model identity.
87
+ * These fields reach the local stamp; server preservation is EPC2-3's scope.
81
88
  */
82
89
 
83
90
  /**