scoutline 0.10.2 → 0.12.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 (151) hide show
  1. package/README.md +99 -44
  2. package/dist/capabilities/diagnostics.d.ts +75 -0
  3. package/dist/capabilities/diagnostics.d.ts.map +1 -1
  4. package/dist/capabilities/diagnostics.js.map +1 -1
  5. package/dist/capabilities/quota.d.ts +64 -1
  6. package/dist/capabilities/quota.d.ts.map +1 -1
  7. package/dist/capabilities/quota.js.map +1 -1
  8. package/dist/capabilities/vision.d.ts +4 -3
  9. package/dist/capabilities/vision.d.ts.map +1 -1
  10. package/dist/capabilities/vision.js +7 -5
  11. package/dist/capabilities/vision.js.map +1 -1
  12. package/dist/commands/code.d.ts +9 -1
  13. package/dist/commands/code.d.ts.map +1 -1
  14. package/dist/commands/code.js +4 -4
  15. package/dist/commands/code.js.map +1 -1
  16. package/dist/commands/crawl.d.ts.map +1 -1
  17. package/dist/commands/crawl.js +14 -3
  18. package/dist/commands/crawl.js.map +1 -1
  19. package/dist/commands/doctor.d.ts +71 -1
  20. package/dist/commands/doctor.d.ts.map +1 -1
  21. package/dist/commands/doctor.js +96 -15
  22. package/dist/commands/doctor.js.map +1 -1
  23. package/dist/commands/init.d.ts +186 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +1221 -0
  26. package/dist/commands/init.js.map +1 -0
  27. package/dist/commands/map.d.ts.map +1 -1
  28. package/dist/commands/map.js +16 -3
  29. package/dist/commands/map.js.map +1 -1
  30. package/dist/commands/quota.d.ts +32 -0
  31. package/dist/commands/quota.d.ts.map +1 -1
  32. package/dist/commands/quota.js +165 -24
  33. package/dist/commands/quota.js.map +1 -1
  34. package/dist/commands/read.d.ts.map +1 -1
  35. package/dist/commands/read.js +9 -3
  36. package/dist/commands/read.js.map +1 -1
  37. package/dist/commands/repo.d.ts.map +1 -1
  38. package/dist/commands/repo.js +5 -3
  39. package/dist/commands/repo.js.map +1 -1
  40. package/dist/commands/research.d.ts +35 -7
  41. package/dist/commands/research.d.ts.map +1 -1
  42. package/dist/commands/research.js +93 -15
  43. package/dist/commands/research.js.map +1 -1
  44. package/dist/commands/tools.d.ts +8 -0
  45. package/dist/commands/tools.d.ts.map +1 -1
  46. package/dist/commands/tools.js +4 -4
  47. package/dist/commands/tools.js.map +1 -1
  48. package/dist/commands/vision.d.ts +10 -0
  49. package/dist/commands/vision.d.ts.map +1 -1
  50. package/dist/commands/vision.js +25 -2
  51. package/dist/commands/vision.js.map +1 -1
  52. package/dist/index.d.ts +136 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +894 -298
  55. package/dist/index.js.map +1 -1
  56. package/dist/lib/api-client.d.ts +1 -1
  57. package/dist/lib/api-client.d.ts.map +1 -1
  58. package/dist/lib/api-client.js +6 -2
  59. package/dist/lib/api-client.js.map +1 -1
  60. package/dist/lib/cache.d.ts +9 -2
  61. package/dist/lib/cache.d.ts.map +1 -1
  62. package/dist/lib/cache.js +9 -2
  63. package/dist/lib/cache.js.map +1 -1
  64. package/dist/lib/code-mode.d.ts +8 -0
  65. package/dist/lib/code-mode.d.ts.map +1 -1
  66. package/dist/lib/code-mode.js +3 -1
  67. package/dist/lib/code-mode.js.map +1 -1
  68. package/dist/lib/config-store.d.ts +165 -0
  69. package/dist/lib/config-store.d.ts.map +1 -0
  70. package/dist/lib/config-store.js +332 -0
  71. package/dist/lib/config-store.js.map +1 -0
  72. package/dist/lib/config.d.ts +2 -2
  73. package/dist/lib/config.d.ts.map +1 -1
  74. package/dist/lib/config.js +19 -11
  75. package/dist/lib/config.js.map +1 -1
  76. package/dist/lib/consumption.d.ts +144 -0
  77. package/dist/lib/consumption.d.ts.map +1 -0
  78. package/dist/lib/consumption.js +161 -0
  79. package/dist/lib/consumption.js.map +1 -0
  80. package/dist/lib/errors.d.ts +11 -0
  81. package/dist/lib/errors.d.ts.map +1 -1
  82. package/dist/lib/errors.js +14 -0
  83. package/dist/lib/errors.js.map +1 -1
  84. package/dist/lib/execution.d.ts +32 -4
  85. package/dist/lib/execution.d.ts.map +1 -1
  86. package/dist/lib/execution.js +156 -13
  87. package/dist/lib/execution.js.map +1 -1
  88. package/dist/lib/mcp-client.d.ts +12 -0
  89. package/dist/lib/mcp-client.d.ts.map +1 -1
  90. package/dist/lib/mcp-client.js +16 -7
  91. package/dist/lib/mcp-client.js.map +1 -1
  92. package/dist/lib/mcp-config.d.ts +10 -0
  93. package/dist/lib/mcp-config.d.ts.map +1 -1
  94. package/dist/lib/mcp-config.js +13 -7
  95. package/dist/lib/mcp-config.js.map +1 -1
  96. package/dist/lib/monitor-client.d.ts +19 -3
  97. package/dist/lib/monitor-client.d.ts.map +1 -1
  98. package/dist/lib/monitor-client.js +21 -5
  99. package/dist/lib/monitor-client.js.map +1 -1
  100. package/dist/lib/provider-fallback.d.ts +150 -0
  101. package/dist/lib/provider-fallback.d.ts.map +1 -0
  102. package/dist/lib/provider-fallback.js +510 -0
  103. package/dist/lib/provider-fallback.js.map +1 -0
  104. package/dist/lib/quota-mapping.d.ts +402 -0
  105. package/dist/lib/quota-mapping.d.ts.map +1 -0
  106. package/dist/lib/quota-mapping.js +539 -0
  107. package/dist/lib/quota-mapping.js.map +1 -0
  108. package/dist/lib/quota-store.d.ts +271 -0
  109. package/dist/lib/quota-store.d.ts.map +1 -0
  110. package/dist/lib/quota-store.js +540 -0
  111. package/dist/lib/quota-store.js.map +1 -0
  112. package/dist/lib/trigger-detection.d.ts +149 -0
  113. package/dist/lib/trigger-detection.d.ts.map +1 -0
  114. package/dist/lib/trigger-detection.js +149 -0
  115. package/dist/lib/trigger-detection.js.map +1 -0
  116. package/dist/lib/tty.d.ts +8 -1
  117. package/dist/lib/tty.d.ts.map +1 -1
  118. package/dist/lib/tty.js +53 -1
  119. package/dist/lib/tty.js.map +1 -1
  120. package/dist/providers/brave/adapter.d.ts +26 -0
  121. package/dist/providers/brave/adapter.d.ts.map +1 -1
  122. package/dist/providers/brave/adapter.js +66 -4
  123. package/dist/providers/brave/adapter.js.map +1 -1
  124. package/dist/providers/brave/client.d.ts +46 -2
  125. package/dist/providers/brave/client.d.ts.map +1 -1
  126. package/dist/providers/brave/client.js +55 -3
  127. package/dist/providers/brave/client.js.map +1 -1
  128. package/dist/providers/exa/adapter.d.ts.map +1 -1
  129. package/dist/providers/exa/adapter.js +10 -1
  130. package/dist/providers/exa/adapter.js.map +1 -1
  131. package/dist/providers/firecrawl/adapter.d.ts.map +1 -1
  132. package/dist/providers/firecrawl/adapter.js +13 -3
  133. package/dist/providers/firecrawl/adapter.js.map +1 -1
  134. package/dist/providers/minimax/adapter.d.ts.map +1 -1
  135. package/dist/providers/minimax/adapter.js +4 -0
  136. package/dist/providers/minimax/adapter.js.map +1 -1
  137. package/dist/providers/selection.d.ts +114 -1
  138. package/dist/providers/selection.d.ts.map +1 -1
  139. package/dist/providers/selection.js +99 -3
  140. package/dist/providers/selection.js.map +1 -1
  141. package/dist/providers/tavily/adapter.d.ts.map +1 -1
  142. package/dist/providers/tavily/adapter.js +2 -0
  143. package/dist/providers/tavily/adapter.js.map +1 -1
  144. package/dist/providers/types.d.ts +33 -1
  145. package/dist/providers/types.d.ts.map +1 -1
  146. package/dist/providers/types.js +8 -0
  147. package/dist/providers/types.js.map +1 -1
  148. package/dist/providers/zai/adapter.d.ts.map +1 -1
  149. package/dist/providers/zai/adapter.js +18 -5
  150. package/dist/providers/zai/adapter.js.map +1 -1
  151. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -17,13 +17,20 @@ 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
- import { resolveProviderId } from "./providers/selection.js";
25
- import { BUILT_IN_PROVIDER_DESCRIPTORS, getProviderDescriptor } from "./providers/registry.js";
24
+ import { resolveEnvFromConfig } from "./lib/config-store.js";
25
+ import { inspectConfig, createDefaultVerificationPromoter, createDefaultHintShownStore, } from "./lib/config-store.js";
26
+ import { createDefaultQuotaStore, refreshQuotaSnapshots, } from "./lib/quota-store.js";
27
+ import { createQuotaStoreConsumptionSink } from "./lib/consumption.js";
28
+ import { classifyCredentialState, formatEnvOnlyHint, isCommandHelpInvocation, OBSERVATIONAL_COMMANDS, } from "./lib/trigger-detection.js";
29
+ import { resolveProviderId, resolveEffectiveProvider } from "./providers/selection.js";
30
+ import { BUILT_IN_PROVIDER_DESCRIPTORS } from "./providers/registry.js";
31
+ import { executeWithFallback } from "./lib/provider-fallback.js";
26
32
  import { visionOperationToCapability } from "./capabilities/vision.js";
33
+ import { handleInitWithHelp, createInquirerPrompts, createDefaultConfigStore, } from "./commands/init.js";
27
34
  import { createRequire } from "node:module";
28
35
  const require = createRequire(import.meta.url);
29
36
  const { version: VERSION } = require("../package.json");
@@ -54,6 +61,7 @@ Commands:
54
61
  doctor Provider-aware environment + connectivity checks
55
62
  cache Inspect or clear the local cache (stats / clear)
56
63
  code Execute TypeScript tool chains (Code Mode, Z.AI)
64
+ init Interactive onboarding wizard (writes ~/.scoutline/config.json)
57
65
 
58
66
  Provider selection (precedence: --provider, then SCOUTLINE_PROVIDER, then zai):
59
67
  --provider <zai|minimax|tavily|exa|brave|firecrawl> Select the active Provider for shared capabilities
@@ -64,11 +72,14 @@ and 'research' commands participate in Provider selection: Z.AI
64
72
  advertises and supplies repository-exploration and reader; Tavily
65
73
  advertises and supplies reader plus crawl, map, and research; Exa
66
74
  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.
75
+ advertises and supplies none of those Provider-only Capabilities.
76
+ Provider fallback is always-on by default (0.11.0+): selecting a
77
+ non-supplier emits a stderr notice and silently reroutes to the next
78
+ eligible configured Provider in registry order. Use --no-fallback (or
79
+ SCOUTLINE_NO_FALLBACK=1) to restore the previous strict
80
+ UNSUPPORTED_CAPABILITY behavior. Z.AI-only commands (tools, tool,
81
+ call, code) carry the flag but ignore it. Quota and doctor report
82
+ per-Provider; --provider picks the effective Provider for metadata.
72
83
 
73
84
  Global Options:
74
85
  --output-format <data|json|pretty|compact|markdown|refs|tty> Output mode (default: data)
@@ -87,6 +98,7 @@ Help:
87
98
  scoutline call --help
88
99
  scoutline code --help
89
100
  scoutline cache --help
101
+ scoutline init --help
90
102
  `.trim();
91
103
  function parseArgs(args) {
92
104
  const flags = {};
@@ -137,6 +149,7 @@ function extractGlobalOptions(args) {
137
149
  let forcePretty = false;
138
150
  let forceRaw = false;
139
151
  let provider;
152
+ let noFallback = false;
140
153
  for (let i = 0; i < args.length; i += 1) {
141
154
  const arg = args[i];
142
155
  if (arg === "--output-format" || arg === "-O") {
@@ -161,9 +174,19 @@ function extractGlobalOptions(args) {
161
174
  i += 1;
162
175
  continue;
163
176
  }
177
+ if (arg === "--no-fallback") {
178
+ // Provider-fallback kill-switch. Accepted before OR after the
179
+ // command token and removed from the rest stream so command-local
180
+ // parsing never observes it. Resolution against
181
+ // `SCOUTLINE_NO_FALLBACK` lives in `main` so a test that drives
182
+ // `extractGlobalOptions` directly can assert the flag shape in
183
+ // isolation.
184
+ noFallback = true;
185
+ continue;
186
+ }
164
187
  rest.push(arg);
165
188
  }
166
- return { outputFormat, forcePretty, forceRaw, provider, rest };
189
+ return { outputFormat, forcePretty, forceRaw, provider, noFallback, rest };
167
190
  }
168
191
  function resolveOutputMode(explicit, forcePretty, forceRaw, adapter) {
169
192
  if (explicit !== undefined) {
@@ -197,66 +220,107 @@ async function handleVision(args, outputMode, deps) {
197
220
  // a parse-time VALIDATION_ERROR and are rejected before any Provider
198
221
  // resolution, support check, or media access.
199
222
  const operation = visionOperationForCommand(command);
223
+ // Per-operation capability id (e.g. `vision.interpret-image`,
224
+ // `vision.chart`). The executor's preflight uses this to filter
225
+ // descriptors, and notice wording carries it verbatim. Vision
226
+ // sub-operations share the same `adapter.vision` slot — the
227
+ // per-operation capability is in the descriptor metadata. Computed
228
+ // before selection so PB-T4 can rank providers against the exact
229
+ // sub-operation being dispatched.
230
+ const capabilityId = visionOperationToCapability(operation);
200
231
  // Resolve the effective Provider for Vision (DESIGN.md §6). Invalid
201
232
  // explicit/env input fails here with VALIDATION_ERROR before any Vision
202
233
  // support check or media access. Selection never consults credentials
203
- // (FR-003); the configured check below is the caller's responsibility.
204
- 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.
211
- 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
- }
234
+ // beyond the descriptor `isConfigured` metadata check (FR-003); the
235
+ // configured check below is the executor's responsibility (Provider
236
+ // Fallback Tech Plan §"Per-handler refactor pattern"). PB-T4: when
237
+ // no pin is present, the effective provider is the highest-scored
238
+ // configured+capable provider for this vision operation against the
239
+ // injected quota snapshot; pin input still bypasses ranking.
240
+ const providerId = resolveEffectiveProvider({
241
+ explicitProvider: deps.provider,
242
+ env: deps.env,
243
+ capabilityId,
244
+ descriptors: deps.providerDescriptors,
245
+ quotaSnapshot: deps.quotaState,
246
+ });
227
247
  // Vision bypasses the response cache (FR-022). The shared execution
228
248
  // primitives (sleep/random) are the same ones Search consumes; they
229
249
  // drive retry backoff deterministically under test.
230
- const visionDeps = {
231
- capability: visionCapability,
250
+ const visionDepsShape = {
232
251
  sleep: deps.searchSleep,
233
252
  random: deps.searchRandom,
234
253
  };
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
- }
254
+ // Provider-fallback Ticket 02: route every vision operation through
255
+ // the shared executor. The existing vision URL→temp-file fallback
256
+ // stays inside the Z.AI adapter's invoke, layered beneath provider
257
+ // fallback.
258
+ return invokeCommand(deps.invocation, async (context) => {
259
+ const outcome = await executeWithFallback({
260
+ capabilityId,
261
+ commandLabel: "vision",
262
+ effectiveProvider: providerId,
263
+ descriptors: deps.providerDescriptors,
264
+ env: deps.env,
265
+ fallbackEnabled: deps.fallbackEnabled,
266
+ writeStderr: (s) => deps.invocation.writeStderr(s),
267
+ }, async (descriptor) => {
268
+ const adapter = descriptor.create({ env: deps.env });
269
+ // The executor's preflight guarantees the descriptor advertises
270
+ // the capability and supplies a non-null adapter slot, so the
271
+ // cast is safe. Operation-level support is checked immediately
272
+ // against `visionCapability.supports(operation)` — descriptor
273
+ // metadata and adapter capability are independent sources of
274
+ // truth, and the live dispatch must fail closed on disagreement
275
+ // (Review Fix 4). A mismatch throws `UnsupportedCapabilityError`
276
+ // before any media work / transport use, and the Executor treats
277
+ // it as a `continue` so the next candidate (if any) gets a
278
+ // chance.
279
+ const visionCapability = adapter.vision;
280
+ if (!visionCapability || !visionCapability.supports(operation)) {
281
+ throw new UnsupportedCapabilityError(descriptor.id, capabilityId);
282
+ }
283
+ const visionDeps = {
284
+ capability: visionCapability,
285
+ ...visionDepsShape,
286
+ // PB-T2: thread the actual attempted descriptor ID + sink
287
+ // through so vision emits one consumption event per
288
+ // billable invoke attempt at the execution seam. The
289
+ // descriptor ID is the *attempted* provider (not the
290
+ // registry-derived effective provider) so fallback attempts
291
+ // record the actual descriptor that invoked transport.
292
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
293
+ ...(deps.consume !== undefined ? { provider: descriptor.id } : {}),
294
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
295
+ };
296
+ switch (command) {
297
+ case "analyze":
298
+ return vision.analyze(source, prompt, visionDeps, context);
299
+ case "ui-to-code": {
300
+ const outputType = flags.output || "code";
301
+ return vision.uiToCode(source, prompt, outputType, visionDeps, context);
302
+ }
303
+ case "extract-text":
304
+ return vision.extractText(source, prompt, flags.language, visionDeps, context);
305
+ case "diagnose-error":
306
+ return vision.diagnoseError(source, prompt, flags.context, visionDeps, context);
307
+ case "diagram":
308
+ return vision.diagram(source, prompt, flags.type, visionDeps, context);
309
+ case "chart":
310
+ return vision.chart(source, prompt, flags.focus, visionDeps, context);
311
+ case "diff": {
312
+ const actual = positional[2];
313
+ const diffPrompt = positional[3];
314
+ return vision.diff(source, actual, diffPrompt, visionDeps, context);
315
+ }
316
+ case "video":
317
+ return vision.video(source, prompt, visionDeps, context);
318
+ default:
319
+ throw new ValidationError(`Unknown vision command: ${command}`, 'Run "scoutline vision --help" for available commands');
320
+ }
321
+ });
322
+ return outcome.result;
323
+ }, outputMode, deps.now, deps.secrets);
260
324
  }
261
325
  /**
262
326
  * Map a Vision subcommand to its discriminated operation id. Used by
@@ -380,29 +444,23 @@ async function handleSearch(args, outputMode, deps) {
380
444
  if (type !== undefined && topic !== undefined) {
381
445
  throw new ValidationError("--type and --topic are mutually exclusive (--type has no editorial topic axis).", "Pass either --type or --topic, not both.");
382
446
  }
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).
389
- 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
- }
447
+ // Resolve the effective Provider ONLY inside shared Search (DESIGN.md §6).
448
+ // Other command families carry the parsed flag but never resolve or
449
+ // validate it. An invalid explicit/env value throws VALIDATION_ERROR
450
+ // here, before any Adapter construction or invocation. Selection never
451
+ // consults credentials beyond the descriptor `isConfigured` metadata
452
+ // check (FR-003); the configured check is the executor's
453
+ // responsibility (Provider Fallback Tech Plan §"Per-handler refactor
454
+ // pattern"). PB-T4: when no pin is present, the effective provider is
455
+ // the highest-scored configured+capable provider for `search` against
456
+ // the injected quota snapshot; pin input still bypasses ranking.
457
+ const providerId = resolveEffectiveProvider({
458
+ explicitProvider: deps.provider,
459
+ env: deps.env,
460
+ capabilityId: "search",
461
+ descriptors: deps.providerDescriptors,
462
+ quotaSnapshot: deps.quotaState,
463
+ });
406
464
  const query = positional.join(" ");
407
465
  const fieldsRaw = flags.fields;
408
466
  const fields = fieldsRaw
@@ -411,7 +469,7 @@ async function handleSearch(args, outputMode, deps) {
411
469
  .map((f) => f.trim())
412
470
  .filter(Boolean)
413
471
  : undefined;
414
- return invokeCommand(deps.invocation, (context) => search(query, {
472
+ const searchOptions = {
415
473
  count,
416
474
  domain: flags.domain,
417
475
  recency: flags.recency,
@@ -419,18 +477,40 @@ async function handleSearch(args, outputMode, deps) {
419
477
  location: flags.location,
420
478
  topic,
421
479
  type,
422
- maxSummary: flags["max-summary"]
423
- ? parseInt(flags["max-summary"], 10)
424
- : undefined,
480
+ maxSummary: flags["max-summary"] ? parseInt(flags["max-summary"], 10) : undefined,
425
481
  fields: fields && fields.length > 0 ? fields : undefined,
426
482
  noCache: flags["no-cache"] === true,
427
483
  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);
484
+ };
485
+ // Provider-fallback Ticket 02: route the call through the shared
486
+ // fallback executor. The executor owns capability+configured+adapter
487
+ // preflight (FR-023/024), candidate plan ordering, error
488
+ // classification, exhaustion semantics, and notices. The handler
489
+ // keeps command-specific work (request building, flag parsing, which
490
+ // executeX). `--merge` runs the whole parallel sub-query batch inside
491
+ // a single attempt so a fallback switch replaces the entire batch,
492
+ // never individual sub-queries (Tech Plan §"Handler non-uniformity").
493
+ return invokeCommand(deps.invocation, async (context) => {
494
+ const outcome = await executeWithFallback({
495
+ capabilityId: "search",
496
+ commandLabel: "search",
497
+ effectiveProvider: providerId,
498
+ descriptors: deps.providerDescriptors,
499
+ env: deps.env,
500
+ fallbackEnabled: deps.fallbackEnabled,
501
+ writeStderr: (s) => deps.invocation.writeStderr(s),
502
+ }, async (descriptor) => {
503
+ const adapter = descriptor.create({ env: deps.env });
504
+ const capability = adapter.search;
505
+ return search(query, searchOptions, {
506
+ capability,
507
+ cache: deps.searchCache,
508
+ sleep: deps.searchSleep,
509
+ random: deps.searchRandom,
510
+ }, context);
511
+ });
512
+ return outcome.result;
513
+ }, outputMode, deps.now, deps.secrets);
434
514
  }
435
515
  async function handleRead(args, outputMode, deps) {
436
516
  const { flags, positional } = parseArgs(args);
@@ -456,44 +536,19 @@ async function handleRead(args, outputMode, deps) {
456
536
  }
457
537
  const extract = extractFlag !== undefined ? extractFlag : undefined;
458
538
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
459
- // explicit --provider > SCOUTLINE_PROVIDER > default zai. Selection
460
- // never consults credentials (FR-003) and never branches on Provider
461
- // ID; an unknown explicit/env value throws VALIDATION_ERROR here.
462
- 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, {
539
+ // explicit --provider > SCOUTLINE_PROVIDER > quota-ranked pick.
540
+ // Selection never consults credentials beyond the descriptor
541
+ // `isConfigured` metadata check (FR-003) and never branches on
542
+ // Provider ID; an unknown explicit/env value throws
543
+ // VALIDATION_ERROR here. (PB-T4.)
544
+ const providerId = resolveEffectiveProvider({
545
+ explicitProvider: deps.provider,
546
+ env: deps.env,
547
+ capabilityId: "reader",
548
+ descriptors: deps.providerDescriptors,
549
+ quotaSnapshot: deps.quotaState,
550
+ });
551
+ const readOptions = {
497
552
  format: flags.format,
498
553
  noImages: flags["no-images"] === true,
499
554
  noCache: flags["no-cache"] === true,
@@ -505,7 +560,39 @@ async function handleRead(args, outputMode, deps) {
505
560
  maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
506
561
  fullEnvelope: flags["full-envelope"] === true,
507
562
  extract,
508
- }, { capability, execution: executionDeps }, context), outputMode, deps.now, deps.secrets);
563
+ };
564
+ // Shared Reader execution dependencies. The cache/sleep/random
565
+ // default to the same production values as Search and Repository
566
+ // but are kept as separate optional MainDependencies so reader
567
+ // tests can inject isolated in-memory doubles.
568
+ const executionDeps = {
569
+ cache: deps.readerCache,
570
+ sleep: deps.readerSleep,
571
+ random: deps.readerRandom,
572
+ };
573
+ // Provider-fallback Ticket 02: route the call through the shared
574
+ // fallback executor. Capability + configured + adapter-handle
575
+ // agreement preflight now lives in the executor (FR-023/024
576
+ // preserved). Under --no-fallback the same preflight runs on the
577
+ // effective Provider only.
578
+ return invokeCommand(deps.invocation, async (context) => {
579
+ const outcome = await executeWithFallback({
580
+ capabilityId: "reader",
581
+ commandLabel: "read",
582
+ effectiveProvider: providerId,
583
+ descriptors: deps.providerDescriptors,
584
+ env: deps.env,
585
+ fallbackEnabled: deps.fallbackEnabled,
586
+ writeStderr: (s) => deps.invocation.writeStderr(s),
587
+ }, async (descriptor) => {
588
+ const adapter = descriptor.create({ env: deps.env });
589
+ return read(url, readOptions, {
590
+ capability: adapter.reader,
591
+ execution: executionDeps,
592
+ }, context);
593
+ });
594
+ return outcome.result;
595
+ }, outputMode, deps.now, deps.secrets);
509
596
  }
510
597
  async function handleCrawl(args, outputMode, deps) {
511
598
  const { flags, positional } = parseArgs(args);
@@ -522,23 +609,15 @@ async function handleCrawl(args, outputMode, deps) {
522
609
  throw new ValidationError("URL must start with http:// or https://");
523
610
  }
524
611
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
525
- // explicit --provider > SCOUTLINE_PROVIDER > default zai.
526
- 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
- }
612
+ // explicit --provider > SCOUTLINE_PROVIDER > quota-ranked pick.
613
+ // (PB-T4.)
614
+ const providerId = resolveEffectiveProvider({
615
+ explicitProvider: deps.provider,
616
+ env: deps.env,
617
+ capabilityId: "crawl",
618
+ descriptors: deps.providerDescriptors,
619
+ quotaSnapshot: deps.quotaState,
620
+ });
542
621
  // Shared Crawl execution dependencies. The cache/sleep/random default
543
622
  // to the same production values as Search/Repository/Reader but are
544
623
  // kept as separate optional MainDependencies so crawl tests can inject
@@ -548,19 +627,43 @@ async function handleCrawl(args, outputMode, deps) {
548
627
  sleep: deps.crawlSleep,
549
628
  random: deps.crawlRandom,
550
629
  };
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);
630
+ // Provider-fallback Ticket 03: route the call through the shared
631
+ // fallback executor. Capability + configured + adapter-handle
632
+ // preflight lives in the executor (FR-023/024 preserved). The
633
+ // async cost-bearing accepted-risk path (Tech Plan §"Accepted
634
+ // risk"): a runtime failure on crawl may fall back to another
635
+ // Provider even if the failed Provider had already accepted
636
+ // the job. `--no-fallback` eliminates this risk.
637
+ return invokeCommand(deps.invocation, async (context) => {
638
+ const outcome = await executeWithFallback({
639
+ capabilityId: "crawl",
640
+ commandLabel: "crawl",
641
+ effectiveProvider: providerId,
642
+ descriptors: deps.providerDescriptors,
643
+ env: deps.env,
644
+ fallbackEnabled: deps.fallbackEnabled,
645
+ writeStderr: (s) => deps.invocation.writeStderr(s),
646
+ }, async (descriptor) => {
647
+ const adapter = descriptor.create({ env: deps.env });
648
+ return crawl(url, {
649
+ depth: flags.depth ? parseInt(flags.depth, 10) : undefined,
650
+ breadth: flags.breadth ? parseInt(flags.breadth, 10) : undefined,
651
+ limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
652
+ selectPaths: flags["select-paths"],
653
+ excludePaths: flags["exclude-paths"],
654
+ instructions: flags.instructions,
655
+ format: flags.format,
656
+ contentSize: flags["content-size"],
657
+ timeout: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
658
+ noCache: flags["no-cache"] === true,
659
+ maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
660
+ }, {
661
+ capability: adapter.crawl,
662
+ execution: executionDeps,
663
+ }, context);
664
+ });
665
+ return outcome.result;
666
+ }, outputMode, deps.now, deps.secrets);
564
667
  }
565
668
  async function handleMap(args, outputMode, deps) {
566
669
  const { flags, positional } = parseArgs(args);
@@ -577,23 +680,15 @@ async function handleMap(args, outputMode, deps) {
577
680
  throw new ValidationError("URL must start with http:// or https://");
578
681
  }
579
682
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
580
- // explicit --provider > SCOUTLINE_PROVIDER > default zai.
581
- 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
- }
683
+ // explicit --provider > SCOUTLINE_PROVIDER > quota-ranked pick.
684
+ // (PB-T4.)
685
+ const providerId = resolveEffectiveProvider({
686
+ explicitProvider: deps.provider,
687
+ env: deps.env,
688
+ capabilityId: "map",
689
+ descriptors: deps.providerDescriptors,
690
+ quotaSnapshot: deps.quotaState,
691
+ });
597
692
  // Shared Map execution dependencies. The cache/sleep/random default
598
693
  // to the same production values as Search/Repository/Reader/Crawl but
599
694
  // are kept as separate optional MainDependencies so map tests can
@@ -603,15 +698,37 @@ async function handleMap(args, outputMode, deps) {
603
698
  sleep: deps.mapSleep,
604
699
  random: deps.mapRandom,
605
700
  };
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);
701
+ // Provider-fallback Ticket 03: route the call through the shared
702
+ // fallback executor. Map retries once per Provider today, so the
703
+ // documented worst-case charged request count under retry+fallback
704
+ // is `2 × N candidates` — see the troubleshooting entry added in
705
+ // Ticket 04. `--no-fallback` is the strict-mode opt-out.
706
+ return invokeCommand(deps.invocation, async (context) => {
707
+ const outcome = await executeWithFallback({
708
+ capabilityId: "map",
709
+ commandLabel: "map",
710
+ effectiveProvider: providerId,
711
+ descriptors: deps.providerDescriptors,
712
+ env: deps.env,
713
+ fallbackEnabled: deps.fallbackEnabled,
714
+ writeStderr: (s) => deps.invocation.writeStderr(s),
715
+ }, async (descriptor) => {
716
+ const adapter = descriptor.create({ env: deps.env });
717
+ return map(url, {
718
+ depth: flags.depth ? parseInt(flags.depth, 10) : undefined,
719
+ breadth: flags.breadth ? parseInt(flags.breadth, 10) : undefined,
720
+ limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
721
+ selectPaths: flags["select-paths"],
722
+ excludePaths: flags["exclude-paths"],
723
+ instructions: flags.instructions,
724
+ noCache: flags["no-cache"] === true,
725
+ }, {
726
+ capability: adapter.map,
727
+ execution: executionDeps,
728
+ }, context);
729
+ });
730
+ return outcome.result;
731
+ }, outputMode, deps.now, deps.secrets);
615
732
  }
616
733
  async function handleResearch(args, outputMode, deps) {
617
734
  const { flags, positional } = parseArgs(args);
@@ -627,42 +744,82 @@ async function handleResearch(args, outputMode, deps) {
627
744
  const outputLength = validateResearchEnum(flags["output-length"], ["short", "standard", "long"], "--output-length");
628
745
  const citationFormat = validateResearchEnum(flags["citation-format"], ["numbered", "mla", "apa", "chicago"], "--citation-format");
629
746
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
630
- // explicit --provider > SCOUTLINE_PROVIDER > default zai.
631
- 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
- }
747
+ // explicit --provider > SCOUTLINE_PROVIDER > quota-ranked pick.
748
+ // (PB-T4.)
749
+ const providerId = resolveEffectiveProvider({
750
+ explicitProvider: deps.provider,
751
+ env: deps.env,
752
+ capabilityId: "research",
753
+ descriptors: deps.providerDescriptors,
754
+ quotaSnapshot: deps.quotaState,
755
+ });
647
756
  // Shared Research execution dependencies.
648
757
  const executionDeps = {
649
758
  cache: deps.researchCache,
650
759
  sleep: deps.researchSleep,
651
760
  random: deps.researchRandom,
652
761
  };
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);
762
+ // Provider-fallback Ticket 03: route the call through the shared
763
+ // fallback executor. The research command is the riskiest piece
764
+ // of the async set: the SIGINT handler, the polling-timeout
765
+ // controller, and the on-disk state-file identity/timeout must
766
+ // RE-BIND to the Provider that actually wins the candidate loop
767
+ // (Tech Plan §"Handler non-uniformity" — research bullet). The
768
+ // `research` command accepts a `registerInterrupt` injection on
769
+ // its handler dependencies; the executor's per-attempt callback
770
+ // builds the descriptor's `capability.run` and re-registers the
771
+ // SIGINT handler under THAT provider's identity before invoke,
772
+ // tearing it down on throw so the next candidate installs its
773
+ // own. The one-time credit warning is emitted ONCE, before the
774
+ // first attempt, so it is not duplicated across candidate
775
+ // switches.
776
+ return invokeCommand(deps.invocation, async (context) => {
777
+ const options = {
778
+ model,
779
+ outputLength,
780
+ citationFormat,
781
+ domain: flags.domain,
782
+ maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
783
+ timeout: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
784
+ noCache: flags["no-cache"] === true,
785
+ };
786
+ // Closure-guarded one-time credit warning (Review Fix 7). The
787
+ // flag is captured INSIDE the attempt closure, so the line is
788
+ // written exactly once — the first time the executor actually
789
+ // visits a candidate. When the executor's preflight rejects
790
+ // every plan entry before invoking `attempt` (capability-
791
+ // mismatch, no candidates configured), the closure is never
792
+ // entered and the warning stays silent. Strict mode
793
+ // (`--no-fallback`) already runs the same preflight on the
794
+ // single remaining candidate so an ineligible effective also
795
+ // stays silent.
796
+ let warned = false;
797
+ const creditWarning = `Research in progress — this is a credit-intensive operation that may take several minutes.\n`;
798
+ const emitCreditWarning = () => {
799
+ if (warned)
800
+ return;
801
+ warned = true;
802
+ deps.invocation.writeStderr(creditWarning);
803
+ };
804
+ const outcome = await executeWithFallback({
805
+ capabilityId: "research",
806
+ commandLabel: "research",
807
+ effectiveProvider: providerId,
808
+ descriptors: deps.providerDescriptors,
809
+ env: deps.env,
810
+ fallbackEnabled: deps.fallbackEnabled,
811
+ writeStderr: (s) => deps.invocation.writeStderr(s),
812
+ }, async (descriptor) => {
813
+ emitCreditWarning();
814
+ const adapter = descriptor.create({ env: deps.env });
815
+ return research(query, options, {
816
+ capability: adapter.research,
817
+ execution: executionDeps,
818
+ registerInterrupt: deps.researchRegisterInterrupt,
819
+ }, context);
820
+ });
821
+ return outcome.result;
822
+ }, outputMode, deps.now, deps.secrets);
666
823
  }
667
824
  /**
668
825
  * Validate a research enum flag (--model, --output-length,
@@ -721,34 +878,18 @@ async function handleRepo(args, outputMode, deps) {
721
878
  throw new ValidationError(`Unknown repo command: ${command}`, 'Run "scoutline repo --help" for available commands');
722
879
  }
723
880
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
724
- // explicit --provider > SCOUTLINE_PROVIDER > default zai. Selection
725
- // never consults credentials (FR-003) and never branches on Provider
726
- // ID; an unknown explicit/env value throws VALIDATION_ERROR here.
727
- 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
- }
881
+ // explicit --provider > SCOUTLINE_PROVIDER > quota-ranked pick.
882
+ // Selection never consults credentials beyond the descriptor
883
+ // `isConfigured` metadata check (FR-003) and never branches on
884
+ // Provider ID; an unknown explicit/env value throws
885
+ // VALIDATION_ERROR here. (PB-T4.)
886
+ const providerId = resolveEffectiveProvider({
887
+ explicitProvider: deps.provider,
888
+ env: deps.env,
889
+ capabilityId: "repository-exploration",
890
+ descriptors: deps.providerDescriptors,
891
+ quotaSnapshot: deps.quotaState,
892
+ });
752
893
  // Shared Repository execution dependencies. The cache/sleep/random
753
894
  // default to the same production values as Search but are kept as
754
895
  // separate optional MainDependencies so repository tests can inject
@@ -758,30 +899,43 @@ async function handleRepo(args, outputMode, deps) {
758
899
  sleep: deps.repositorySleep,
759
900
  random: deps.repositoryRandom,
760
901
  };
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
- }
902
+ const language = flags.language;
903
+ const maxChars = flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined;
904
+ const noCache = flags["no-cache"] === true;
905
+ const treePath = flags.path;
906
+ const depth = flags.depth ? parseInt(flags.depth, 10) : undefined;
907
+ // Provider-fallback Ticket 02: route every subcommand through the
908
+ // shared executor. All three projections (search/tree/read) share
909
+ // the `repository-exploration` Capability, so a single executor
910
+ // invocation wraps the chosen subcommand.
911
+ return invokeCommand(deps.invocation, async (context) => {
912
+ const outcome = await executeWithFallback({
913
+ capabilityId: "repository-exploration",
914
+ commandLabel: "repo",
915
+ effectiveProvider: providerId,
916
+ descriptors: deps.providerDescriptors,
917
+ env: deps.env,
918
+ fallbackEnabled: deps.fallbackEnabled,
919
+ writeStderr: (s) => deps.invocation.writeStderr(s),
920
+ }, async (descriptor) => {
921
+ const adapter = descriptor.create({ env: deps.env });
922
+ const capability = adapter.repository;
923
+ switch (command) {
924
+ case "search":
925
+ return repoSearch(repo, searchQuery, { language, maxChars, noCache }, { capability, execution: executionDeps }, context);
926
+ case "tree":
927
+ return repoTree(repo, { path: treePath, depth, noCache }, { capability, execution: executionDeps }, context);
928
+ case "read":
929
+ return repoRead(repo, readPath, { maxChars, noCache }, { capability, execution: executionDeps }, context);
930
+ default:
931
+ // Unreachable: the parse-level validation above already
932
+ // rejected unknown subcommands. Keep a defensive throw so
933
+ // the dispatch table stays total.
934
+ throw new ValidationError(`Unknown repo command: ${command}`, 'Run "scoutline repo --help" for available commands');
935
+ }
936
+ });
937
+ return outcome.result;
938
+ }, outputMode, deps.now, deps.secrets);
785
939
  }
786
940
  async function handleTools(args, outputMode, deps) {
787
941
  const { flags } = parseArgs(args);
@@ -794,6 +948,7 @@ async function handleTools(args, outputMode, deps) {
794
948
  full: flags.full === true,
795
949
  typescript: flags.typescript === true || flags.ts === true,
796
950
  enableVision: flags.vision !== false,
951
+ env: deps.env,
797
952
  }, context), outputMode, deps.now, deps.secrets);
798
953
  }
799
954
  async function handleTool(args, outputMode, deps) {
@@ -802,7 +957,7 @@ async function handleTool(args, outputMode, deps) {
802
957
  deps.invocation.writeStdout(TOOLS_HELP);
803
958
  return 0;
804
959
  }
805
- return invokeCommand(deps.invocation, (context) => showTool(positional[0], { enableVision: flags.vision !== false }, context), outputMode, deps.now, deps.secrets);
960
+ return invokeCommand(deps.invocation, (context) => showTool(positional[0], { enableVision: flags.vision !== false, env: deps.env }, context), outputMode, deps.now, deps.secrets);
806
961
  }
807
962
  async function handleCall(args, outputMode, deps) {
808
963
  const { flags, positional } = parseArgs(args);
@@ -816,6 +971,7 @@ async function handleCall(args, outputMode, deps) {
816
971
  stdin: flags.stdin === true,
817
972
  dryRun: flags["dry-run"] === true,
818
973
  enableVision: flags.vision !== false,
974
+ env: deps.env,
819
975
  }, context), outputMode, deps.now, deps.secrets);
820
976
  }
821
977
  async function handleDoctor(args, outputMode, deps) {
@@ -845,6 +1001,29 @@ async function handleDoctor(args, outputMode, deps) {
845
1001
  sleep: deps.searchSleep,
846
1002
  random: deps.searchRandom,
847
1003
  cacheSummary,
1004
+ // T3b: thread the injected promoter + clock through to the
1005
+ // report builder so a successful probe can flip the matching
1006
+ // record from `unverified` to `verified`. When the promoter
1007
+ // is absent (hermetic test path), Doctor runs without
1008
+ // promotion. The write-failure sink surfaces promotion
1009
+ // errors as a stderr notice without affecting the exit code.
1010
+ verificationPromoter: deps.verificationPromoter,
1011
+ now: deps.now ?? Date.now,
1012
+ onPromotionError: (providerId, error) => {
1013
+ const message = error instanceof Error ? error.message : String(error);
1014
+ deps.invocation.writeStderr(`scoutline: verification promotion failed for "${providerId}": ${message}\n`);
1015
+ },
1016
+ // PB-T5: thread the snapshot + verification records through
1017
+ // to the report builder so each Provider entry carries a
1018
+ // `quota` summary (source/freshness) and a `verification`
1019
+ // summary. Both are pure state reads; Doctor never
1020
+ // live-probes quota. The snapshot appears even under
1021
+ // --no-tools (a snapshot read is local state, not
1022
+ // transport).
1023
+ ...(deps.quotaState !== undefined ? { quotaSnapshot: deps.quotaState } : {}),
1024
+ ...(deps.verificationRecords !== undefined
1025
+ ? { verificationRecords: deps.verificationRecords }
1026
+ : {}),
848
1027
  }),
849
1028
  }), outputMode, deps.now, deps.secrets);
850
1029
  }
@@ -904,6 +1083,17 @@ async function handleQuota(args, outputMode, deps) {
904
1083
  env: deps.env,
905
1084
  sleep: deps.searchSleep,
906
1085
  random: deps.searchRandom,
1086
+ // PB-T5: thread the snapshot + store + clock through so the
1087
+ // dashboard reads each Provider's snapshot first, labels
1088
+ // source/freshness, and persists a live-probe fallback via
1089
+ // the awaited write-through. When `quotaState` is absent
1090
+ // (test path that didn't opt in), the snapshot path is
1091
+ // disabled and every configured Provider is live-probed
1092
+ // (byte-for-byte pre-PB-T5 behavior — no `quotaSource`
1093
+ // field attached).
1094
+ ...(deps.quotaState !== undefined ? { quotaSnapshot: deps.quotaState } : {}),
1095
+ ...(deps.quotaStore !== undefined ? { quotaStore: deps.quotaStore } : {}),
1096
+ now: deps.now ?? Date.now,
907
1097
  }),
908
1098
  writeStderr: (value) => deps.invocation.writeStderr(value),
909
1099
  secrets: deps.secrets,
@@ -924,17 +1114,17 @@ async function handleCode(args, outputMode, deps) {
924
1114
  if (!filePath) {
925
1115
  throw new ValidationError("Missing code file", "Usage: scoutline code run <file>");
926
1116
  }
927
- return invokeCommand(deps.invocation, (context) => runCodeFile(filePath, { timeout, includeLogs }, context), outputMode, deps.now, deps.secrets);
1117
+ return invokeCommand(deps.invocation, (context) => runCodeFile(filePath, { timeout, includeLogs, env: deps.env }, context), outputMode, deps.now, deps.secrets);
928
1118
  }
929
1119
  case "eval": {
930
1120
  const code = positional.slice(1).join(" ");
931
1121
  if (!code) {
932
1122
  throw new ValidationError("Missing code string", "Usage: scoutline code eval <code>");
933
1123
  }
934
- return invokeCommand(deps.invocation, (context) => evalCode(code, { timeout, includeLogs }, context), outputMode, deps.now, deps.secrets);
1124
+ return invokeCommand(deps.invocation, (context) => evalCode(code, { timeout, includeLogs, env: deps.env }, context), outputMode, deps.now, deps.secrets);
935
1125
  }
936
1126
  case "interfaces":
937
- return invokeCommand(deps.invocation, (context) => printInterfaces(context), outputMode, deps.now, deps.secrets);
1127
+ return invokeCommand(deps.invocation, (context) => printInterfaces({ env: deps.env }, context), outputMode, deps.now, deps.secrets);
938
1128
  case "prompt":
939
1129
  return invokeCommand(deps.invocation, async (context) => printPromptTemplate(context), outputMode, deps.now, deps.secrets);
940
1130
  default:
@@ -942,6 +1132,37 @@ async function handleCode(args, outputMode, deps) {
942
1132
  }
943
1133
  }
944
1134
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1135
+ /**
1136
+ * Map Plan A's `ProviderVerification` records (config-store shape) to
1137
+ * the capability contract's `ProviderVerificationSummary` (PB-T5). The
1138
+ * shapes are structurally identical; the indirection exists only so
1139
+ * `capabilities/diagnostics.ts` does not import `lib/config-store.ts`
1140
+ * (the capability contract keeps a strict import boundary). Returns
1141
+ * `undefined` when no Provider has a verification record, so the
1142
+ * dispatcher can omit the dependency entirely (Doctor's report builder
1143
+ * leaves the `verification` field off in that case).
1144
+ *
1145
+ * When the caller injected its own `verificationRecords` (test path),
1146
+ * that injection wins — the test owns the verification view.
1147
+ */
1148
+ function loadVerificationRecords(config, dependencies) {
1149
+ if (dependencies.verificationRecords !== undefined)
1150
+ return dependencies.verificationRecords;
1151
+ const out = {};
1152
+ let hasAny = false;
1153
+ for (const id of Object.keys(config.providers)) {
1154
+ const record = config.providers[id]?.verification;
1155
+ if (!record)
1156
+ continue;
1157
+ out[id] = {
1158
+ status: record.status,
1159
+ checkedAt: record.checkedAt,
1160
+ ...(record.reason !== undefined ? { reason: record.reason } : {}),
1161
+ };
1162
+ hasAny = true;
1163
+ }
1164
+ return hasAny ? out : undefined;
1165
+ }
945
1166
  export async function main(args, dependencies) {
946
1167
  const { invocation, env, now } = dependencies;
947
1168
  const providerDescriptors = dependencies.providerDescriptors ?? BUILT_IN_PROVIDER_DESCRIPTORS;
@@ -985,8 +1206,13 @@ export async function main(args, dependencies) {
985
1206
  // Resolve configured Provider credentials from the INJECTED env (B3) so
986
1207
  // redaction follows the same environment the handlers see — a secret
987
1208
  // that exists only in MainDependencies.env is still redacted from output.
988
- const secrets = configuredSecrets(env);
989
- const { outputFormat, forcePretty, forceRaw, provider, rest } = extractGlobalOptions([...args]);
1209
+ // T2a: this env-only view is used by credential-free commands that
1210
+ // short-circuit before config load; the full resolvedEnv (env + file keys)
1211
+ // is computed below for credentialed paths.
1212
+ const envSecrets = configuredSecrets(env);
1213
+ const { outputFormat, forcePretty, forceRaw, provider, noFallback, rest } = extractGlobalOptions([
1214
+ ...args,
1215
+ ]);
990
1216
  // Fixup C — B10: resolve the output mode BEFORE the dispatch try/catch.
991
1217
  // An invalid explicit mode still surfaces as a typed ValidationError,
992
1218
  // but the surface formatter uses the user's REQUESTED mode (or the
@@ -1001,9 +1227,12 @@ export async function main(args, dependencies) {
1001
1227
  catch (error) {
1002
1228
  // The explicit mode is invalid — fall back to a deterministic
1003
1229
  // compact form so we can still surface a structured error envelope.
1004
- invocation.writeStderr(formatErrorOutput(error, "data", secrets));
1230
+ invocation.writeStderr(formatErrorOutput(error, "data", envSecrets));
1005
1231
  return getErrorExitCode(error);
1006
1232
  }
1233
+ // Credential-free commands: --help / --version short-circuit before any
1234
+ // config-file load so a corrupt or unreadable config.json never blocks
1235
+ // them (T2a — command classification).
1007
1236
  if (rest.length === 0 || rest[0] === "--help" || rest[0] === "-h") {
1008
1237
  invocation.writeStdout(MAIN_HELP);
1009
1238
  return 0;
@@ -1014,13 +1243,53 @@ export async function main(args, dependencies) {
1014
1243
  }
1015
1244
  const command = rest[0];
1016
1245
  const commandArgs = rest.slice(1);
1017
- const handlerDeps = {
1246
+ // PB-T1/PB-T2 — Quota snapshot store + consumption sink.
1247
+ //
1248
+ // Constructed once here so `buildHandlerDeps` can close over
1249
+ // `consume` and thread it through every handler. The hermeticity
1250
+ // gate (`quotaRefreshEnabled`, mirroring trigger-detection): the
1251
+ // PRODUCTION sink is built ONLY in full production mode (no
1252
+ // injected `loadScoutlineConfig` AND no injected `providerDescriptors`).
1253
+ // Either injection signals a test that owns its own descriptor/config
1254
+ // construction; without this gate, the production sink would silently
1255
+ // reach the user's real `~/.scoutline/state.json` during a test run
1256
+ // that did not inject `quotaStore` either. Tests that need to assert
1257
+ // on consumption inject `MainDependencies.consume` directly.
1258
+ //
1259
+ // The sink is the *where* of consumption recording; shared execution
1260
+ // (`lib/execution.ts`) is the *when* (one event per billable invoke
1261
+ // attempt, after the cache-miss check, around each `invoke()` call).
1262
+ // Shared execution awaits `record()` per invoke attempt, so the write
1263
+ // is on the critical path before the result returns outward —
1264
+ // surviving the bin's immediate `process.exit(status)`.
1265
+ const quotaRefreshEnabled = !dependencies.loadScoutlineConfig && !dependencies.providerDescriptors;
1266
+ const quotaStore = dependencies.quotaStore ?? createDefaultQuotaStore();
1267
+ const consume = dependencies.consume ??
1268
+ (quotaRefreshEnabled
1269
+ ? createQuotaStoreConsumptionSink({ store: quotaStore, now: now ?? Date.now })
1270
+ : undefined);
1271
+ // PB-T4: quota snapshot for selection. Declared here so
1272
+ // `buildHandlerDeps` closes over the binding; assigned AFTER the
1273
+ // PB-T1 pre-command refresh so observational commands' fresh data is
1274
+ // reflected. The seven shared handlers consume this via
1275
+ // `resolveEffectiveProvider`; Doctor/quota/cache/init/raw-tools
1276
+ // ignore it. Hermeticity gate mirrors `consume`: the PRODUCTION read
1277
+ // runs only in full production mode; tests inject `quotaState`
1278
+ // directly when they assert a specific selection outcome, and
1279
+ // otherwise leave it undefined so the resolver degrades to
1280
+ // first-eligible (pre-PB-T4 behaviour).
1281
+ let quotaState = dependencies.quotaState;
1282
+ // Build HandlerDependencies for a given credential view. The
1283
+ // cache/sleep/random fields are always available (resolved above); only
1284
+ // env/secrets/fallbackEnabled depend on whether config has been loaded.
1285
+ const buildHandlerDeps = (credEnv, credSecrets, credFallback) => ({
1018
1286
  invocation,
1019
- env,
1020
- secrets,
1287
+ env: credEnv,
1288
+ secrets: credSecrets,
1021
1289
  now,
1022
1290
  provider,
1023
1291
  providerDescriptors,
1292
+ fallbackEnabled: credFallback,
1024
1293
  searchCache,
1025
1294
  searchSleep,
1026
1295
  searchRandom,
@@ -1039,40 +1308,342 @@ export async function main(args, dependencies) {
1039
1308
  researchCache,
1040
1309
  researchSleep,
1041
1310
  researchRandom,
1311
+ researchRegisterInterrupt: dependencies.researchRegisterInterrupt,
1312
+ // Promoter wiring (T3b). When the caller explicitly injects a
1313
+ // promoter, use it (tests assert behavior this way). Otherwise, in
1314
+ // PRODUCTION (no injected config reader), construct the default
1315
+ // real-file promoter. When a test injects `loadScoutlineConfig`
1316
+ // (hermetic in-memory config), promotion is DISABLED so the
1317
+ // default promoter never reaches the user's real
1318
+ // ~/.scoutline/config.json during a test run.
1319
+ verificationPromoter: dependencies.verificationPromoter ??
1320
+ (dependencies.loadScoutlineConfig ? undefined : createDefaultVerificationPromoter()),
1321
+ // PB-T2: thread the production consumption sink through. `consume`
1322
+ // is constructed once in `main` (below) and shared by every
1323
+ // handler; tests inject their own through `MainDependencies.consume`.
1324
+ // When undefined (test path that didn't opt in), no events fire.
1325
+ consume,
1326
+ // PB-T4: thread the quota snapshot for selection. `quotaState` is
1327
+ // resolved once in `main` (below) after the PB-T1 pre-command
1328
+ // refresh and shared by every handler; tests inject their own
1329
+ // through `MainDependencies.quotaState`. When undefined (test path
1330
+ // or no-snapshot production run), `resolveEffectiveProvider`
1331
+ // degrades to first-eligible in registry order.
1332
+ quotaState,
1333
+ // PB-T5: thread the quota store for `quota`'s live-probe
1334
+ // write-through. Doctor ignores this field (it never live-probes
1335
+ // quota). Tests inject an in-memory double; production wires the
1336
+ // singleton constructed in `main`.
1337
+ quotaStore: dependencies.quotaStore ?? (quotaRefreshEnabled ? quotaStore : undefined),
1338
+ // PB-T5: verification records are NOT threaded here. They are
1339
+ // derived from `config` AFTER it is loaded (the credentialed
1340
+ // path); `buildHandlerDeps` runs once BEFORE config load (the
1341
+ // cache short-circuit), so referencing `config` here would be a
1342
+ // use-before-initialization error. The post-config
1343
+ // `handlerDepsWithVerification` spread below threads the resolved
1344
+ // records in for Doctor.
1345
+ });
1346
+ // Cache is credential-free (no Provider resolution, no descriptor lookup,
1347
+ // no transport). Short-circuit before config load so a corrupt
1348
+ // config.json never blocks cache inspection or clearing. Env-only
1349
+ // secrets suffice: the stats/clear output carries no credentials.
1350
+ if (command === "cache") {
1351
+ try {
1352
+ return await handleCache(commandArgs, outputMode, buildHandlerDeps(env, envSecrets, true));
1353
+ }
1354
+ catch (error) {
1355
+ invocation.writeStderr(formatErrorOutput(error, outputMode, envSecrets));
1356
+ return getErrorExitCode(error);
1357
+ }
1358
+ }
1359
+ // `init` manages config itself (it inspects + writes via the T1
1360
+ // primitives, never reads the resolvedEnv the credentialed path
1361
+ // produces). Short-circuit before the credentialed config load so a
1362
+ // corrupt or unreadable config.json never blocks the wizard that is
1363
+ // documented to repair it. The wizard is presented-only; it does not
1364
+ // dispatch through the credentialed handler boundary. Release gate
1365
+ // (T3a ticket): the command's code lands now, but its public docs
1366
+ // (MAIN_HELP Commands list, README setup, skills/) wait for T3b.
1367
+ if (command === "init") {
1368
+ const initDeps = {
1369
+ descriptors: providerDescriptors,
1370
+ prompts: dependencies.initPrompts ?? createInquirerPrompts(),
1371
+ configStore: dependencies.initConfigStore ?? createDefaultConfigStore(),
1372
+ env,
1373
+ now: now ?? Date.now,
1374
+ stdinIsTTY: invocation.stdinIsTTY,
1375
+ writeStderr: (value) => invocation.writeStderr(value),
1376
+ writeStdout: (value) => invocation.writeStdout(value),
1377
+ };
1378
+ try {
1379
+ return await handleInitWithHelp(commandArgs, initDeps);
1380
+ }
1381
+ catch (error) {
1382
+ invocation.writeStderr(formatErrorOutput(error, outputMode, envSecrets));
1383
+ return getErrorExitCode(error);
1384
+ }
1385
+ }
1386
+ // Credentialed path: load the config file (T2a — Plan A). T3b makes
1387
+ // the load TOLERANT so command help remains usable under a corrupt
1388
+ // config (review item 9: "do not force a credential check merely to
1389
+ // render help"). The injected reader (when provided) is still strict
1390
+ // for backward compatibility with existing tests; the production
1391
+ // path uses `inspectConfig`.
1392
+ //
1393
+ // Classification of the corrupt case:
1394
+ // - command help (`<cmd> --help`): proceed with an empty config so
1395
+ // the handler can render its help. The handler's own help
1396
+ // short-circuit never touches config.
1397
+ // - everything else (including observational `doctor`/`quota`):
1398
+ // refuse with the existing CONFIGURATION_ERROR (exit 3). The
1399
+ // error's help points at `init` as the recovery path.
1400
+ // Observational commands do NOT bypass the corrupt refuse — they
1401
+ // still need `resolvedEnv` (which needs config) to probe/report.
1402
+ const isHelpInvocation = isCommandHelpInvocation(commandArgs);
1403
+ const isObservational = OBSERVATIONAL_COMMANDS.has(command);
1404
+ let config;
1405
+ if (dependencies.loadScoutlineConfig) {
1406
+ try {
1407
+ config = await dependencies.loadScoutlineConfig();
1408
+ }
1409
+ catch (error) {
1410
+ if (isHelpInvocation) {
1411
+ config = { version: 1, providers: {} };
1412
+ }
1413
+ else {
1414
+ invocation.writeStderr(formatErrorOutput(error, outputMode, envSecrets));
1415
+ return getErrorExitCode(error);
1416
+ }
1417
+ }
1418
+ }
1419
+ else {
1420
+ const inspection = await inspectConfig();
1421
+ if (inspection.status === "corrupt") {
1422
+ if (isHelpInvocation) {
1423
+ config = { version: 1, providers: {} };
1424
+ }
1425
+ else {
1426
+ invocation.writeStderr(formatErrorOutput(inspection.error, outputMode, envSecrets));
1427
+ return getErrorExitCode(inspection.error);
1428
+ }
1429
+ }
1430
+ else if (inspection.status === "absent") {
1431
+ config = { version: 1, providers: {} };
1432
+ }
1433
+ else {
1434
+ config = inspection.config;
1435
+ }
1436
+ }
1437
+ // Build resolvedEnv: the injected env with file-configured API keys
1438
+ // layered in for any Provider NOT already configured via env (env
1439
+ // overrides file; alias precedence preserved). process.env is never
1440
+ // mutated — the returned object is a fresh shallow copy owned by this
1441
+ // invocation.
1442
+ const resolvedEnv = resolveEnvFromConfig(env, config, providerDescriptors);
1443
+ const secrets = configuredSecrets(resolvedEnv);
1444
+ // Trigger detection (T3b — Option B). The ONLY interception here is
1445
+ // the env-only one-time hint: when the user is running on
1446
+ // environment-variable credentials and has never been through
1447
+ // `scoutline init`, we emit a single stderr hint pointing at the
1448
+ // wizard and persist `config.json.hintShown` so it never repeats.
1449
+ // The command then runs normally and preserves its natural output/exit.
1450
+ //
1451
+ // The "missing credential everywhere" case is NOT intercepted here:
1452
+ // the handler's own preflight already surfaces the existing
1453
+ // `CONFIGURATION_ERROR` exit 3 (see
1454
+ // {@link missingCredentialError} in trigger-detection.ts for the
1455
+ // shared error contract). Intercepting it pre-dispatch would break
1456
+ // the locked validation-before-configuration ordering (an invalid
1457
+ // `--count` must exit 1 with VALIDATION_ERROR even when no credential
1458
+ // is present — see search.test.js "count validation ordering").
1459
+ //
1460
+ // Hermeticity: trigger detection runs ONLY in full production mode
1461
+ // (no injected `loadScoutlineConfig` AND no injected
1462
+ // `providerDescriptors`). Either injection signals a test that owns
1463
+ // its own config/descriptor construction; fake descriptors routinely
1464
+ // lie about `isConfigured`, so trusting them here would mis-classify.
1465
+ // Dedicated trigger-detection tests run via subprocess (the real
1466
+ // binary) or via `main()` without injecting either, optionally
1467
+ // pointed at a temp `SCOUTLINE_CONFIG_DIR`.
1468
+ const triggerDetectionEnabled = !dependencies.loadScoutlineConfig && !dependencies.providerDescriptors;
1469
+ if (triggerDetectionEnabled && !isHelpInvocation && !isObservational) {
1470
+ const state = classifyCredentialState({
1471
+ descriptors: providerDescriptors,
1472
+ env,
1473
+ resolvedEnv,
1474
+ config,
1475
+ });
1476
+ if (state.kind === "env-only" && config.hintShown !== true) {
1477
+ invocation.writeStderr(formatEnvOnlyHint());
1478
+ // Persist hintShown best-effort. A write failure is isolated: the
1479
+ // hint simply does not repeat within this process; the next run
1480
+ // tries again. The injected store keeps tests hermetic.
1481
+ const hintStore = dependencies.hintShownStore ?? createDefaultHintShownStore();
1482
+ try {
1483
+ await hintStore.setHintShown();
1484
+ }
1485
+ catch {
1486
+ // Best-effort: do not turn a hint into a failure.
1487
+ }
1488
+ }
1489
+ }
1490
+ // Provider-fallback resolution (T2a — review blocker 5). Precedence:
1491
+ // 1. Invocation opt-out (`--no-fallback` flag or `SCOUTLINE_NO_FALLBACK`
1492
+ // env non-empty) — either disables the cross-Provider candidate
1493
+ // loop, matching the 0.11.0 kill-switch contract.
1494
+ // 2. `config.fallbackEnabled` — the wizard's Step-5 preference, so the
1495
+ // onboarding answer is no longer write-only.
1496
+ // 3. Default `true` (the 0.11.0 always-on contract).
1497
+ const envDisablesFallback = typeof env.SCOUTLINE_NO_FALLBACK === "string" && env.SCOUTLINE_NO_FALLBACK.length > 0;
1498
+ const fallbackEnabled = noFallback || envDisablesFallback ? false : (config.fallbackEnabled ?? true);
1499
+ const handlerDeps = buildHandlerDeps(resolvedEnv, secrets, fallbackEnabled);
1500
+ // PB-T5 — derive Plan A verification records from the loaded config
1501
+ // AFTER `config` is in scope. `buildHandlerDeps` runs once BEFORE
1502
+ // config load (the cache short-circuit), so this derivation cannot
1503
+ // live inside it (TDZ on `config`). Returned as a fresh spread so
1504
+ // the cache/init paths that already returned are unaffected; only
1505
+ // the credentialed handler chain sees the verification records.
1506
+ // Returns `undefined` when no Provider has a verification record,
1507
+ // leaving the field absent (Doctor's report builder omits the
1508
+ // `verification` field in that case — backward-compatible).
1509
+ const verificationRecords = loadVerificationRecords(config, dependencies);
1510
+ const handlerDepsWithVerification = verificationRecords === undefined ? handlerDeps : { ...handlerDeps, verificationRecords };
1511
+ // PB-T1 — Quota snapshot refresh lifecycle.
1512
+ //
1513
+ // The refresh is awaited and bounded (review blocker 3): every
1514
+ // refresh and store write is awaited before `main` returns so it
1515
+ // survives the bin's immediate `process.exit(status)`. There is no
1516
+ // fire-and-forget tail.
1517
+ //
1518
+ // Two triggers:
1519
+ // 1. Explicit `quota`/`doctor` refresh (force) — runs BEFORE the
1520
+ // handler so the dashboard/report reflects fresh data. The
1521
+ // cadence gate is bypassed (the user asked for fresh data); the
1522
+ // per-provider transport timeout + single-attempt contract
1523
+ // still applies.
1524
+ // 2. After-command due-refresh (cadence-gated) — runs AFTER every
1525
+ // other recognized credentialed command. A provider whose
1526
+ // `observedAt` is within the staleness threshold is skipped
1527
+ // (Tavily's 10/10min is the floor). This keeps the snapshot
1528
+ // live between explicit refreshes without excessive polling.
1529
+ //
1530
+ // Failure policy: best-effort. A per-provider refresh failure is
1531
+ // routed to a stderr notice and never rejects the outer promise. The
1532
+ // snapshot stays stale; the next due-refresh retries.
1533
+ //
1534
+ // Lock scope: each provider's `capability.invoke()` runs in parallel
1535
+ // (Promise.all). Store writes are serialized within the process by
1536
+ // the per-file async mutex in `createDefaultQuotaStore`. Cross-process
1537
+ // concurrency is last-write-wins (acceptable for an observational
1538
+ // heuristic; the atomic rename keeps the final state crash-safe).
1539
+ //
1540
+ // Hermeticity gate: the refresh runs ONLY in full production mode
1541
+ // (no injected `loadScoutlineConfig` AND no injected
1542
+ // `providerDescriptors`). Either injection signals a test that owns
1543
+ // its own descriptor/config construction; fake descriptors routinely
1544
+ // count `invoke()` calls and would see the refresh as a stray
1545
+ // invocation. Dedicated refresh lifecycle tests run via subprocess
1546
+ // (the real binary) or through `refreshQuotaSnapshots` directly.
1547
+ // This mirrors the trigger-detection gate (T3b).
1548
+ //
1549
+ // (`quotaRefreshEnabled`, `quotaStore`, and `consume` are declared
1550
+ // earlier alongside `buildHandlerDeps` so the sink closes over a
1551
+ // defined binding — see PB-T2 above.)
1552
+ const isQuotaObservationalCommand = command === "quota" || command === "doctor";
1553
+ const refreshOnError = (providerId, error) => {
1554
+ const message = error instanceof Error ? error.message : String(error);
1555
+ invocation.writeStderr(`scoutline: quota refresh failed for "${providerId}": ${message}\n`);
1042
1556
  };
1557
+ if (quotaRefreshEnabled && isQuotaObservationalCommand) {
1558
+ await refreshQuotaSnapshots({
1559
+ descriptors: providerDescriptors,
1560
+ env: resolvedEnv,
1561
+ store: quotaStore,
1562
+ now: now ?? Date.now,
1563
+ force: true,
1564
+ onError: refreshOnError,
1565
+ });
1566
+ }
1567
+ // PB-T4: read the quota snapshot once for selection, AFTER any
1568
+ // pre-command refresh so observational commands' fresh data is
1569
+ // reflected. `quotaStore.read()` is fail-open (a corrupt or absent
1570
+ // `state.json` yields an empty state + warning, never a throw), so
1571
+ // this can never block dispatch. Tests that need a specific
1572
+ // selection outcome inject `MainDependencies.quotaState` directly,
1573
+ // which short-circuits this read. The snapshot is observational; the
1574
+ // resolver never writes or decrements it.
1575
+ if (dependencies.quotaState === undefined && quotaRefreshEnabled) {
1576
+ quotaState = await quotaStore.read();
1577
+ }
1578
+ // Thread the resolved snapshot into the handler deps. When undefined
1579
+ // (test path that didn't opt in, or non-production mode without an
1580
+ // injected snapshot), `resolveEffectiveProvider` degrades to
1581
+ // first-eligible — the pre-PB-T4 behaviour. Built on top of
1582
+ // `handlerDepsWithVerification` so PB-T5's verification records also
1583
+ // reach every handler.
1584
+ const handlerDepsWithSelection = quotaState === undefined
1585
+ ? handlerDepsWithVerification
1586
+ : { ...handlerDepsWithVerification, quotaState };
1587
+ let exitCode;
1588
+ let commandRecognized = false;
1043
1589
  try {
1044
1590
  switch (command) {
1045
1591
  case "vision":
1046
- return await handleVision(commandArgs, outputMode, handlerDeps);
1592
+ commandRecognized = true;
1593
+ exitCode = await handleVision(commandArgs, outputMode, handlerDepsWithSelection);
1594
+ break;
1047
1595
  case "search":
1048
- return await handleSearch(commandArgs, outputMode, handlerDeps);
1596
+ commandRecognized = true;
1597
+ exitCode = await handleSearch(commandArgs, outputMode, handlerDepsWithSelection);
1598
+ break;
1049
1599
  case "read":
1050
- return await handleRead(commandArgs, outputMode, handlerDeps);
1600
+ commandRecognized = true;
1601
+ exitCode = await handleRead(commandArgs, outputMode, handlerDepsWithSelection);
1602
+ break;
1051
1603
  case "crawl":
1052
- return await handleCrawl(commandArgs, outputMode, handlerDeps);
1604
+ commandRecognized = true;
1605
+ exitCode = await handleCrawl(commandArgs, outputMode, handlerDepsWithSelection);
1606
+ break;
1053
1607
  case "map":
1054
- return await handleMap(commandArgs, outputMode, handlerDeps);
1608
+ commandRecognized = true;
1609
+ exitCode = await handleMap(commandArgs, outputMode, handlerDepsWithSelection);
1610
+ break;
1055
1611
  case "research":
1056
- return await handleResearch(commandArgs, outputMode, handlerDeps);
1612
+ commandRecognized = true;
1613
+ exitCode = await handleResearch(commandArgs, outputMode, handlerDepsWithSelection);
1614
+ break;
1057
1615
  case "repo":
1058
- return await handleRepo(commandArgs, outputMode, handlerDeps);
1616
+ commandRecognized = true;
1617
+ exitCode = await handleRepo(commandArgs, outputMode, handlerDepsWithSelection);
1618
+ break;
1059
1619
  case "tools":
1060
- return await handleTools(commandArgs, outputMode, handlerDeps);
1620
+ commandRecognized = true;
1621
+ exitCode = await handleTools(commandArgs, outputMode, handlerDepsWithSelection);
1622
+ break;
1061
1623
  case "tool":
1062
- return await handleTool(commandArgs, outputMode, handlerDeps);
1624
+ commandRecognized = true;
1625
+ exitCode = await handleTool(commandArgs, outputMode, handlerDepsWithSelection);
1626
+ break;
1063
1627
  case "call":
1064
- return await handleCall(commandArgs, outputMode, handlerDeps);
1628
+ commandRecognized = true;
1629
+ exitCode = await handleCall(commandArgs, outputMode, handlerDepsWithSelection);
1630
+ break;
1065
1631
  case "doctor":
1066
- return await handleDoctor(commandArgs, outputMode, handlerDeps);
1067
- case "cache":
1068
- return await handleCache(commandArgs, outputMode, handlerDeps);
1632
+ commandRecognized = true;
1633
+ exitCode = await handleDoctor(commandArgs, outputMode, handlerDepsWithSelection);
1634
+ break;
1069
1635
  case "quota":
1070
- return await handleQuota(commandArgs, outputMode, handlerDeps);
1636
+ commandRecognized = true;
1637
+ exitCode = await handleQuota(commandArgs, outputMode, handlerDepsWithSelection);
1638
+ break;
1071
1639
  case "code":
1072
- return await handleCode(commandArgs, outputMode, handlerDeps);
1640
+ commandRecognized = true;
1641
+ exitCode = await handleCode(commandArgs, outputMode, handlerDepsWithSelection);
1642
+ break;
1073
1643
  default:
1074
1644
  invocation.writeStderr(formatErrorOutput(new ValidationError(`Unknown command: ${command}`, 'Run "scoutline --help" for available commands'), outputMode, secrets));
1075
- return 1;
1645
+ exitCode = 1;
1646
+ break;
1076
1647
  }
1077
1648
  }
1078
1649
  catch (error) {
@@ -1081,7 +1652,32 @@ export async function main(args, dependencies) {
1081
1652
  // formatted in the resolved output mode — they used to be hardcoded
1082
1653
  // to "data" regardless of what the user asked for.
1083
1654
  invocation.writeStderr(formatErrorOutput(error, outputMode, secrets));
1084
- return getErrorExitCode(error);
1085
- }
1655
+ exitCode = getErrorExitCode(error);
1656
+ commandRecognized = true;
1657
+ }
1658
+ // After-command due-refresh (cadence-gated). Skip for:
1659
+ // - `quota`/`doctor` (already force-refreshed before the handler)
1660
+ // - help invocations (`<cmd> --help` exits 0 but did no real work)
1661
+ // - unrecognized commands (the default case above)
1662
+ // Run even when the handler threw (commandRecognized + caught):
1663
+ // the refresh is independent of the command's outcome and the
1664
+ // snapshot stays live regardless. The staleness check inside
1665
+ // `refreshQuotaSnapshots` skips providers whose `observedAt` is
1666
+ // within the threshold, so the common case (fresh snapshot) is a
1667
+ // single state-file read + no transport calls.
1668
+ if (quotaRefreshEnabled &&
1669
+ commandRecognized &&
1670
+ !isQuotaObservationalCommand &&
1671
+ !isHelpInvocation) {
1672
+ await refreshQuotaSnapshots({
1673
+ descriptors: providerDescriptors,
1674
+ env: resolvedEnv,
1675
+ store: quotaStore,
1676
+ now: now ?? Date.now,
1677
+ force: false,
1678
+ onError: refreshOnError,
1679
+ });
1680
+ }
1681
+ return exitCode;
1086
1682
  }
1087
1683
  //# sourceMappingURL=index.js.map