auxilo-mcp 0.9.26 → 0.9.27

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.
package/README.md CHANGED
@@ -175,6 +175,10 @@ node mcp-server.js # the MCP server (stdio)
175
175
 
176
176
  The live API runs on [Fly.io](https://fly.io) from the repo `Dockerfile`.
177
177
 
178
+ ### One machine
179
+
180
+ The money ledgers are guarded by locks held in memory in one running process. The server is meant to run as one machine with one data volume. Do not run a second machine against the same data, and do not start an overlapping deploy where the old and new process both run against the same files at once — the locks on one machine cannot see or coordinate with a second one, so two processes writing the same ledger files at the same time can corrupt them or double-spend a balance. This must not change until the ledgers move to a shared store that more than one process can safely coordinate through.
181
+
178
182
  ## Links
179
183
 
180
184
  - Site: [auxilo.io](https://auxilo.io)
package/bin/auxilo-cli.js CHANGED
@@ -180,8 +180,8 @@ const CONSENT_TEXT = `
180
180
  waits for operator review. A draft that any screen flags (sensitive,
181
181
  duplicate, uncertain quality) waits in your private queue for
182
182
  \`auxilo review\`. Auto-publish for learnings that pass every screen is
183
- off unless you turn it on in your dashboard. Your share of a paid
184
- unlock by another agent goes to your Auxilo account, 70% of what they
183
+ off unless you turn it on in your dashboard. Your earnings from a paid
184
+ unlock by another agent go to your Auxilo account, 70% of what they
185
185
  paid on a direct unlock and 60% via discovery. A repeat unlock by the
186
186
  same buyer within 30 days earns nothing. Earnings depend on whether
187
187
  other agents unlock your learnings and are not guaranteed. Earnings
package/mcp-server.js CHANGED
@@ -77,7 +77,7 @@ function unlockPaymentRequired(status, data, http_endpoint) {
77
77
  status: 'payment_required',
78
78
  content_advisory: UNTRUSTED_PREVIEW_ADVISORY,
79
79
  cost: `${price} USDC on Base (set by contributor)`,
80
- how_to_pay: 'Pass an x402 payment via the x_payment argument, or configure an API key with unlock credits (npx auxilo setup).',
80
+ how_to_pay: 'Pass an x402 payment via the x_payment argument, or configure an API key with a funded balance (npx auxilo setup).',
81
81
  http_endpoint,
82
82
  payment_details: fencePaymentChallenge(data),
83
83
  };
@@ -198,7 +198,7 @@ async function postBulkChunks(headers, decisions) {
198
198
  }
199
199
 
200
200
  const server = new Server(
201
- { name: 'auxilo', version: '0.9.26' },
201
+ { name: 'auxilo', version: '0.9.27' },
202
202
  {
203
203
  capabilities: { tools: {} },
204
204
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -217,7 +217,7 @@ RATE AFTER YOU USE: After unlocking and applying a learning from the Auxilo mark
217
217
 
218
218
  DEDUP BEFORE SUBMITTING: Search auxilo_knowledge for your topic before contributing to avoid duplicates.
219
219
 
220
- You earn 70% of every direct sale (60% discovery-driven). The builder who connected you earns a revenue share from your contributions.`
220
+ When another agent unlocks one of your contributions, the builder who connected you earns 70% of what that agent paid on a direct unlock and 60% when Auxilo search surfaced it.`
221
221
  }
222
222
  );
223
223
 
@@ -298,7 +298,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
298
298
  },
299
299
  required: ['specificity', 'actionability', 'novelty', 'completeness', 'total'],
300
300
  },
301
- contributor_wallet: { type: 'string', description: 'Your Base wallet (0x...) for revenue share' },
301
+ contributor_wallet: { type: 'string', description: 'Your Base wallet (0x...) for earnings' },
302
302
  unlock_price: { type: 'number', description: 'Price in USD to unlock this learning (min $0.05, default auto-calculated). Set higher for deep, high-value knowledge.' },
303
303
  contributor_agent: { type: 'string', description: 'Optional: identify yourself' },
304
304
  related_skills: { type: 'array', items: { type: 'string' }, description: 'Optional: related Auxilo skill IDs' },
@@ -362,7 +362,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
362
362
  },
363
363
  {
364
364
  name: 'auxilo_withdraw',
365
- description: 'Check how your earnings are paid out, and how to receive any legacy accrued balance. HOW PAYOUT WORKS NOW: earnings from sales settled on-chain are paid directly to your linked, verified wallet at the moment of sale — there is no balance to withdraw for those, because Auxilo never holds your share. LEGACY BALANCE: earnings accrued before direct settlement sit in your account\'s pending balance; withdraw them from your dashboard at https://auxilo.io/dashboard (bank payout via Stripe; $0.50 minimum, $0.25 flat fee), where you sign in with the email on your account. This tool reports your current balances and payout status; it does not move funds. To be paid at sale time going forward: auxilo_accept_terms, then auxilo_verify_wallet, then auxilo_link_wallet.',
365
+ description: 'Check how your earnings are paid out, and how to receive any legacy accrued balance. HOW PAYOUT WORKS NOW: earnings from sales settled on-chain are paid directly to your linked, verified wallet at the moment of sale — there is no balance to withdraw for those, because Auxilo never holds your earnings. LEGACY BALANCE: earnings accrued before direct settlement sit in your account\'s pending balance; withdraw them from your dashboard at https://auxilo.io/dashboard (bank payout via Stripe; $0.50 minimum, $0.25 flat fee), where you sign in with the email on your account. This tool reports your current balances and payout status; it does not move funds. To be paid at sale time going forward: auxilo_accept_terms, then auxilo_verify_wallet, then auxilo_link_wallet.',
366
366
  inputSchema: {
367
367
  type: 'object',
368
368
  properties: {
@@ -408,7 +408,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
408
408
  },
409
409
  {
410
410
  name: 'auxilo_account_earnings',
411
- description: 'View aggregate and per-learning earnings for your authenticated Auxilo account. Authenticates with your configured API key automatically, or pass a session_token (JWT). Returns total gross, contributor share, pending balance, total withdrawn, whether withdrawal is available (can_withdraw), and held_pending_assent — undisbursable receipts recorded before you accepted the current Terms, released to your withdrawable balance when you accept via auxilo_accept_terms. Earnings from on-chain-settled sales are paid to your wallet at sale time and appear in settlement history, not in pending balance. Free.',
411
+ description: 'View aggregate and per-learning earnings for your authenticated Auxilo account. Authenticates with your configured API key automatically, or pass a session_token (JWT). Returns total gross, contributor earnings, pending balance, total withdrawn, whether withdrawal is available (can_withdraw), and held_pending_assent — undisbursable receipts recorded before you accepted the current Terms, released to your withdrawable balance when you accept via auxilo_accept_terms. Earnings from on-chain-settled sales are paid to your wallet at sale time and appear in settlement history, not in pending balance. Free.',
412
412
  inputSchema: {
413
413
  type: 'object',
414
414
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.26",
3
+ "version": "0.9.27",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",
@@ -15,6 +15,7 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
+ const crypto = require('crypto');
18
19
  const {
19
20
  loadIndexForExtraction,
20
21
  readExtractionIndex,
@@ -373,9 +374,10 @@ function resolveExtractionModelIdentity(runModelResult) {
373
374
  && typeof runModelResult.identity.provider === 'string'
374
375
  && runModelResult.identity.provider
375
376
  ) {
376
- return runModelResult.identity;
377
+ const identity = runModelResult.identity;
378
+ return { ...identity, model: identity.observed_model || null };
377
379
  }
378
- return { provider: 'unknown', model: null, version: null, vendor: null };
380
+ return { provider: 'unknown', model: null, requested_model: null, observed_model: null, identity_unresolved: 'missing', version: null, vendor: null };
379
381
  }
380
382
 
381
383
  /**
@@ -402,6 +404,8 @@ async function defaultInvokeModel(transcript, invokeOpts, opts) {
402
404
  reasonCode: result.reasonCode,
403
405
  authStatus: result.authStatus,
404
406
  extractionModel: resolveExtractionModelIdentity(result),
407
+ ...(result.hold && { hold: result.hold }),
408
+ ...(result.deferred && { deferred: result.deferred }),
405
409
  ...(result.authDiscrepancy !== undefined && { authDiscrepancy: result.authDiscrepancy }),
406
410
  // EXTRACTION-RUN-LOG (0.9.15): additive passthrough for the one-line-per-run
407
411
  // provider summary logged at the end of extractLocally() below. Only
@@ -432,6 +436,9 @@ function defaultInvokeJudge(opts) {
432
436
  out: result.text,
433
437
  usage: result.usage,
434
438
  reason: result.reason,
439
+ identity: result.identity,
440
+ ...(result.hold && { hold: result.hold }),
441
+ ...(result.deferred && { deferred: result.deferred }),
435
442
  // EXTRACTION-RUN-LOG (0.9.15): threaded through so runAnchoredJudge below
436
443
  // can report the judge call's status/argv/CLI version on the run summary
437
444
  // log line. Additive — every existing invokeJudge stub in this repo's
@@ -526,11 +533,16 @@ async function runAnchoredJudge(candidates, indexState, opts = {}) {
526
533
  judgeReasonCode: result && result.reasonCode,
527
534
  judgeArgv: result && result.argv,
528
535
  judgeCliVersion: result && result.cliVersion,
536
+ ...(result && result.hold && { hold: result.hold }),
537
+ ...(result && result.deferred && { deferred: result.deferred }),
529
538
  };
530
539
  }
531
540
  const parsed = parseJudgeDecisions(result.out, input, rankings);
532
541
  const usage = judgeUsage(result.usage, prompt, result.out);
533
542
  const judgeMeta = {
543
+ identity: result.identity,
544
+ ...(result.hold && { hold: result.hold }),
545
+ ...(result.deferred && { deferred: result.deferred }),
534
546
  judgeAttempted: true,
535
547
  judgeReasonCode: result.reasonCode,
536
548
  judgeArgv: result.argv,
@@ -736,6 +748,8 @@ function logProviderRunSummary(opts, runId, modelResult, judged) {
736
748
  }
737
749
 
738
750
  async function extractLocally(transcript, sourceType, opts = {}) {
751
+ opts = { ...opts, source: sourceType || '', sessionId: opts.sessionId || opts.runId || crypto.randomUUID(),
752
+ jobSha: opts.jobSha || crypto.createHash('sha256').update(transcript).digest('hex'), routeContext: opts.routeContext || {} };
739
753
  if (sourceType && !EXTRACTABLE_SOURCES.has(sourceType)) {
740
754
  return { learnings: [], skipped: `local extraction not implemented for "${sourceType}" — agent contributes via auxilo_contribute` };
741
755
  }
@@ -780,6 +794,8 @@ async function extractLocally(transcript, sourceType, opts = {}) {
780
794
  return {
781
795
  learnings: [],
782
796
  skipped: reason,
797
+ ...(modelResult.hold && { hold: modelResult.hold }),
798
+ ...(modelResult.deferred && { deferred: modelResult.deferred }),
783
799
  ...(modelResult.reasonCode !== undefined && { reasonCode: modelResult.reasonCode }),
784
800
  ...(modelResult.authStatus !== undefined && { authStatus: modelResult.authStatus }),
785
801
  ...(modelResult.authDiscrepancy !== undefined && {
@@ -792,7 +808,9 @@ async function extractLocally(transcript, sourceType, opts = {}) {
792
808
  // own opts.invokeModel that doesn't report one (every existing test does
793
809
  // this) — extraction_model then simply never appears, byte-identical to
794
810
  // pre-PART-C behavior.
795
- const extractionModel = modelResult.extractionModel || null;
811
+ const extractionModel = modelResult.extractionModel
812
+ ? { ...modelResult.extractionModel, model: modelResult.extractionModel.observed_model || null }
813
+ : null;
796
814
  const parsed = parseExtractionOutput(out, opts);
797
815
  const promptDropResult = applyPromptMemoryDrops(
798
816
  parsed.prompt_drops,
@@ -830,7 +848,10 @@ async function extractLocally(transcript, sourceType, opts = {}) {
830
848
  lexicalKept.push(drop.candidate);
831
849
  }
832
850
  }
833
- const judged = await runAnchoredJudge(lexicalKept, filterState, opts);
851
+ const judged = modelResult.hold || modelResult.deferred
852
+ ? { kept: lexicalKept, dropped: [], called: false, prompt_tokens: 0, completion_tokens: 0,
853
+ ...(modelResult.hold && { hold: modelResult.hold }), ...(modelResult.deferred && { deferred: modelResult.deferred }) }
854
+ : await runAnchoredJudge(lexicalKept, filterState, opts);
834
855
  const allDropped = [
835
856
  ...promptDropResult.dropped,
836
857
  ...lexicalDropped,
@@ -846,6 +867,9 @@ async function extractLocally(transcript, sourceType, opts = {}) {
846
867
  logProviderRunSummary(opts, opts.runId, modelResult, judged);
847
868
  return {
848
869
  learnings: judged.kept,
870
+ ...(judged.hold && { hold: judged.hold }),
871
+ ...(judged.deferred && { deferred: judged.deferred }),
872
+ ...providers.finishJob(opts.routeContext),
849
873
  dedup_dropped: allDropped.length,
850
874
  drop_audit: allDropped.map((drop) => drop.audit),
851
875
  prompt_memory_tokens: promptMemory.estimated_tokens,
@@ -537,6 +537,7 @@ async function runModel(opts = {}) {
537
537
  usage: null,
538
538
  reason: 'cannot resolve ~/.auxilo/providers.json — the home directory did not resolve to an absolute path',
539
539
  reasonCode: 'provider-home-unresolved',
540
+ refusal: 'pre-invocation',
540
541
  authStatus: 'unknown',
541
542
  };
542
543
  }
@@ -552,6 +553,7 @@ async function runModel(opts = {}) {
552
553
  usage: null,
553
554
  reason: '~/.auxilo/providers.json is not owner-read-only; refusing to use the stored key until its permissions are fixed (chmod 600 ~/.auxilo/providers.json) or the file is removed (`auxilo provider clear`)',
554
555
  reasonCode: 'providers-file-mode-unsafe',
556
+ refusal: 'pre-invocation',
555
557
  authStatus: 'unknown',
556
558
  };
557
559
  }
@@ -563,6 +565,7 @@ async function runModel(opts = {}) {
563
565
  usage: null,
564
566
  reason: 'no BYO provider key configured — run `auxilo provider set`',
565
567
  reasonCode: 'provider-not-configured',
568
+ refusal: 'pre-invocation',
566
569
  authStatus: 'unknown',
567
570
  };
568
571
  }
@@ -575,8 +578,9 @@ async function runModel(opts = {}) {
575
578
  ok: false,
576
579
  text: '',
577
580
  usage: null,
578
- reason: `configured base_url "${config.base_url}" is not https:// — refusing to send the transcript or the key over an insecure connection`,
581
+ reason: 'configured base URL is not https; refusing the request',
579
582
  reasonCode: 'provider-base-url-insecure',
583
+ refusal: 'pre-invocation',
580
584
  authStatus: 'unknown',
581
585
  };
582
586
  }
@@ -587,7 +591,7 @@ async function runModel(opts = {}) {
587
591
  const fetchImpl = typeof opts.fetchImpl === 'function' ? opts.fetchImpl : fetch;
588
592
  const timeoutMs = opts.timeoutMs || 120000;
589
593
 
590
- const identity = { provider: 'byo-key', model: config.model, version: null, vendor };
594
+ const identity = { provider: 'byo-key', model: null, requested_model: config.model, observed_model: null, identity_unresolved: 'missing', version: null, vendor };
591
595
 
592
596
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
593
597
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -595,6 +599,8 @@ async function runModel(opts = {}) {
595
599
 
596
600
  try {
597
601
  let res;
602
+ const gate = require('./route-binding.js').invocationGate(opts);
603
+ if (gate) return gate;
598
604
  try {
599
605
  res = await fetchImpl(request.url, {
600
606
  method: 'POST',
@@ -611,7 +617,7 @@ async function runModel(opts = {}) {
611
617
  ok: false,
612
618
  text: '',
613
619
  usage: null,
614
- reason: `BYO provider request failed: ${error.message}`,
620
+ reason: 'BYO provider request failed',
615
621
  reasonCode: 'provider-error',
616
622
  authStatus: 'unknown',
617
623
  identity,
@@ -634,7 +640,7 @@ async function runModel(opts = {}) {
634
640
  ok: false,
635
641
  text: '',
636
642
  usage: null,
637
- reason: `BYO provider returned HTTP ${res.status}`,
643
+ reason: Number.isInteger(res.status) ? `BYO provider returned HTTP ${res.status}` : 'BYO provider request failed',
638
644
  reasonCode: 'provider-error',
639
645
  authStatus: 'unknown',
640
646
  identity,
@@ -660,13 +666,19 @@ async function runModel(opts = {}) {
660
666
  ok: false,
661
667
  text: '',
662
668
  usage: null,
663
- reason: `BYO provider returned a non-JSON body: ${error.message}`,
669
+ reason: 'BYO provider returned a non-JSON body',
664
670
  reasonCode: 'provider-error',
665
671
  authStatus: 'unknown',
666
672
  identity,
667
673
  };
668
674
  }
669
675
 
676
+ const observed = data && (vendor === 'gemini' ? data.modelVersion : data.model);
677
+ if (typeof observed === 'string' && observed.trim()) {
678
+ identity.observed_model = observed;
679
+ identity.model = observed;
680
+ delete identity.identity_unresolved;
681
+ }
670
682
  return {
671
683
  ok: true,
672
684
  text: extractText(vendor, data),
@@ -675,6 +687,8 @@ async function runModel(opts = {}) {
675
687
  authStatus: 'unknown',
676
688
  identity,
677
689
  };
690
+ } catch {
691
+ return { ok: false, text: '', usage: null, reason: 'BYO provider request failed', reasonCode: 'provider-error', authStatus: 'unknown' };
678
692
  } finally {
679
693
  clearTimer();
680
694
  }
@@ -689,6 +703,7 @@ module.exports = {
689
703
  writeByoConfig,
690
704
  clearProvidersFile,
691
705
  resolveVendor,
706
+ baseUrlFor,
692
707
  DEFAULT_PROVIDERS_STATE_PATH,
693
708
  // Exported for direct unit coverage (test/byo-key-provider.test.js,
694
709
  // test/extract-w1-fix2.test.js) AND for scripts/providers/index.js, which
@@ -21,6 +21,7 @@ const { spawnSync } = require('child_process');
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
  const os = require('os');
24
+ const { invocationGate } = require('./route-binding.js');
24
25
 
25
26
  /** Leading major.minor.patch only; unreadable versions retain fallback behavior. */
26
27
  function versionParts(version) {
@@ -219,7 +220,7 @@ const SCRUBBED_CLIENT_ENV_VARS = Object.freeze([
219
220
  // SETTING_SOURCES_ARGS to both — the child loads none of user/project/local
220
221
  // settings, so the operator's own SessionStart hooks never fire.
221
222
  // Strict mode with no --mcp-config also excludes account-connected MCP servers.
222
- const EXTRACT_MODE_ARGV = Object.freeze(['-p', '--no-session-persistence', '--tools', '', ...SETTING_SOURCES_ARGS, '--strict-mcp-config']);
223
+ const EXTRACT_MODE_ARGV = Object.freeze(['-p', '--output-format', 'json', '--no-session-persistence', '--tools', '', ...SETTING_SOURCES_ARGS, '--strict-mcp-config']);
223
224
  const JUDGE_MODE_ARGV = Object.freeze(['-p', '--output-format', 'json', '--no-session-persistence', '--tools', '', ...SETTING_SOURCES_ARGS, '--strict-mcp-config']);
224
225
 
225
226
  /**
@@ -402,9 +403,8 @@ function checkAuthStatus(opts = {}) {
402
403
  const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
403
404
  const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
404
405
  const version = versionParts(getClaudeCliVersion(bin, opts));
405
- // Older CLIs treat `auth status` as a model prompt. Unknown versions retain
406
- // the existing probe; only a known-old build can safely skip it here.
407
- if (version && compareVersions(version, [2, 1, 41]) < 0) return 'unknown';
406
+ // Unknown and old versions can interpret this probe as a model prompt.
407
+ if (!version || compareVersions(version, [2, 1, 41]) < 0) return 'unknown';
408
408
  let res;
409
409
  try {
410
410
  res = spawnSyncImpl(bin, ['auth', 'status'], {
@@ -455,84 +455,108 @@ function detect(opts = {}) {
455
455
  * needs to await beyond the spawn itself); `runModel` wraps it in a resolved
456
456
  * Promise, per the provider.interface.js contract.
457
457
  */
458
- function runExtractMode(opts) {
459
- const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
460
- const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
461
- if (cachedSettingSourcesUnsupported) return settingSourcesIsolationUnsupportedResult('unknown');
462
- const authStatus = checkAuthStatus({ spawnSyncImpl, claudeBin: bin, ...opts });
463
- if (authStatus === 'logged-out') {
464
- return {
465
- ok: false,
466
- text: '',
467
- usage: null,
468
- reason: 'local model not authenticated in this context (run `claude auth login` once); skipping deterministic extraction',
469
- reasonCode: 'cli-unauthenticated',
470
- authStatus,
471
- };
458
+ function runExtractMode(opts) { return runCliMode(opts, 'extract'); }
459
+
460
+ function decodeWrapper(stdout) {
461
+ let wrapper;
462
+ try { wrapper = JSON.parse(stdout); }
463
+ catch {
464
+ const values = [];
465
+ const lines = stdout.split('\n');
466
+ for (let i = 0; i < lines.length; i += 1) {
467
+ let value = lines[i].trim();
468
+ if (value.startsWith('{') || value.startsWith('[')) {
469
+ // Consume the whole container, including pretty-printed extra values.
470
+ // A line-only JSON.parse scan could silently overlook that second value.
471
+ let depth = 0; let quoted = false; let escaped = false; let closed = false;
472
+ const parts = [];
473
+ for (; i < lines.length; i += 1) {
474
+ const line = lines[i]; parts.push(line);
475
+ for (let j = 0; j < line.length; j += 1) {
476
+ const char = line[j];
477
+ if (quoted) {
478
+ if (escaped) escaped = false;
479
+ else if (char === '\\') escaped = true;
480
+ else if (char === '"') quoted = false;
481
+ } else if (char === '"') quoted = true;
482
+ else if (char === '{' || char === '[') depth += 1;
483
+ else if (char === '}' || char === ']') {
484
+ depth -= 1;
485
+ if (depth === 0) {
486
+ if (line.slice(j + 1).trim()) return { malformed: true };
487
+ closed = true; break;
488
+ }
489
+ }
490
+ }
491
+ if (closed) break;
492
+ }
493
+ if (!closed) return { malformed: true };
494
+ value = parts.join('\n');
495
+ try { values.push(JSON.parse(value)); } catch { return { malformed: true }; }
496
+ } else {
497
+ try { values.push(JSON.parse(value)); } catch { /* non-JSON diagnostic */ }
498
+ }
499
+ if (values.length > 1) return { malformed: true };
500
+ }
501
+ if (values.length !== 1 || !values[0] || values[0].type !== 'result') return { malformed: true };
502
+ [wrapper] = values;
472
503
  }
473
- const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
474
- const stdin = prompt + String(opts.input || '').slice(0, 200000);
475
- // --no-session-persistence (EXTRACT-PER-CLIENT W1 FIX GIVENS): matches the
476
- // judge spawn below — an extraction run leaves no session file behind either.
477
- // --setting-sources '' (EXTRACTION-CHILD-HOOKS, 0.9.15): the child loads none
478
- // of user/project/local settings, so the operator's own SessionStart hooks
479
- // never fire and their output never reaches this prompt.
480
- const argv = EXTRACT_MODE_ARGV;
504
+ if (!wrapper || wrapper.type !== 'result' || typeof wrapper.result !== 'string' || wrapper.is_error === true) {
505
+ return { unsuccessful: true, wrapper };
506
+ }
507
+ return { wrapper };
508
+ }
509
+
510
+ function wrapperIdentity(wrapper, cliVersion) {
511
+ const models = [...new Set(Object.values(wrapper.modelUsage || {}).map(entry => entry && entry.canonicalModel)
512
+ .filter(value => typeof value === 'string' && value.trim()))];
513
+ const observed = models.length === 1 ? models[0] : null;
514
+ return { provider: 'claude-code', model: observed, requested_model: null, observed_model: observed,
515
+ version: cliVersion || null, vendor: 'anthropic',
516
+ ...(observed === null && { identity_unresolved: models.length ? 'ambiguous' : 'missing' }) };
517
+ }
518
+
519
+ function runCliMode(opts, mode) {
520
+ const spawn = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
521
+ const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
522
+ if (cachedSettingSourcesUnsupported) return { ...settingSourcesIsolationUnsupportedResult('unknown'), refusal: 'pre-invocation' };
523
+ const authStatus = mode === 'extract' ? checkAuthStatus({ ...opts, claudeBin: bin }) : 'unknown';
524
+ const authReason = 'local model not authenticated in this context (run `claude auth login` once); skipping deterministic extraction';
525
+ if (authStatus === 'logged-out') return { ok: false, text: '', usage: null, reason: authReason,
526
+ reasonCode: 'cli-unauthenticated', authStatus, refusal: 'pre-invocation' };
527
+ const argv = mode === 'judge' ? JUDGE_MODE_ARGV : EXTRACT_MODE_ARGV;
481
528
  const cliVersion = getClaudeCliVersion(bin, opts);
529
+ const meta = { authStatus, argv, cliVersion };
530
+ const fail = (reasonCode, reason) => ({ ok: false, text: '', usage: null, reasonCode, reason, ...meta });
531
+ const gate = invocationGate(opts);
532
+ if (gate) return { ...gate, ...meta };
482
533
  let res;
483
534
  try {
484
- // --no-session-persistence (EXTRACT-PER-CLIENT W1 FIX GIVENS): matches the
485
- // judge spawn below — an extraction run leaves no session file behind either.
486
- res = spawnSyncImpl(bin, argv, {
487
- input: stdin,
488
- encoding: 'utf-8',
489
- env: claudeChildEnv(),
490
- timeout: opts.timeoutMs || 120000,
491
- maxBuffer: 20 * 1024 * 1024,
535
+ res = spawn(bin, argv, {
536
+ input: (typeof opts.prompt === 'string' ? opts.prompt : '') + (mode === 'extract' ? String(opts.input || '').slice(0, 200000) : ''),
537
+ encoding: 'utf-8', env: claudeChildEnv(), timeout: opts.timeoutMs || 120000, maxBuffer: 20 * 1024 * 1024,
492
538
  });
493
- } catch (error) {
494
- return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): ${error.message}`, reasonCode: 'unknown', authStatus, argv, cliVersion };
495
- }
496
- if (!res) {
497
- return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus, argv, cliVersion };
498
- }
539
+ } catch { return fail('unknown', 'local model spawn failed'); }
540
+ if (!res) return fail('unknown', 'local model returned no process result');
499
541
  if (looksLikeUnsupportedSettingSourcesFlag(res)) {
500
542
  cachedSettingSourcesUnsupported = true;
501
543
  return { ...settingSourcesIsolationUnsupportedResult(authStatus), argv, cliVersion };
502
544
  }
503
- const out = String(res.stdout || '');
504
- if (res.error) {
505
- return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): ${res.error.message}`, reasonCode: 'unknown', authStatus, argv, cliVersion };
506
- }
507
- const mcpRefusal = enterpriseMcpRefusal(res, authStatus);
508
- if (mcpRefusal) return { ...mcpRefusal, argv, cliVersion };
509
- // Claude prints auth failures ("API Error: 401 ... Please run /login") to stdout.
510
- if (/Please run \/login|authentication_error|401/i.test(out) || /Please run \/login|authentication_error/i.test(String(res.stderr || ''))) {
511
- return {
512
- ok: false,
513
- text: out,
514
- usage: null,
515
- reason: 'local model not authenticated in this context (run `claude auth login` once); skipping deterministic extraction',
516
- reasonCode: 'cli-unauthenticated',
517
- authStatus,
518
- argv,
519
- cliVersion,
520
- ...(authStatus === 'logged-in' && { authDiscrepancy: true }),
521
- };
522
- }
523
- if (res.status !== 0) {
524
- return {
525
- ok: false,
526
- text: out,
527
- usage: null,
528
- reason: `local model exited ${res.status}: ${(out || String(res.stderr || '')).slice(0, 160)}`,
529
- reasonCode: 'model-error',
530
- authStatus,
531
- argv,
532
- cliVersion,
533
- };
545
+ if (res.error || (res.signal && res.status === null)) return fail('unknown', 'local model invocation failed');
546
+ const mcp = enterpriseMcpRefusal(res, authStatus);
547
+ if (mcp) return { ...mcp, argv, cliVersion };
548
+ const stdout = String(res.stdout || '');
549
+ const decoded = decodeWrapper(stdout);
550
+ // A wrapper's result is content, never an authentication signal.
551
+ const authPattern = /Please run \/login|authentication_error/i;
552
+ if ((!decoded.wrapper && authPattern.test(stdout)) || authPattern.test(String(res.stderr || ''))) {
553
+ return { ...fail('cli-unauthenticated', authReason), ...(authStatus === 'logged-in' && { authDiscrepancy: true }) };
534
554
  }
535
- return { ok: true, text: out, usage: null, reason: null, authStatus, argv, cliVersion };
555
+ if (res.status !== 0) return fail('model-error', 'local model exited unsuccessfully');
556
+ if (decoded.malformed) return fail('model-error', mode === 'judge' ? 'local judge returned malformed JSON wrapper' : 'local model returned malformed JSON wrapper');
557
+ if (decoded.unsuccessful) return fail('model-error', 'local model returned no successful result');
558
+ return { ok: true, text: decoded.wrapper.result, usage: normalizeJudgeUsage(decoded.wrapper.usage), reason: null,
559
+ ...meta, identity: wrapperIdentity(decoded.wrapper, cliVersion) };
536
560
  }
537
561
 
538
562
  /**
@@ -557,75 +581,7 @@ function normalizeJudgeUsage(rawUsage) {
557
581
 
558
582
  /** mode:'judge' — binary anchored-dedup decision. Argv byte-identical to pre-move plus
559
583
  * the same --setting-sources '' isolation the extraction spawn above gains (0.9.15). */
560
- function runJudgeMode(opts) {
561
- const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
562
- const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
563
- if (cachedSettingSourcesUnsupported) return settingSourcesIsolationUnsupportedResult('unknown');
564
- const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
565
- const argv = JUDGE_MODE_ARGV;
566
- const cliVersion = getClaudeCliVersion(bin, opts);
567
- let res;
568
- try {
569
- res = spawnSyncImpl(bin, argv, {
570
- input: prompt,
571
- encoding: 'utf8',
572
- env: claudeChildEnv(),
573
- timeout: opts.timeoutMs || 120000,
574
- maxBuffer: 20 * 1024 * 1024,
575
- });
576
- } catch (error) {
577
- return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): ${error.message}`, reasonCode: 'unknown', authStatus: 'unknown', argv, cliVersion };
578
- }
579
- if (!res) {
580
- return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus: 'unknown', argv, cliVersion };
581
- }
582
- if (looksLikeUnsupportedSettingSourcesFlag(res)) {
583
- cachedSettingSourcesUnsupported = true;
584
- return { ...settingSourcesIsolationUnsupportedResult('unknown'), argv, cliVersion };
585
- }
586
- const stdout = String(res.stdout || '');
587
- if (res.error) {
588
- return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): ${res.error.message}`, reasonCode: 'unknown', authStatus: 'unknown', argv, cliVersion };
589
- }
590
- const mcpRefusal = enterpriseMcpRefusal(res, 'unknown');
591
- if (mcpRefusal) return { ...mcpRefusal, argv, cliVersion };
592
- if (/Please run \/login|authentication_error|401/i.test(stdout) || /Please run \/login|authentication_error/i.test(String(res.stderr || ''))) {
593
- return { ok: false, text: '', usage: null, reason: 'local judge model is not authenticated', reasonCode: 'cli-unauthenticated', authStatus: 'unknown', argv, cliVersion };
594
- }
595
- if (res.status !== 0) {
596
- return {
597
- ok: false,
598
- text: '',
599
- usage: null,
600
- reason: `local judge exited ${res.status}: ${(stdout || String(res.stderr || '')).slice(0, 160)}`,
601
- reasonCode: 'model-error',
602
- authStatus: 'unknown',
603
- argv,
604
- cliVersion,
605
- };
606
- }
607
- let wrapper;
608
- try {
609
- wrapper = JSON.parse(stdout);
610
- } catch {
611
- // Some CLI builds append a non-JSON MCP SDK diagnostic to stdout. Recover
612
- // only a single result value; an extra JSON value of ANY type is ambiguous.
613
- const values = [];
614
- for (const rawLine of stdout.split('\n')) {
615
- const line = rawLine.trim();
616
- if (!line) continue;
617
- try { values.push(JSON.parse(line)); } catch { /* discard non-JSON noise */ }
618
- }
619
- if (values.length !== 1 || !values[0] || typeof values[0] !== 'object' || values[0].type !== 'result') {
620
- return { ok: false, text: '', usage: null, reason: 'local judge returned malformed JSON wrapper', reasonCode: 'model-error', authStatus: 'unknown', argv, cliVersion };
621
- }
622
- [wrapper] = values;
623
- }
624
- if (!wrapper || typeof wrapper.result !== 'string' || wrapper.is_error === true) {
625
- return { ok: false, text: '', usage: null, reason: 'local judge returned no successful result', reasonCode: 'model-error', authStatus: 'unknown', argv, cliVersion };
626
- }
627
- return { ok: true, text: wrapper.result, usage: normalizeJudgeUsage(wrapper.usage), reason: null, authStatus: 'unknown', argv, cliVersion };
628
- }
584
+ function runJudgeMode(opts) { return runCliMode(opts, 'judge'); }
629
585
 
630
586
  /**
631
587
  * runModel(opts) — the provider.interface.js contract. Checks the billing-helper
@@ -633,7 +589,7 @@ function runJudgeMode(opts) {
633
589
  * its existing pre-spawn auth short-circuit, mode:'judge' spawns directly as it
634
590
  * always has (auth failure there is only knowable post-hoc, from the output).
635
591
  */
636
- async function runModel(opts = {}) {
592
+ function runModelImpl(opts) {
637
593
  if (detectBillingHelperConfigured(opts)) {
638
594
  return {
639
595
  ok: false,
@@ -641,6 +597,7 @@ async function runModel(opts = {}) {
641
597
  usage: null,
642
598
  reason: 'a foreign-billing CLI helper is configured — extraction declines to run under it',
643
599
  reasonCode: 'cli-billing-helper-configured',
600
+ refusal: 'pre-invocation',
644
601
  authStatus: 'unknown',
645
602
  };
646
603
  }
@@ -648,6 +605,11 @@ async function runModel(opts = {}) {
648
605
  return mode === 'judge' ? runJudgeMode(opts) : runExtractMode(opts);
649
606
  }
650
607
 
608
+ async function runModel(opts = {}) {
609
+ try { return runModelImpl(opts); }
610
+ catch { return { ok: false, text: '', usage: null, reasonCode: 'unknown', reason: 'local model invocation failed', authStatus: 'unknown' }; }
611
+ }
612
+
651
613
  /**
652
614
  * Legacy-shaped synchronous entry point — extractWithClaudeCode(transcript, opts).
653
615
  * Kept (not deleted) because test/ext-0806b-silent-skip.test.js imports it
@@ -663,6 +625,7 @@ function extractWithClaudeCode(transcript, opts = {}) {
663
625
  out: '',
664
626
  reason: 'a foreign-billing CLI helper is configured — extraction declines to run under it',
665
627
  reasonCode: 'cli-billing-helper-configured',
628
+ refusal: 'pre-invocation',
666
629
  authStatus: 'unknown',
667
630
  };
668
631
  }