scoutline 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +72 -44
  2. package/dist/capabilities/vision.d.ts +4 -3
  3. package/dist/capabilities/vision.d.ts.map +1 -1
  4. package/dist/capabilities/vision.js +7 -5
  5. package/dist/capabilities/vision.js.map +1 -1
  6. package/dist/commands/crawl.d.ts.map +1 -1
  7. package/dist/commands/crawl.js +14 -3
  8. package/dist/commands/crawl.js.map +1 -1
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +15 -10
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/map.d.ts.map +1 -1
  13. package/dist/commands/map.js +16 -3
  14. package/dist/commands/map.js.map +1 -1
  15. package/dist/commands/read.d.ts.map +1 -1
  16. package/dist/commands/read.js +9 -3
  17. package/dist/commands/read.js.map +1 -1
  18. package/dist/commands/repo.d.ts.map +1 -1
  19. package/dist/commands/repo.js +5 -3
  20. package/dist/commands/repo.js.map +1 -1
  21. package/dist/commands/research.d.ts +35 -7
  22. package/dist/commands/research.d.ts.map +1 -1
  23. package/dist/commands/research.js +93 -15
  24. package/dist/commands/research.js.map +1 -1
  25. package/dist/commands/vision.d.ts.map +1 -1
  26. package/dist/commands/vision.js +2 -1
  27. package/dist/commands/vision.js.map +1 -1
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +338 -250
  31. package/dist/index.js.map +1 -1
  32. package/dist/lib/errors.d.ts +11 -0
  33. package/dist/lib/errors.d.ts.map +1 -1
  34. package/dist/lib/errors.js +14 -0
  35. package/dist/lib/errors.js.map +1 -1
  36. package/dist/lib/execution.d.ts.map +1 -1
  37. package/dist/lib/execution.js +12 -8
  38. package/dist/lib/execution.js.map +1 -1
  39. package/dist/lib/provider-fallback.d.ts +150 -0
  40. package/dist/lib/provider-fallback.d.ts.map +1 -0
  41. package/dist/lib/provider-fallback.js +510 -0
  42. package/dist/lib/provider-fallback.js.map +1 -0
  43. package/dist/providers/brave/adapter.d.ts.map +1 -1
  44. package/dist/providers/brave/adapter.js +2 -0
  45. package/dist/providers/brave/adapter.js.map +1 -1
  46. package/dist/providers/exa/adapter.d.ts.map +1 -1
  47. package/dist/providers/exa/adapter.js +10 -1
  48. package/dist/providers/exa/adapter.js.map +1 -1
  49. package/dist/providers/firecrawl/adapter.d.ts.map +1 -1
  50. package/dist/providers/firecrawl/adapter.js +13 -3
  51. package/dist/providers/firecrawl/adapter.js.map +1 -1
  52. package/dist/providers/minimax/adapter.d.ts.map +1 -1
  53. package/dist/providers/minimax/adapter.js +4 -0
  54. package/dist/providers/minimax/adapter.js.map +1 -1
  55. package/dist/providers/tavily/adapter.d.ts.map +1 -1
  56. package/dist/providers/tavily/adapter.js +2 -0
  57. package/dist/providers/tavily/adapter.js.map +1 -1
  58. package/dist/providers/types.d.ts +28 -0
  59. package/dist/providers/types.d.ts.map +1 -1
  60. package/dist/providers/types.js +8 -0
  61. package/dist/providers/types.js.map +1 -1
  62. package/dist/providers/zai/adapter.d.ts.map +1 -1
  63. package/dist/providers/zai/adapter.js +4 -0
  64. package/dist/providers/zai/adapter.js.map +1 -1
  65. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -17,12 +17,13 @@ import { isExtractMode } from "./lib/extract.js";
17
17
  import { runCodeFile, evalCode, printInterfaces, printPromptTemplate, CODE_HELP, } from "./commands/code.js";
18
18
  import { isOutputMode, OUTPUT_MODES } from "./lib/output.js";
19
19
  import { formatErrorOutput } from "./lib/output.js";
20
- import { ConfigurationError, ValidationError, UnsupportedCapabilityError, getErrorExitCode, } from "./lib/errors.js";
20
+ import { ValidationError, UnsupportedCapabilityError, getErrorExitCode, } from "./lib/errors.js";
21
21
  import { invokeCommand } from "./command-invocation.js";
22
22
  import { defaultResponseCache } from "./lib/cache.js";
23
23
  import { configuredSecrets } from "./lib/redact.js";
24
24
  import { resolveProviderId } from "./providers/selection.js";
25
- import { BUILT_IN_PROVIDER_DESCRIPTORS, getProviderDescriptor } from "./providers/registry.js";
25
+ import { BUILT_IN_PROVIDER_DESCRIPTORS } from "./providers/registry.js";
26
+ import { executeWithFallback } from "./lib/provider-fallback.js";
26
27
  import { visionOperationToCapability } from "./capabilities/vision.js";
27
28
  import { createRequire } from "node:module";
28
29
  const require = createRequire(import.meta.url);
@@ -64,11 +65,14 @@ and 'research' commands participate in Provider selection: Z.AI
64
65
  advertises and supplies repository-exploration and reader; Tavily
65
66
  advertises and supplies reader plus crawl, map, and research; Exa
66
67
  advertises and supplies search, reader, and research; MiniMax
67
- advertises and supplies none of those Provider-only Capabilities. A
68
- non-supplier returns UNSUPPORTED_CAPABILITY with no fallback. Z.AI-only
69
- commands (tools, tool, call, code) carry the flag but ignore it.
70
- Quota and doctor report per-Provider; --provider picks the effective
71
- Provider for metadata.
68
+ advertises and supplies none of those Provider-only Capabilities.
69
+ Provider fallback is always-on by default (0.11.0+): selecting a
70
+ non-supplier emits a stderr notice and silently reroutes to the next
71
+ eligible configured Provider in registry order. Use --no-fallback (or
72
+ SCOUTLINE_NO_FALLBACK=1) to restore the previous strict
73
+ UNSUPPORTED_CAPABILITY behavior. Z.AI-only commands (tools, tool,
74
+ call, code) carry the flag but ignore it. Quota and doctor report
75
+ per-Provider; --provider picks the effective Provider for metadata.
72
76
 
73
77
  Global Options:
74
78
  --output-format <data|json|pretty|compact|markdown|refs|tty> Output mode (default: data)
@@ -137,6 +141,7 @@ function extractGlobalOptions(args) {
137
141
  let forcePretty = false;
138
142
  let forceRaw = false;
139
143
  let provider;
144
+ let noFallback = false;
140
145
  for (let i = 0; i < args.length; i += 1) {
141
146
  const arg = args[i];
142
147
  if (arg === "--output-format" || arg === "-O") {
@@ -161,9 +166,19 @@ function extractGlobalOptions(args) {
161
166
  i += 1;
162
167
  continue;
163
168
  }
169
+ if (arg === "--no-fallback") {
170
+ // Provider-fallback kill-switch. Accepted before OR after the
171
+ // command token and removed from the rest stream so command-local
172
+ // parsing never observes it. Resolution against
173
+ // `SCOUTLINE_NO_FALLBACK` lives in `main` so a test that drives
174
+ // `extractGlobalOptions` directly can assert the flag shape in
175
+ // isolation.
176
+ noFallback = true;
177
+ continue;
178
+ }
164
179
  rest.push(arg);
165
180
  }
166
- return { outputFormat, forcePretty, forceRaw, provider, rest };
181
+ return { outputFormat, forcePretty, forceRaw, provider, noFallback, rest };
167
182
  }
168
183
  function resolveOutputMode(explicit, forcePretty, forceRaw, adapter) {
169
184
  if (explicit !== undefined) {
@@ -200,63 +215,83 @@ async function handleVision(args, outputMode, deps) {
200
215
  // Resolve the effective Provider for Vision (DESIGN.md §6). Invalid
201
216
  // explicit/env input fails here with VALIDATION_ERROR before any Vision
202
217
  // support check or media access. Selection never consults credentials
203
- // (FR-003); the configured check below is the caller's responsibility.
218
+ // (FR-003); the configured check below is the executor's responsibility
219
+ // (Provider Fallback Tech Plan §"Per-handler refactor pattern").
204
220
  const providerId = resolveProviderId(deps.provider, deps.env);
205
- const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
206
- // Gate the operation on descriptor metadata BEFORE Adapter construction.
207
- // An unsupported operation (e.g. MiniMax for any specialized op, diff, or
208
- // video) fails with UNSUPPORTED_CAPABILITY before credentials, media,
209
- // transport, cache, or any Z.AI fallback (FR-023, FR-024). No command
210
- // branches on a Provider ID: the support check alone decides availability.
221
+ // Per-operation capability id (e.g. `vision.interpret-image`,
222
+ // `vision.chart`). The executor's preflight uses this to filter
223
+ // descriptors, and notice wording carries it verbatim. Vision
224
+ // sub-operations share the same `adapter.vision` slot — the
225
+ // per-operation capability is in the descriptor metadata.
211
226
  const capabilityId = visionOperationToCapability(operation);
212
- if (!descriptor.capabilities().has(capabilityId)) {
213
- throw new UnsupportedCapabilityError(providerId, capabilityId);
214
- }
215
- // FR-003: selection returns the default zai even when unconfigured. The
216
- // dispatch layer surfaces a missing credential as ConfigurationError
217
- // (exit 3), AFTER the capability support check (FR-023) but before any
218
- // Adapter construction or media access (Fixup A — B5).
219
- if (!descriptor.isConfigured(deps.env)) {
220
- throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
221
- }
222
- const adapter = descriptor.create({ env: deps.env });
223
- const visionCapability = adapter.vision;
224
- if (!visionCapability || !visionCapability.supports(operation)) {
225
- throw new UnsupportedCapabilityError(providerId, capabilityId);
226
- }
227
227
  // Vision bypasses the response cache (FR-022). The shared execution
228
228
  // primitives (sleep/random) are the same ones Search consumes; they
229
229
  // drive retry backoff deterministically under test.
230
- const visionDeps = {
231
- capability: visionCapability,
230
+ const visionDepsShape = {
232
231
  sleep: deps.searchSleep,
233
232
  random: deps.searchRandom,
234
233
  };
235
- switch (command) {
236
- case "analyze":
237
- return invokeCommand(deps.invocation, (context) => vision.analyze(source, prompt, visionDeps, context), outputMode, deps.now, deps.secrets);
238
- case "ui-to-code": {
239
- const outputType = flags.output || "code";
240
- return invokeCommand(deps.invocation, (context) => vision.uiToCode(source, prompt, outputType, visionDeps, context), outputMode, deps.now, deps.secrets);
241
- }
242
- case "extract-text":
243
- return invokeCommand(deps.invocation, (context) => vision.extractText(source, prompt, flags.language, visionDeps, context), outputMode, deps.now, deps.secrets);
244
- case "diagnose-error":
245
- return invokeCommand(deps.invocation, (context) => vision.diagnoseError(source, prompt, flags.context, visionDeps, context), outputMode, deps.now, deps.secrets);
246
- case "diagram":
247
- return invokeCommand(deps.invocation, (context) => vision.diagram(source, prompt, flags.type, visionDeps, context), outputMode, deps.now, deps.secrets);
248
- case "chart":
249
- return invokeCommand(deps.invocation, (context) => vision.chart(source, prompt, flags.focus, visionDeps, context), outputMode, deps.now, deps.secrets);
250
- case "diff": {
251
- const actual = positional[2];
252
- const diffPrompt = positional[3];
253
- return invokeCommand(deps.invocation, (context) => vision.diff(source, actual, diffPrompt, visionDeps, context), outputMode, deps.now, deps.secrets);
254
- }
255
- case "video":
256
- return invokeCommand(deps.invocation, (context) => vision.video(source, prompt, visionDeps, context), outputMode, deps.now, deps.secrets);
257
- default:
258
- throw new ValidationError(`Unknown vision command: ${command}`, 'Run "scoutline vision --help" for available commands');
259
- }
234
+ // Provider-fallback Ticket 02: route every vision operation through
235
+ // the shared executor. The existing vision URL→temp-file fallback
236
+ // stays inside the Z.AI adapter's invoke, layered beneath provider
237
+ // fallback.
238
+ return invokeCommand(deps.invocation, async (context) => {
239
+ const outcome = await executeWithFallback({
240
+ capabilityId,
241
+ commandLabel: "vision",
242
+ effectiveProvider: providerId,
243
+ descriptors: deps.providerDescriptors,
244
+ env: deps.env,
245
+ fallbackEnabled: deps.fallbackEnabled,
246
+ writeStderr: (s) => deps.invocation.writeStderr(s),
247
+ }, async (descriptor) => {
248
+ const adapter = descriptor.create({ env: deps.env });
249
+ // The executor's preflight guarantees the descriptor advertises
250
+ // the capability and supplies a non-null adapter slot, so the
251
+ // cast is safe. Operation-level support is checked immediately
252
+ // against `visionCapability.supports(operation)` — descriptor
253
+ // metadata and adapter capability are independent sources of
254
+ // truth, and the live dispatch must fail closed on disagreement
255
+ // (Review Fix 4). A mismatch throws `UnsupportedCapabilityError`
256
+ // before any media work / transport use, and the Executor treats
257
+ // it as a `continue` so the next candidate (if any) gets a
258
+ // chance.
259
+ const visionCapability = adapter.vision;
260
+ if (!visionCapability || !visionCapability.supports(operation)) {
261
+ throw new UnsupportedCapabilityError(descriptor.id, capabilityId);
262
+ }
263
+ const visionDeps = {
264
+ capability: visionCapability,
265
+ ...visionDepsShape,
266
+ };
267
+ switch (command) {
268
+ case "analyze":
269
+ return vision.analyze(source, prompt, visionDeps, context);
270
+ case "ui-to-code": {
271
+ const outputType = flags.output || "code";
272
+ return vision.uiToCode(source, prompt, outputType, visionDeps, context);
273
+ }
274
+ case "extract-text":
275
+ return vision.extractText(source, prompt, flags.language, visionDeps, context);
276
+ case "diagnose-error":
277
+ return vision.diagnoseError(source, prompt, flags.context, visionDeps, context);
278
+ case "diagram":
279
+ return vision.diagram(source, prompt, flags.type, visionDeps, context);
280
+ case "chart":
281
+ return vision.chart(source, prompt, flags.focus, visionDeps, context);
282
+ case "diff": {
283
+ const actual = positional[2];
284
+ const diffPrompt = positional[3];
285
+ return vision.diff(source, actual, diffPrompt, visionDeps, context);
286
+ }
287
+ case "video":
288
+ return vision.video(source, prompt, visionDeps, context);
289
+ default:
290
+ throw new ValidationError(`Unknown vision command: ${command}`, 'Run "scoutline vision --help" for available commands');
291
+ }
292
+ });
293
+ return outcome.result;
294
+ }, outputMode, deps.now, deps.secrets);
260
295
  }
261
296
  /**
262
297
  * Map a Vision subcommand to its discriminated operation id. Used by
@@ -380,29 +415,14 @@ async function handleSearch(args, outputMode, deps) {
380
415
  if (type !== undefined && topic !== undefined) {
381
416
  throw new ValidationError("--type and --topic are mutually exclusive (--type has no editorial topic axis).", "Pass either --type or --topic, not both.");
382
417
  }
383
- // Resolve the Provider ONLY inside shared Search (DESIGN.md §6). Other
384
- // command families carry the parsed flag but never resolve or validate
385
- // it. An invalid explicit/env value throws VALIDATION_ERROR here, before
386
- // any Adapter construction or invocation. Selection never consults
387
- // credentials (FR-003); the configured check below is the caller's
388
- // responsibility (Fixup A — B5).
418
+ // Resolve the effective Provider ONLY inside shared Search (DESIGN.md §6).
419
+ // Other command families carry the parsed flag but never resolve or
420
+ // validate it. An invalid explicit/env value throws VALIDATION_ERROR
421
+ // here, before any Adapter construction or invocation. Selection never
422
+ // consults credentials (FR-003); the configured check is the
423
+ // executor's responsibility (Provider Fallback Tech Plan §"Per-handler
424
+ // refactor pattern").
389
425
  const providerId = resolveProviderId(deps.provider, deps.env);
390
- const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
391
- if (!descriptor.capabilities().has("search")) {
392
- throw new UnsupportedCapabilityError(providerId, "search");
393
- }
394
- // FR-003: selection returns the default zai even when unconfigured. The
395
- // dispatch layer surfaces a missing credential as ConfigurationError
396
- // (exit 3), AFTER the capability support check (FR-023) but before any
397
- // Adapter construction (Fixup A — B5).
398
- if (!descriptor.isConfigured(deps.env)) {
399
- throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
400
- }
401
- const adapter = descriptor.create({ env: deps.env });
402
- const capability = adapter.search;
403
- if (!capability) {
404
- throw new UnsupportedCapabilityError(providerId, "search");
405
- }
406
426
  const query = positional.join(" ");
407
427
  const fieldsRaw = flags.fields;
408
428
  const fields = fieldsRaw
@@ -411,7 +431,7 @@ async function handleSearch(args, outputMode, deps) {
411
431
  .map((f) => f.trim())
412
432
  .filter(Boolean)
413
433
  : undefined;
414
- return invokeCommand(deps.invocation, (context) => search(query, {
434
+ const searchOptions = {
415
435
  count,
416
436
  domain: flags.domain,
417
437
  recency: flags.recency,
@@ -425,12 +445,36 @@ async function handleSearch(args, outputMode, deps) {
425
445
  fields: fields && fields.length > 0 ? fields : undefined,
426
446
  noCache: flags["no-cache"] === true,
427
447
  merge: flags.merge === true,
428
- }, {
429
- capability,
430
- cache: deps.searchCache,
431
- sleep: deps.searchSleep,
432
- random: deps.searchRandom,
433
- }, context), outputMode, deps.now, deps.secrets);
448
+ };
449
+ // Provider-fallback Ticket 02: route the call through the shared
450
+ // fallback executor. The executor owns capability+configured+adapter
451
+ // preflight (FR-023/024), candidate plan ordering, error
452
+ // classification, exhaustion semantics, and notices. The handler
453
+ // keeps command-specific work (request building, flag parsing, which
454
+ // executeX). `--merge` runs the whole parallel sub-query batch inside
455
+ // a single attempt so a fallback switch replaces the entire batch,
456
+ // never individual sub-queries (Tech Plan §"Handler non-uniformity").
457
+ return invokeCommand(deps.invocation, async (context) => {
458
+ const outcome = await executeWithFallback({
459
+ capabilityId: "search",
460
+ commandLabel: "search",
461
+ effectiveProvider: providerId,
462
+ descriptors: deps.providerDescriptors,
463
+ env: deps.env,
464
+ fallbackEnabled: deps.fallbackEnabled,
465
+ writeStderr: (s) => deps.invocation.writeStderr(s),
466
+ }, async (descriptor) => {
467
+ const adapter = descriptor.create({ env: deps.env });
468
+ const capability = adapter.search;
469
+ return search(query, searchOptions, {
470
+ capability,
471
+ cache: deps.searchCache,
472
+ sleep: deps.searchSleep,
473
+ random: deps.searchRandom,
474
+ }, context);
475
+ });
476
+ return outcome.result;
477
+ }, outputMode, deps.now, deps.secrets);
434
478
  }
435
479
  async function handleRead(args, outputMode, deps) {
436
480
  const { flags, positional } = parseArgs(args);
@@ -460,40 +504,7 @@ async function handleRead(args, outputMode, deps) {
460
504
  // never consults credentials (FR-003) and never branches on Provider
461
505
  // ID; an unknown explicit/env value throws VALIDATION_ERROR here.
462
506
  const providerId = resolveProviderId(deps.provider, deps.env);
463
- const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
464
- // Capability support check BEFORE `descriptor.isConfigured`,
465
- // `descriptor.create`, Adapter access, operation validation/
466
- // cacheIdentity, credential use, cache, or transport. Unsupported
467
- // MiniMax (explicit or environment) returns UNSUPPORTED_CAPABILITY
468
- // with no fallback to Z.AI; zero selected-Provider work occurs.
469
- if (!descriptor.capabilities().has("reader")) {
470
- throw new UnsupportedCapabilityError(providerId, "reader");
471
- }
472
- // FR-003: selection returns the default zai even when unconfigured.
473
- // Supported-but-unconfigured Z.AI surfaces a missing credential as
474
- // ConfigurationError (exit 3) AFTER the support metadata check and
475
- // BEFORE `descriptor.create`.
476
- if (!descriptor.isConfigured(deps.env)) {
477
- throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
478
- }
479
- const adapter = descriptor.create({ env: deps.env });
480
- const capability = adapter.reader;
481
- // Defensive fail-closed: the descriptor advertised support but the
482
- // Adapter omitted the handle. Treat as unsupported so a registry
483
- // mismatch can never reach transport.
484
- if (!capability) {
485
- throw new UnsupportedCapabilityError(providerId, "reader");
486
- }
487
- // Shared Reader execution dependencies. The cache/sleep/random
488
- // default to the same production values as Search and Repository
489
- // but are kept as separate optional MainDependencies so reader
490
- // tests can inject isolated in-memory doubles.
491
- const executionDeps = {
492
- cache: deps.readerCache,
493
- sleep: deps.readerSleep,
494
- random: deps.readerRandom,
495
- };
496
- return invokeCommand(deps.invocation, (context) => read(url, {
507
+ const readOptions = {
497
508
  format: flags.format,
498
509
  noImages: flags["no-images"] === true,
499
510
  noCache: flags["no-cache"] === true,
@@ -505,7 +516,39 @@ async function handleRead(args, outputMode, deps) {
505
516
  maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
506
517
  fullEnvelope: flags["full-envelope"] === true,
507
518
  extract,
508
- }, { capability, execution: executionDeps }, context), outputMode, deps.now, deps.secrets);
519
+ };
520
+ // Shared Reader execution dependencies. The cache/sleep/random
521
+ // default to the same production values as Search and Repository
522
+ // but are kept as separate optional MainDependencies so reader
523
+ // tests can inject isolated in-memory doubles.
524
+ const executionDeps = {
525
+ cache: deps.readerCache,
526
+ sleep: deps.readerSleep,
527
+ random: deps.readerRandom,
528
+ };
529
+ // Provider-fallback Ticket 02: route the call through the shared
530
+ // fallback executor. Capability + configured + adapter-handle
531
+ // agreement preflight now lives in the executor (FR-023/024
532
+ // preserved). Under --no-fallback the same preflight runs on the
533
+ // effective Provider only.
534
+ return invokeCommand(deps.invocation, async (context) => {
535
+ const outcome = await executeWithFallback({
536
+ capabilityId: "reader",
537
+ commandLabel: "read",
538
+ effectiveProvider: providerId,
539
+ descriptors: deps.providerDescriptors,
540
+ env: deps.env,
541
+ fallbackEnabled: deps.fallbackEnabled,
542
+ writeStderr: (s) => deps.invocation.writeStderr(s),
543
+ }, async (descriptor) => {
544
+ const adapter = descriptor.create({ env: deps.env });
545
+ return read(url, readOptions, {
546
+ capability: adapter.reader,
547
+ execution: executionDeps,
548
+ }, context);
549
+ });
550
+ return outcome.result;
551
+ }, outputMode, deps.now, deps.secrets);
509
552
  }
510
553
  async function handleCrawl(args, outputMode, deps) {
511
554
  const { flags, positional } = parseArgs(args);
@@ -524,21 +567,6 @@ async function handleCrawl(args, outputMode, deps) {
524
567
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
525
568
  // explicit --provider > SCOUTLINE_PROVIDER > default zai.
526
569
  const providerId = resolveProviderId(deps.provider, deps.env);
527
- const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
528
- // Capability support check BEFORE descriptor.isConfigured, descriptor.create,
529
- // or any Adapter work. Unsupported Z.AI/MiniMax returns
530
- // UNSUPPORTED_CAPABILITY with no fallback to Tavily.
531
- if (!descriptor.capabilities().has("crawl")) {
532
- throw new UnsupportedCapabilityError(providerId, "crawl");
533
- }
534
- if (!descriptor.isConfigured(deps.env)) {
535
- throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
536
- }
537
- const adapter = descriptor.create({ env: deps.env });
538
- const capability = adapter.crawl;
539
- if (!capability) {
540
- throw new UnsupportedCapabilityError(providerId, "crawl");
541
- }
542
570
  // Shared Crawl execution dependencies. The cache/sleep/random default
543
571
  // to the same production values as Search/Repository/Reader but are
544
572
  // kept as separate optional MainDependencies so crawl tests can inject
@@ -548,19 +576,40 @@ async function handleCrawl(args, outputMode, deps) {
548
576
  sleep: deps.crawlSleep,
549
577
  random: deps.crawlRandom,
550
578
  };
551
- return invokeCommand(deps.invocation, (context) => crawl(url, {
552
- depth: flags.depth ? parseInt(flags.depth, 10) : undefined,
553
- breadth: flags.breadth ? parseInt(flags.breadth, 10) : undefined,
554
- limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
555
- selectPaths: flags["select-paths"],
556
- excludePaths: flags["exclude-paths"],
557
- instructions: flags.instructions,
558
- format: flags.format,
559
- contentSize: flags["content-size"],
560
- timeout: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
561
- noCache: flags["no-cache"] === true,
562
- maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
563
- }, { capability, execution: executionDeps }, context), outputMode, deps.now, deps.secrets);
579
+ // Provider-fallback Ticket 03: route the call through the shared
580
+ // fallback executor. Capability + configured + adapter-handle
581
+ // preflight lives in the executor (FR-023/024 preserved). The
582
+ // async cost-bearing accepted-risk path (Tech Plan §"Accepted
583
+ // risk"): a runtime failure on crawl may fall back to another
584
+ // Provider even if the failed Provider had already accepted
585
+ // the job. `--no-fallback` eliminates this risk.
586
+ return invokeCommand(deps.invocation, async (context) => {
587
+ const outcome = await executeWithFallback({
588
+ capabilityId: "crawl",
589
+ commandLabel: "crawl",
590
+ effectiveProvider: providerId,
591
+ descriptors: deps.providerDescriptors,
592
+ env: deps.env,
593
+ fallbackEnabled: deps.fallbackEnabled,
594
+ writeStderr: (s) => deps.invocation.writeStderr(s),
595
+ }, async (descriptor) => {
596
+ const adapter = descriptor.create({ env: deps.env });
597
+ return crawl(url, {
598
+ depth: flags.depth ? parseInt(flags.depth, 10) : undefined,
599
+ breadth: flags.breadth ? parseInt(flags.breadth, 10) : undefined,
600
+ limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
601
+ selectPaths: flags["select-paths"],
602
+ excludePaths: flags["exclude-paths"],
603
+ instructions: flags.instructions,
604
+ format: flags.format,
605
+ contentSize: flags["content-size"],
606
+ timeout: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
607
+ noCache: flags["no-cache"] === true,
608
+ maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
609
+ }, { capability: adapter.crawl, execution: executionDeps }, context);
610
+ });
611
+ return outcome.result;
612
+ }, outputMode, deps.now, deps.secrets);
564
613
  }
565
614
  async function handleMap(args, outputMode, deps) {
566
615
  const { flags, positional } = parseArgs(args);
@@ -579,21 +628,6 @@ async function handleMap(args, outputMode, deps) {
579
628
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
580
629
  // explicit --provider > SCOUTLINE_PROVIDER > default zai.
581
630
  const providerId = resolveProviderId(deps.provider, deps.env);
582
- const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
583
- // Capability support check BEFORE descriptor.isConfigured, descriptor.create,
584
- // or any Adapter work. Unsupported Z.AI/MiniMax returns
585
- // UNSUPPORTED_CAPABILITY with no fallback to Tavily.
586
- if (!descriptor.capabilities().has("map")) {
587
- throw new UnsupportedCapabilityError(providerId, "map");
588
- }
589
- if (!descriptor.isConfigured(deps.env)) {
590
- throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
591
- }
592
- const adapter = descriptor.create({ env: deps.env });
593
- const capability = adapter.map;
594
- if (!capability) {
595
- throw new UnsupportedCapabilityError(providerId, "map");
596
- }
597
631
  // Shared Map execution dependencies. The cache/sleep/random default
598
632
  // to the same production values as Search/Repository/Reader/Crawl but
599
633
  // are kept as separate optional MainDependencies so map tests can
@@ -603,15 +637,34 @@ async function handleMap(args, outputMode, deps) {
603
637
  sleep: deps.mapSleep,
604
638
  random: deps.mapRandom,
605
639
  };
606
- return invokeCommand(deps.invocation, (context) => map(url, {
607
- depth: flags.depth ? parseInt(flags.depth, 10) : undefined,
608
- breadth: flags.breadth ? parseInt(flags.breadth, 10) : undefined,
609
- limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
610
- selectPaths: flags["select-paths"],
611
- excludePaths: flags["exclude-paths"],
612
- instructions: flags.instructions,
613
- noCache: flags["no-cache"] === true,
614
- }, { capability, execution: executionDeps }, context), outputMode, deps.now, deps.secrets);
640
+ // Provider-fallback Ticket 03: route the call through the shared
641
+ // fallback executor. Map retries once per Provider today, so the
642
+ // documented worst-case charged request count under retry+fallback
643
+ // is `2 × N candidates` — see the troubleshooting entry added in
644
+ // Ticket 04. `--no-fallback` is the strict-mode opt-out.
645
+ return invokeCommand(deps.invocation, async (context) => {
646
+ const outcome = await executeWithFallback({
647
+ capabilityId: "map",
648
+ commandLabel: "map",
649
+ effectiveProvider: providerId,
650
+ descriptors: deps.providerDescriptors,
651
+ env: deps.env,
652
+ fallbackEnabled: deps.fallbackEnabled,
653
+ writeStderr: (s) => deps.invocation.writeStderr(s),
654
+ }, async (descriptor) => {
655
+ const adapter = descriptor.create({ env: deps.env });
656
+ return map(url, {
657
+ depth: flags.depth ? parseInt(flags.depth, 10) : undefined,
658
+ breadth: flags.breadth ? parseInt(flags.breadth, 10) : undefined,
659
+ limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
660
+ selectPaths: flags["select-paths"],
661
+ excludePaths: flags["exclude-paths"],
662
+ instructions: flags.instructions,
663
+ noCache: flags["no-cache"] === true,
664
+ }, { capability: adapter.map, execution: executionDeps }, context);
665
+ });
666
+ return outcome.result;
667
+ }, outputMode, deps.now, deps.secrets);
615
668
  }
616
669
  async function handleResearch(args, outputMode, deps) {
617
670
  const { flags, positional } = parseArgs(args);
@@ -629,40 +682,73 @@ async function handleResearch(args, outputMode, deps) {
629
682
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
630
683
  // explicit --provider > SCOUTLINE_PROVIDER > default zai.
631
684
  const providerId = resolveProviderId(deps.provider, deps.env);
632
- const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
633
- // Capability support check BEFORE descriptor.isConfigured,
634
- // descriptor.create, or any Adapter work. Unsupported Z.AI/MiniMax
635
- // returns UNSUPPORTED_CAPABILITY with no fallback to Tavily.
636
- if (!descriptor.capabilities().has("research")) {
637
- throw new UnsupportedCapabilityError(providerId, "research");
638
- }
639
- if (!descriptor.isConfigured(deps.env)) {
640
- throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
641
- }
642
- const adapter = descriptor.create({ env: deps.env });
643
- const capability = adapter.research;
644
- if (!capability) {
645
- throw new UnsupportedCapabilityError(providerId, "research");
646
- }
647
685
  // Shared Research execution dependencies.
648
686
  const executionDeps = {
649
687
  cache: deps.researchCache,
650
688
  sleep: deps.researchSleep,
651
689
  random: deps.researchRandom,
652
690
  };
653
- // Wait disclaimer — shown BEFORE invoke because research is
654
- // credit-intensive and may take several minutes. Written to stderr so
655
- // it never corrupts data-mode stdout.
656
- deps.invocation.writeStderr("Research in progress — this is a credit-intensive operation that may take several minutes.\n");
657
- return invokeCommand(deps.invocation, (context) => research(query, {
658
- model,
659
- outputLength,
660
- citationFormat,
661
- domain: flags.domain,
662
- maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
663
- timeout: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
664
- noCache: flags["no-cache"] === true,
665
- }, { capability, execution: executionDeps }, context), outputMode, deps.now, deps.secrets);
691
+ // Provider-fallback Ticket 03: route the call through the shared
692
+ // fallback executor. The research command is the riskiest piece
693
+ // of the async set: the SIGINT handler, the polling-timeout
694
+ // controller, and the on-disk state-file identity/timeout must
695
+ // RE-BIND to the Provider that actually wins the candidate loop
696
+ // (Tech Plan §"Handler non-uniformity" — research bullet). The
697
+ // `research` command accepts a `registerInterrupt` injection on
698
+ // its handler dependencies; the executor's per-attempt callback
699
+ // builds the descriptor's `capability.run` and re-registers the
700
+ // SIGINT handler under THAT provider's identity before invoke,
701
+ // tearing it down on throw so the next candidate installs its
702
+ // own. The one-time credit warning is emitted ONCE, before the
703
+ // first attempt, so it is not duplicated across candidate
704
+ // switches.
705
+ return invokeCommand(deps.invocation, async (context) => {
706
+ const options = {
707
+ model,
708
+ outputLength,
709
+ citationFormat,
710
+ domain: flags.domain,
711
+ maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
712
+ timeout: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
713
+ noCache: flags["no-cache"] === true,
714
+ };
715
+ // Closure-guarded one-time credit warning (Review Fix 7). The
716
+ // flag is captured INSIDE the attempt closure, so the line is
717
+ // written exactly once — the first time the executor actually
718
+ // visits a candidate. When the executor's preflight rejects
719
+ // every plan entry before invoking `attempt` (capability-
720
+ // mismatch, no candidates configured), the closure is never
721
+ // entered and the warning stays silent. Strict mode
722
+ // (`--no-fallback`) already runs the same preflight on the
723
+ // single remaining candidate so an ineligible effective also
724
+ // stays silent.
725
+ let warned = false;
726
+ const creditWarning = `Research in progress — this is a credit-intensive operation that may take several minutes.\n`;
727
+ const emitCreditWarning = () => {
728
+ if (warned)
729
+ return;
730
+ warned = true;
731
+ deps.invocation.writeStderr(creditWarning);
732
+ };
733
+ const outcome = await executeWithFallback({
734
+ capabilityId: "research",
735
+ commandLabel: "research",
736
+ effectiveProvider: providerId,
737
+ descriptors: deps.providerDescriptors,
738
+ env: deps.env,
739
+ fallbackEnabled: deps.fallbackEnabled,
740
+ writeStderr: (s) => deps.invocation.writeStderr(s),
741
+ }, async (descriptor) => {
742
+ emitCreditWarning();
743
+ const adapter = descriptor.create({ env: deps.env });
744
+ return research(query, options, {
745
+ capability: adapter.research,
746
+ execution: executionDeps,
747
+ registerInterrupt: deps.researchRegisterInterrupt,
748
+ }, context);
749
+ });
750
+ return outcome.result;
751
+ }, outputMode, deps.now, deps.secrets);
666
752
  }
667
753
  /**
668
754
  * Validate a research enum flag (--model, --output-length,
@@ -725,30 +811,6 @@ async function handleRepo(args, outputMode, deps) {
725
811
  // never consults credentials (FR-003) and never branches on Provider
726
812
  // ID; an unknown explicit/env value throws VALIDATION_ERROR here.
727
813
  const providerId = resolveProviderId(deps.provider, deps.env);
728
- const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
729
- // Capability support check BEFORE `descriptor.isConfigured`,
730
- // `descriptor.create`, Adapter access, operation validation/
731
- // cacheIdentity, credential use, cache, or transport. Unsupported
732
- // MiniMax (explicit or environment) returns UNSUPPORTED_CAPABILITY
733
- // with no fallback to Z.AI; zero selected-Provider work occurs.
734
- if (!descriptor.capabilities().has("repository-exploration")) {
735
- throw new UnsupportedCapabilityError(providerId, "repository-exploration");
736
- }
737
- // FR-003: selection returns the default zai even when unconfigured.
738
- // Supported-but-unconfigured Z.AI surfaces a missing credential as
739
- // ConfigurationError (exit 3) AFTER the support metadata check and
740
- // BEFORE `descriptor.create`.
741
- if (!descriptor.isConfigured(deps.env)) {
742
- throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
743
- }
744
- const adapter = descriptor.create({ env: deps.env });
745
- const capability = adapter.repository;
746
- // Defensive fail-closed: the descriptor advertised support but the
747
- // Adapter omitted the handle. Treat as unsupported so a registry
748
- // mismatch can never reach transport.
749
- if (!capability) {
750
- throw new UnsupportedCapabilityError(providerId, "repository-exploration");
751
- }
752
814
  // Shared Repository execution dependencies. The cache/sleep/random
753
815
  // default to the same production values as Search but are kept as
754
816
  // separate optional MainDependencies so repository tests can inject
@@ -758,30 +820,43 @@ async function handleRepo(args, outputMode, deps) {
758
820
  sleep: deps.repositorySleep,
759
821
  random: deps.repositoryRandom,
760
822
  };
761
- switch (command) {
762
- case "search": {
763
- const language = flags.language;
764
- const maxChars = flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined;
765
- const noCache = flags["no-cache"] === true;
766
- return invokeCommand(deps.invocation, () => repoSearch(repo, searchQuery, { language, maxChars, noCache }, { capability, execution: executionDeps }), outputMode, deps.now, deps.secrets);
767
- }
768
- case "tree": {
769
- const treePath = flags.path;
770
- const depth = flags.depth ? parseInt(flags.depth, 10) : undefined;
771
- const noCache = flags["no-cache"] === true;
772
- return invokeCommand(deps.invocation, () => repoTree(repo, { path: treePath, depth, noCache }, { capability, execution: executionDeps }), outputMode, deps.now, deps.secrets);
773
- }
774
- case "read": {
775
- const maxChars = flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined;
776
- const noCache = flags["no-cache"] === true;
777
- return invokeCommand(deps.invocation, () => repoRead(repo, readPath, { maxChars, noCache }, { capability, execution: executionDeps }), outputMode, deps.now, deps.secrets);
778
- }
779
- default:
780
- // Unreachable: the parse-level validation above already rejected
781
- // unknown subcommands. Keep a defensive throw so the dispatch
782
- // table stays total.
783
- throw new ValidationError(`Unknown repo command: ${command}`, 'Run "scoutline repo --help" for available commands');
784
- }
823
+ const language = flags.language;
824
+ const maxChars = flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined;
825
+ const noCache = flags["no-cache"] === true;
826
+ const treePath = flags.path;
827
+ const depth = flags.depth ? parseInt(flags.depth, 10) : undefined;
828
+ // Provider-fallback Ticket 02: route every subcommand through the
829
+ // shared executor. All three projections (search/tree/read) share
830
+ // the `repository-exploration` Capability, so a single executor
831
+ // invocation wraps the chosen subcommand.
832
+ return invokeCommand(deps.invocation, async (context) => {
833
+ const outcome = await executeWithFallback({
834
+ capabilityId: "repository-exploration",
835
+ commandLabel: "repo",
836
+ effectiveProvider: providerId,
837
+ descriptors: deps.providerDescriptors,
838
+ env: deps.env,
839
+ fallbackEnabled: deps.fallbackEnabled,
840
+ writeStderr: (s) => deps.invocation.writeStderr(s),
841
+ }, async (descriptor) => {
842
+ const adapter = descriptor.create({ env: deps.env });
843
+ const capability = adapter.repository;
844
+ switch (command) {
845
+ case "search":
846
+ return repoSearch(repo, searchQuery, { language, maxChars, noCache }, { capability, execution: executionDeps }, context);
847
+ case "tree":
848
+ return repoTree(repo, { path: treePath, depth, noCache }, { capability, execution: executionDeps }, context);
849
+ case "read":
850
+ return repoRead(repo, readPath, { maxChars, noCache }, { capability, execution: executionDeps }, context);
851
+ default:
852
+ // Unreachable: the parse-level validation above already
853
+ // rejected unknown subcommands. Keep a defensive throw so
854
+ // the dispatch table stays total.
855
+ throw new ValidationError(`Unknown repo command: ${command}`, 'Run "scoutline repo --help" for available commands');
856
+ }
857
+ });
858
+ return outcome.result;
859
+ }, outputMode, deps.now, deps.secrets);
785
860
  }
786
861
  async function handleTools(args, outputMode, deps) {
787
862
  const { flags } = parseArgs(args);
@@ -986,7 +1061,18 @@ export async function main(args, dependencies) {
986
1061
  // redaction follows the same environment the handlers see — a secret
987
1062
  // that exists only in MainDependencies.env is still redacted from output.
988
1063
  const secrets = configuredSecrets(env);
989
- const { outputFormat, forcePretty, forceRaw, provider, rest } = extractGlobalOptions([...args]);
1064
+ const { outputFormat, forcePretty, forceRaw, provider, noFallback, rest } = extractGlobalOptions([...args]);
1065
+ // Provider-fallback kill-switch. The flag and the env are both
1066
+ // opt-outs; either is sufficient to disable the cross-Provider
1067
+ // candidate loop. The flag wins when both are present (the user
1068
+ // is being explicit). `SCOUTLINE_NO_FALLBACK` is read from the
1069
+ // injected `env` (B3) so redaction and executor preflight see
1070
+ // the same environment the user actually configured. Truthy
1071
+ // values include any non-empty string, so `SCOUTLINE_NO_FALLBACK=1`
1072
+ // and `SCOUTLINE_NO_FALLBACK=true` both opt out; an empty string
1073
+ // is the conventional "unset" form.
1074
+ const envDisablesFallback = typeof env.SCOUTLINE_NO_FALLBACK === "string" && env.SCOUTLINE_NO_FALLBACK.length > 0;
1075
+ const fallbackEnabled = !(noFallback || envDisablesFallback);
990
1076
  // Fixup C — B10: resolve the output mode BEFORE the dispatch try/catch.
991
1077
  // An invalid explicit mode still surfaces as a typed ValidationError,
992
1078
  // but the surface formatter uses the user's REQUESTED mode (or the
@@ -1021,6 +1107,7 @@ export async function main(args, dependencies) {
1021
1107
  now,
1022
1108
  provider,
1023
1109
  providerDescriptors,
1110
+ fallbackEnabled,
1024
1111
  searchCache,
1025
1112
  searchSleep,
1026
1113
  searchRandom,
@@ -1039,6 +1126,7 @@ export async function main(args, dependencies) {
1039
1126
  researchCache,
1040
1127
  researchSleep,
1041
1128
  researchRandom,
1129
+ researchRegisterInterrupt: dependencies.researchRegisterInterrupt,
1042
1130
  };
1043
1131
  try {
1044
1132
  switch (command) {