scoutline 0.16.0 → 0.17.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 (58) hide show
  1. package/README.md +47 -0
  2. package/dist/command-invocation.d.ts +2 -2
  3. package/dist/command-invocation.d.ts.map +1 -1
  4. package/dist/command-invocation.js +1 -1
  5. package/dist/command-invocation.js.map +1 -1
  6. package/dist/commands/research.d.ts +45 -0
  7. package/dist/commands/research.d.ts.map +1 -1
  8. package/dist/commands/research.js +152 -4
  9. package/dist/commands/research.js.map +1 -1
  10. package/dist/commands/search.d.ts +11 -0
  11. package/dist/commands/search.d.ts.map +1 -1
  12. package/dist/commands/search.js +31 -2
  13. package/dist/commands/search.js.map +1 -1
  14. package/dist/commands/usage.d.ts +101 -0
  15. package/dist/commands/usage.d.ts.map +1 -0
  16. package/dist/commands/usage.js +228 -0
  17. package/dist/commands/usage.js.map +1 -0
  18. package/dist/commands/vision.d.ts +28 -0
  19. package/dist/commands/vision.d.ts.map +1 -1
  20. package/dist/commands/vision.js +452 -0
  21. package/dist/commands/vision.js.map +1 -1
  22. package/dist/index.d.ts +25 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +572 -10
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/batch-assign.d.ts +61 -0
  27. package/dist/lib/batch-assign.d.ts.map +1 -0
  28. package/dist/lib/batch-assign.js +118 -0
  29. package/dist/lib/batch-assign.js.map +1 -0
  30. package/dist/lib/batch-manifest.d.ts +183 -0
  31. package/dist/lib/batch-manifest.d.ts.map +1 -0
  32. package/dist/lib/batch-manifest.js +644 -0
  33. package/dist/lib/batch-manifest.js.map +1 -0
  34. package/dist/lib/batch-runner.d.ts +169 -0
  35. package/dist/lib/batch-runner.d.ts.map +1 -0
  36. package/dist/lib/batch-runner.js +357 -0
  37. package/dist/lib/batch-runner.js.map +1 -0
  38. package/dist/lib/consumption.d.ts +17 -0
  39. package/dist/lib/consumption.d.ts.map +1 -1
  40. package/dist/lib/consumption.js +38 -0
  41. package/dist/lib/consumption.js.map +1 -1
  42. package/dist/lib/context-file.d.ts +90 -0
  43. package/dist/lib/context-file.d.ts.map +1 -0
  44. package/dist/lib/context-file.js +244 -0
  45. package/dist/lib/context-file.js.map +1 -0
  46. package/dist/lib/trigger-detection.d.ts +21 -0
  47. package/dist/lib/trigger-detection.d.ts.map +1 -1
  48. package/dist/lib/trigger-detection.js +63 -0
  49. package/dist/lib/trigger-detection.js.map +1 -1
  50. package/dist/lib/usage-ledger.d.ts +221 -0
  51. package/dist/lib/usage-ledger.d.ts.map +1 -0
  52. package/dist/lib/usage-ledger.js +408 -0
  53. package/dist/lib/usage-ledger.js.map +1 -0
  54. package/dist/node-command-invocation-adapter.d.ts +25 -0
  55. package/dist/node-command-invocation-adapter.d.ts.map +1 -1
  56. package/dist/node-command-invocation-adapter.js +73 -38
  57. package/dist/node-command-invocation-adapter.js.map +1 -1
  58. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -13,23 +13,32 @@ import { doctor, buildDiagnosticsReport, DOCTOR_HELP } from "./commands/doctor.j
13
13
  import { quota, buildQuotaDashboard, QUOTA_HELP } from "./commands/quota.js";
14
14
  import { cacheStatsCommand, cacheClearCommand, cachePruneCommand, formatDoctorCacheSummary, CACHE_HELP, } from "./commands/cache.js";
15
15
  import { CONFIG_HELP, configGetCommand, configSetCommand, configUnsetCommand, } from "./commands/config.js";
16
+ import { usageCommand, USAGE_HELP, DEFAULT_USAGE_WINDOW_DAYS, MAX_USAGE_WINDOW_DAYS, } from "./commands/usage.js";
16
17
  import { cacheStats, clearAllCaches, parsePruneDuration, pruneCaches } from "./lib/cache.js";
18
+ import { parseBatchManifest } from "./lib/batch-manifest.js";
19
+ import { assignBatchProviders } from "./lib/batch-assign.js";
20
+ import { BATCH_MAX_CONCURRENCY, runBatch, } from "./lib/batch-runner.js";
17
21
  import { isExtractMode } from "./lib/extract.js";
18
22
  import { runCodeFile, evalCode, printInterfaces, printPromptTemplate, CODE_HELP, } from "./commands/code.js";
19
23
  import { isOutputMode, OUTPUT_MODES } from "./lib/output.js";
20
24
  import { formatErrorOutput } from "./lib/output.js";
21
25
  import { ValidationError, UnsupportedCapabilityError, getErrorExitCode, } from "./lib/errors.js";
22
26
  import * as os from "node:os";
23
- import { invokeCommand } from "./command-invocation.js";
27
+ import * as fs from "node:fs/promises";
28
+ import { existsSync } from "node:fs";
29
+ import { invokeCommand, } from "./command-invocation.js";
24
30
  import { defaultResponseCache } from "./lib/cache.js";
31
+ import { MAX_SUBQUERIES, parseContextText, readContextSource } from "./lib/context-file.js";
25
32
  import { configuredSecrets } from "./lib/redact.js";
26
33
  import { configFilePath, readConfig, resolveConfigRootPure, resolveEnvFromConfig, setConfigValue, unsetConfigValue, } from "./lib/config-store.js";
27
34
  import { inspectConfig, createDefaultVerificationPromoter, createDefaultHintShownStore, } from "./lib/config-store.js";
28
35
  import { createDefaultQuotaStore, refreshQuotaSnapshots, } from "./lib/quota-store.js";
29
- import { createQuotaStoreConsumptionSink } from "./lib/consumption.js";
30
- import { classifyCredentialState, formatEnvOnlyHint, isCommandHelpInvocation, OBSERVATIONAL_COMMANDS, } from "./lib/trigger-detection.js";
36
+ import { createCompositeConsumptionSink, createQuotaStoreConsumptionSink, } from "./lib/consumption.js";
37
+ import { createUsageLedgerSink, readUsageLedger, resolveUsageLedgerPath } from "./lib/usage-ledger.js";
38
+ import { classifyCredentialState, formatEnvOnlyHint, isCommandHelpInvocation, isDryRunBatchInvocation, OBSERVATIONAL_COMMANDS, } from "./lib/trigger-detection.js";
31
39
  import { resolveProviderId, resolveEffectiveProvider } from "./providers/selection.js";
32
40
  import { BUILT_IN_PROVIDER_DESCRIPTORS } from "./providers/registry.js";
41
+ import { PROVIDER_IDS } from "./providers/types.js";
33
42
  import { executeWithFallback } from "./lib/provider-fallback.js";
34
43
  import { visionOperationToCapability } from "./capabilities/vision.js";
35
44
  import { handleInitWithHelp, createInquirerPrompts, createDefaultConfigStore, } from "./commands/init.js";
@@ -56,11 +65,15 @@ Commands:
56
65
  MiniMax and Tavily return UNSUPPORTED_CAPABILITY)
57
66
  quota Provider-aware plan usage (calls remaining, reset time; default
58
67
  reports every configured Provider)
68
+ batch Run a manifest of capability operations across Providers
69
+ (distribution is the default; pin per op or globally to opt out)
59
70
  tools List available MCP tools (Z.AI)
60
71
  tool Show a tool schema (Z.AI)
61
72
  call Call a tool directly (Z.AI)
62
73
  doctor Provider-aware environment + connectivity checks
63
74
  cache Inspect or clear the local cache (stats / clear)
75
+ usage Report local call-usage history (usage.json ledger,
76
+ credential-free)
64
77
  code Execute TypeScript tool chains (Code Mode, Z.AI)
65
78
  init Interactive onboarding wizard (writes ~/.scoutline/config.json)
66
79
 
@@ -98,10 +111,12 @@ Help:
98
111
  scoutline map --help
99
112
  scoutline research --help
100
113
  scoutline repo --help
114
+ scoutline batch --help
101
115
  scoutline tools --help
102
116
  scoutline call --help
103
117
  scoutline code --help
104
118
  scoutline cache --help
119
+ scoutline usage --help
105
120
  scoutline init --help
106
121
  `.trim();
107
122
  function parseArgs(args) {
@@ -322,6 +337,15 @@ async function handleVision(args, outputMode, deps) {
322
337
  const command = positional[0] ?? "";
323
338
  const source = positional[1] ?? "";
324
339
  const prompt = positional[2];
340
+ // batch-runner Ticket 7 (DESIGN D10): `vision batch` is a WRAPPER over
341
+ // the shared manifest runner. The branch sits BEFORE
342
+ // `visionOperationForCommand` (which raises VALIDATION_ERROR for
343
+ // "batch") and before ANY provider resolution or `descriptor.create()`
344
+ // — per-op provider assignment happens inside the runner (D1, D4),
345
+ // never at this seam.
346
+ if (command === "batch") {
347
+ return vision.handleVisionBatch(positional.slice(1), flags, outputMode, deps, BATCH_HANDLERS);
348
+ }
325
349
  // Map the subcommand to its Vision operation. Unknown subcommands are
326
350
  // a parse-time VALIDATION_ERROR and are rejected before any Provider
327
351
  // resolution, support check, or media access.
@@ -531,6 +555,26 @@ export function parseAndValidateType(raw) {
531
555
  }
532
556
  async function handleSearch(args, outputMode, deps) {
533
557
  const { flags, positional } = parseArgs(args);
558
+ // Local-context plan, Ticket 4 (DESIGN D1 placement pin): the
559
+ // `--context` value-shape check runs BEFORE the help-gate below —
560
+ // parseArgs records `true` for a valueless flag and leaves positional
561
+ // empty, so without this check `search --context` would short-circuit
562
+ // to HELP + exit 0 with the malformed flag silently swallowed.
563
+ // Mirrors the `validateModel` shape (`commands/research.ts`) and the
564
+ // identical pre-gate check in `handleResearch`. The post-gate enum
565
+ // validators below do NOT move.
566
+ if (flags.context === true) {
567
+ throw new ValidationError("--context requires a value.", "Pass a file path: --context <path>, or pipe the context with --context-stdin.");
568
+ }
569
+ // Local-context plan, Ticket 5 (DESIGN D1): `--context-stdin` accepts
570
+ // no value — parseArgs greedily consumes the next non-dash token as a
571
+ // flag's value, so `search --context-stdin "<q>"` yields a string flag
572
+ // value and an empty positional. A string value is a VALIDATION_ERROR
573
+ // (never a silent drop through a `=== true` identity test), placed
574
+ // BEFORE the help-gate for the same reason as `--context` above.
575
+ if (typeof flags["context-stdin"] === "string") {
576
+ throw new ValidationError("--context-stdin does not take a value.", 'Pipe the context on standard input: cat notes.md | scoutline search "<query>" --context-stdin.');
577
+ }
534
578
  if (flags.help || flags.h || positional.length === 0) {
535
579
  deps.invocation.writeStdout(SEARCH_HELP);
536
580
  return 0;
@@ -551,6 +595,29 @@ async function handleSearch(args, outputMode, deps) {
551
595
  if (type !== undefined && topic !== undefined) {
552
596
  throw new ValidationError("--type and --topic are mutually exclusive (--type has no editorial topic axis).", "Pass either --type or --topic, not both.");
553
597
  }
598
+ // Local-context plan, Ticket 5 (DESIGN D1): the two context-source
599
+ // spellings are mutually exclusive — a file path and a pipe cannot
600
+ // both be the source. Parse-level gate beside the Ticket 4 mutex
601
+ // below. `contextKind` resolves the active source for the D7 block
602
+ // (Ticket 5 extends it beyond the file source to `--context-stdin`).
603
+ const contextKind = typeof flags.context === "string"
604
+ ? { file: flags.context }
605
+ : flags["context-stdin"] === true
606
+ ? { stdin: true }
607
+ : undefined;
608
+ if (typeof flags.context === "string" && flags["context-stdin"] === true) {
609
+ throw new ValidationError("--context and --context-stdin are mutually exclusive.", "Pass one context source: --context <path> or --context-stdin, not both.");
610
+ }
611
+ // Local-context plan, Ticket 4 (DESIGN D7): an explicit `--merge`
612
+ // together with a context source is an ambiguous combination — the
613
+ // user's manual `|` sub-queries and the derived stream would fight
614
+ // over the same query string. Parse-level gate, same shape as the
615
+ // type/topic mutex above (before Provider resolution); Ticket 5
616
+ // extends it to the `--context-stdin` spelling (same feature, other
617
+ // source).
618
+ if (contextKind !== undefined && flags.merge === true) {
619
+ throw new ValidationError("--merge and --context are mutually exclusive.", "--context derives and joins sub-queries itself; pass one of the two, not both.");
620
+ }
554
621
  // Resolve the effective Provider ONLY inside shared Search (DESIGN.md §6).
555
622
  // Other command families carry the parsed flag but never resolve or
556
623
  // validate it. An invalid explicit/env value throws VALIDATION_ERROR
@@ -636,11 +703,99 @@ async function handleSearch(args, outputMode, deps) {
636
703
  if (fanoutPlan.suppress) {
637
704
  context.notice(fanoutPlan.suppress);
638
705
  }
706
+ // Local-context plan, Ticket 4 (DESIGN D7): read the context
707
+ // source ONCE here in the handler, derive the sub-query stream
708
+ // (D2.4), and build the dispatch query BEFORE either dispatch
709
+ // path — the executors split sub-queries from the raw query
710
+ // string themselves (`splitMergeSubQueries`), so only this
711
+ // handler-side join reaches both the single-pin path and the
712
+ // fan-out grid. The user's query is kept FIRST; every stream
713
+ // member is trim-then-escaped with pipe-only escaping so the
714
+ // join/split round-trip is lossless. Ticket 5 routes the
715
+ // `--context-stdin` spelling through the same block: stdin is
716
+ // read once via the injected io seam (`context.readStdin()`,
717
+ // drained by the Node adapter on first read), so the join and
718
+ // the D6 wrapper below are source-agnostic.
719
+ let dispatchQuery = query;
720
+ let contextInfo;
721
+ if (contextKind !== undefined) {
722
+ const content = await readContextSource(contextKind, {
723
+ readFile: (filePath) => fs.readFile(filePath),
724
+ readStdin: (maxBytes) => context.readStdin(maxBytes),
725
+ });
726
+ const parsed = parseContextText(content.text);
727
+ const derived = parsed.subQueries;
728
+ // Drop notice (D2.4/D7): the parser caps the stream at
729
+ // MAX_SUBQUERIES; the pre-cap count is recomputed here with the
730
+ // same qualification rules (headings within the 60-char bound,
731
+ // backslash-trimmed non-empty) purely for the notice — the
732
+ // capped stream itself is authoritative for the join.
733
+ const uncappedSet = new Set([
734
+ ...parsed.headings.filter((h) => h.length <= 60),
735
+ ...parsed.questions,
736
+ ]
737
+ .map((value) => value.replace(/\\+$/, ""))
738
+ .filter((value) => value.length > 0));
739
+ const uncapped = uncappedSet.size;
740
+ if (uncapped > MAX_SUBQUERIES) {
741
+ context.notice(`context: derived ${uncapped} sub-queries; dropped ${uncapped - derived.length} (cap ${MAX_SUBQUERIES})`);
742
+ }
743
+ if (derived.length === 0) {
744
+ // Zero-derivation fallback (D7): the original query runs
745
+ // alone, merge NOT engaged — a literal `|` in the user's
746
+ // query must not split. The context wrapper below still
747
+ // applies.
748
+ context.notice("context: derived 0 sub-queries; using original query");
749
+ }
750
+ else {
751
+ // Join site (D7): the user query gets the D2.4
752
+ // trailing-backslash trim here (derived members are already
753
+ // trimmed by the parser), then EVERY member is escaped
754
+ // pipe-only — no backslash doubling, `splitMergeSubQueries`
755
+ // never unescapes `\\`.
756
+ dispatchQuery = [query.replace(/\\+$/, ""), ...derived]
757
+ .map((s) => s.replace(/\|/g, "\\|"))
758
+ .join("|");
759
+ searchOptions.merge = true;
760
+ // Fan-out cost disclosure (D7): when the resolved plan is
761
+ // fan-out AND the joined stream holds more than one
762
+ // sub-query, the (arm × sub-query) grid is a cost
763
+ // multiplication the user must see. N counts the whole
764
+ // joined stream, user query included.
765
+ if (fanoutPlan.mode === "fanout") {
766
+ const n = derived.length + 1;
767
+ const m = fanoutPlan.arms.length;
768
+ context.notice(`context: ${n} sub-queries × ${m} arms = ${n * m} billable searches`);
769
+ }
770
+ }
771
+ // D6 privacy boundary: counts, hashes, and the path only —
772
+ // never content. `subQueries` is the post-cap stream length.
773
+ contextInfo = {
774
+ source: content.source,
775
+ ...(content.path !== undefined ? { path: content.path } : {}),
776
+ sha256: content.sha256,
777
+ derived: {
778
+ headings: parsed.headings.length,
779
+ questions: parsed.questions.length,
780
+ terms: parsed.terms.length,
781
+ subQueries: derived.length,
782
+ },
783
+ };
784
+ }
785
+ // Flags-gated wrapper (D7): under `--context` (only then) the
786
+ // dispatch result's DATA payload is wrapped as
787
+ // `{context: {...}, results: [...]}`; text-mode presentations
788
+ // stay unwrapped (they are built inside dispatch from the bare
789
+ // results). Without the flag the result passes through
790
+ // byte-identically.
791
+ const applyContextWrapper = (result) => contextInfo === undefined || result.kind !== "data"
792
+ ? result
793
+ : { ...result, data: { context: contextInfo, results: result.data } };
639
794
  if (fanoutPlan.mode === "fanout") {
640
- return executeFanoutPlan(fanoutPlan, {
795
+ return applyContextWrapper(await executeFanoutPlan(fanoutPlan, {
641
796
  descriptors: deps.providerDescriptors,
642
797
  env: deps.env,
643
- query,
798
+ query: dispatchQuery,
644
799
  searchOptions,
645
800
  dependencies: {
646
801
  cache: deps.searchCache,
@@ -654,7 +809,7 @@ async function handleSearch(args, outputMode, deps) {
654
809
  ...(deps.now !== undefined ? { now: deps.now } : {}),
655
810
  },
656
811
  secrets: deps.secrets,
657
- }, context);
812
+ }, context));
658
813
  }
659
814
  const outcome = await executeWithFallback({
660
815
  capabilityId: "search",
@@ -670,14 +825,20 @@ async function handleSearch(args, outputMode, deps) {
670
825
  }, async (descriptor) => {
671
826
  const adapter = descriptor.create({ env: deps.env });
672
827
  const capability = adapter.search;
673
- return search(query, searchOptions, {
828
+ return search(dispatchQuery, searchOptions, {
674
829
  capability,
675
830
  cache: deps.searchCache,
676
831
  sleep: deps.searchSleep,
677
832
  random: deps.searchRandom,
833
+ // PB-T2 parity with the fan-out path above (usage-ledger
834
+ // DESIGN D7): thread the configured consumption sink +
835
+ // clock so every billable sub-query on the single-pin path
836
+ // records through it.
837
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
838
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
678
839
  }, context);
679
840
  });
680
- return outcome.result;
841
+ return applyContextWrapper(outcome.result);
681
842
  }, outputMode, deps.now, deps.secrets);
682
843
  }
683
844
  async function handleRead(args, outputMode, deps) {
@@ -738,6 +899,10 @@ async function handleRead(args, outputMode, deps) {
738
899
  cache: deps.readerCache,
739
900
  sleep: deps.readerSleep,
740
901
  random: deps.readerRandom,
902
+ // PB-T2 (usage-ledger DESIGN D7): thread the configured consumption
903
+ // sink + clock so every billable reader-fetch attempt records.
904
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
905
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
741
906
  };
742
907
  // Provider-fallback Ticket 02: route the call through the shared
743
908
  // fallback executor. Capability + configured + adapter-handle
@@ -796,6 +961,10 @@ async function handleCrawl(args, outputMode, deps) {
796
961
  cache: deps.crawlCache,
797
962
  sleep: deps.crawlSleep,
798
963
  random: deps.crawlRandom,
964
+ // PB-T2 (usage-ledger DESIGN D7): thread the configured consumption
965
+ // sink + clock so every billable crawl-fetch attempt records.
966
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
967
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
799
968
  };
800
969
  // Provider-fallback Ticket 03: route the call through the shared
801
970
  // fallback executor. Capability + configured + adapter-handle
@@ -868,6 +1037,10 @@ async function handleMap(args, outputMode, deps) {
868
1037
  cache: deps.mapCache,
869
1038
  sleep: deps.mapSleep,
870
1039
  random: deps.mapRandom,
1040
+ // PB-T2 (usage-ledger DESIGN D7): thread the configured consumption
1041
+ // sink + clock so every billable map-fetch attempt records.
1042
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
1043
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
871
1044
  };
872
1045
  // Provider-fallback Ticket 03: route the call through the shared
873
1046
  // fallback executor. Map retries once per Provider today, so the
@@ -903,6 +1076,26 @@ async function handleMap(args, outputMode, deps) {
903
1076
  }
904
1077
  async function handleResearch(args, outputMode, deps) {
905
1078
  const { flags, positional } = parseArgs(args);
1079
+ // Local-context plan, Ticket 2 (DESIGN D1 placement pin): the
1080
+ // `--context` value-shape check runs BEFORE the help-gate below —
1081
+ // parseArgs records `true` for a valueless flag and leaves positional
1082
+ // empty, so without this check `research --context` would
1083
+ // short-circuit to HELP + exit 0 with the malformed flag silently
1084
+ // swallowed. Mirrors the `validateModel` shape (commands/research.ts).
1085
+ // The post-gate enum validators below do NOT move.
1086
+ if (flags.context === true) {
1087
+ throw new ValidationError("--context requires a value.", "Pass a file path: --context <path>, or pipe the context with --context-stdin.");
1088
+ }
1089
+ // Local-context plan, Ticket 5 (DESIGN D1): `--context-stdin` accepts
1090
+ // no value — parseArgs greedily consumes the next non-dash token as a
1091
+ // flag's value, so `research --context-stdin "<q>"` yields a string
1092
+ // flag value and an empty positional. A string value is a
1093
+ // VALIDATION_ERROR (never a silent drop through a `=== true` identity
1094
+ // test), placed BEFORE the help-gate for the same reason as
1095
+ // `--context` above.
1096
+ if (typeof flags["context-stdin"] === "string") {
1097
+ throw new ValidationError("--context-stdin does not take a value.", 'Pipe the context on standard input: cat notes.md | scoutline research "<query>" --context-stdin.');
1098
+ }
906
1099
  if (flags.help || flags.h || positional.length === 0) {
907
1100
  deps.invocation.writeStdout(RESEARCH_HELP);
908
1101
  return 0;
@@ -914,6 +1107,17 @@ async function handleResearch(args, outputMode, deps) {
914
1107
  const model = validateResearchEnum(flags.model, ["mini", "pro", "auto"], "--model");
915
1108
  const outputLength = validateResearchEnum(flags["output-length"], ["short", "standard", "long"], "--output-length");
916
1109
  const citationFormat = validateResearchEnum(flags["citation-format"], ["numbered", "mla", "apa", "chicago"], "--citation-format");
1110
+ // Local-context plan, Ticket 5 (DESIGN D1): the two context-source
1111
+ // spellings are mutually exclusive — a file path and a pipe cannot
1112
+ // both be the source. Parse-level gate before Provider resolution,
1113
+ // same region as the enum validators.
1114
+ if (typeof flags.context === "string" && flags["context-stdin"] === true) {
1115
+ throw new ValidationError("--context and --context-stdin are mutually exclusive.", "Pass one context source: --context <path> or --context-stdin, not both.");
1116
+ }
1117
+ // Local-context plan, Ticket 2 (DESIGN D1): research-only mode enum,
1118
+ // defaulting to `organize` at the read site below. Same parse-level
1119
+ // gate shape as the research enums above.
1120
+ const contextMode = validateResearchEnum(flags["context-mode"], ["organize", "bias", "both"], "--context-mode");
917
1121
  // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
918
1122
  // explicit --provider > SCOUTLINE_PROVIDER > quota-ranked pick.
919
1123
  // (PB-T4.)
@@ -930,6 +1134,11 @@ async function handleResearch(args, outputMode, deps) {
930
1134
  cache: deps.researchCache,
931
1135
  sleep: deps.researchSleep,
932
1136
  random: deps.researchRandom,
1137
+ // PB-T2 (usage-ledger DESIGN D7): thread the configured consumption
1138
+ // sink + clock so every billable research-fetch attempt records
1139
+ // (research is maxRetries 0 by policy — exactly one event per run).
1140
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
1141
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
933
1142
  };
934
1143
  // Provider-fallback Ticket 03: route the call through the shared
935
1144
  // fallback executor. The research command is the riskiest piece
@@ -946,6 +1155,17 @@ async function handleResearch(args, outputMode, deps) {
946
1155
  // first attempt, so it is not duplicated across candidate
947
1156
  // switches.
948
1157
  return invokeCommand(deps.invocation, async (context) => {
1158
+ // Local-context plan, Ticket 3 (DESIGN D5): resume-bearing view
1159
+ // of the context flags, threaded through ResearchOptions into
1160
+ // `buildResearchResumeCommand`. `mode` records ONLY an
1161
+ // explicitly-set --context-mode (undefined stays omitted — the
1162
+ // function's set-values-only convention), so an organize-default
1163
+ // resume command carries `--context <path>` alone.
1164
+ const resumeContext = typeof flags.context === "string"
1165
+ ? { source: "file", path: flags.context, mode: contextMode }
1166
+ : flags["context-stdin"] === true
1167
+ ? { source: "stdin", mode: contextMode }
1168
+ : undefined;
949
1169
  const options = {
950
1170
  model,
951
1171
  outputLength,
@@ -954,7 +1174,34 @@ async function handleResearch(args, outputMode, deps) {
954
1174
  maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
955
1175
  timeout: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
956
1176
  noCache: flags["no-cache"] === true,
1177
+ context: resumeContext,
957
1178
  };
1179
+ // Local-context plan, Ticket 2 (DESIGN D3/D5): read + parse the
1180
+ // context source exactly ONCE, here in the handler and BEFORE
1181
+ // `executeWithFallback`. The Node invocation adapter drains
1182
+ // process.stdin on the first `readStdin()`, so a per-attempt read
1183
+ // inside `research()` would hand a fallback retry an empty
1184
+ // string, silently mutate the request, and hash to a different
1185
+ // async-job state file (D5's second-paid-job trap). `research()`
1186
+ // consumes the threaded result only (D4 remap + envelope field) —
1187
+ // it never re-reads the source.
1188
+ const contextKind = typeof flags.context === "string"
1189
+ ? { file: flags.context }
1190
+ : flags["context-stdin"] === true
1191
+ ? { stdin: true }
1192
+ : undefined;
1193
+ let researchContext;
1194
+ if (contextKind !== undefined) {
1195
+ const content = await readContextSource(contextKind, {
1196
+ readFile: (filePath) => fs.readFile(filePath),
1197
+ readStdin: (maxBytes) => context.readStdin(maxBytes),
1198
+ });
1199
+ researchContext = {
1200
+ mode: contextMode ?? "organize",
1201
+ content,
1202
+ parsed: parseContextText(content.text),
1203
+ };
1204
+ }
958
1205
  // Closure-guarded one-time credit warning (Review Fix 7). The
959
1206
  // flag is captured INSIDE the attempt closure, so the line is
960
1207
  // written exactly once — the first time the executor actually
@@ -988,6 +1235,7 @@ async function handleResearch(args, outputMode, deps) {
988
1235
  capability: adapter.research,
989
1236
  execution: executionDeps,
990
1237
  registerInterrupt: deps.researchRegisterInterrupt,
1238
+ context: researchContext,
991
1239
  }, context);
992
1240
  });
993
1241
  return outcome.result;
@@ -1123,6 +1371,13 @@ async function handleRepo(args, outputMode, deps) {
1123
1371
  cache: deps.repositoryCache,
1124
1372
  sleep: deps.repositorySleep,
1125
1373
  random: deps.repositoryRandom,
1374
+ // PB-T2 (usage-ledger DESIGN D7): thread the configured consumption
1375
+ // sink + clock so every billable repository operation attempt
1376
+ // records (one deps object per handler is correct across fallback
1377
+ // candidates — each candidate's cache identity supplies its own
1378
+ // provider at emission time).
1379
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
1380
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
1126
1381
  };
1127
1382
  const language = flags.language;
1128
1383
  const maxChars = flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined;
@@ -1170,6 +1425,193 @@ async function handleRepo(args, outputMode, deps) {
1170
1425
  return outcome.result;
1171
1426
  }, outputMode, deps.now, deps.secrets);
1172
1427
  }
1428
+ // ---------------------------------------------------------------------------
1429
+ // Batch manifest runner (batch-runner DESIGN D1, D8)
1430
+ // ---------------------------------------------------------------------------
1431
+ const BATCH_HELP = `
1432
+ Batch - Run a manifest of capability operations across Providers
1433
+
1434
+ Usage: scoutline batch <manifest.json|-> [options]
1435
+
1436
+ Executes every manifest operation through the SAME handlers a direct
1437
+ call uses, forced to data mode, through a bounded worker pool. Provider
1438
+ distribution is the DEFAULT (DESIGN D4): unpinned operations are
1439
+ assigned round-robin across configured, capable Providers per
1440
+ capability group, in registry order. Pin an operation with its manifest
1441
+ "provider" field, or the whole batch with the global --provider flag,
1442
+ to opt out. routing.<capability> preferences are ignored inside batch
1443
+ (all eligible providers participate; pin to opt out), and search
1444
+ fan-out is suppressed: every operation runs on exactly its assigned
1445
+ Provider. Results[] keeps manifest order; per-op notices and errors
1446
+ are captured per operation, never re-emitted live. Process stdout
1447
+ carries exactly ONE write: the summary envelope.
1448
+
1449
+ Manifest (schema v1, strict parse - unknown fields reject):
1450
+ {
1451
+ "schemaVersion": 1,
1452
+ "operations": [
1453
+ { "name": "op-1", "command": "search", "input": { "query": "..." } }
1454
+ ]
1455
+ }
1456
+
1457
+ commands: search, read, research, repo, vision, crawl, map
1458
+ op fields: name (unique), command, input, provider? (pin), output?
1459
+ - reads the manifest JSON from stdin (the ops themselves never can)
1460
+
1461
+ Options:
1462
+ --concurrency <n> Parallel operations (integer 1-8; default 4)
1463
+ --fail-fast Stop scheduling after the first failed operation
1464
+ --dry-run Validate the manifest and preview the full
1465
+ assignment without contacting any provider
1466
+ --help Show this help
1467
+
1468
+ Dry run boundary (--dry-run): every operation still gets its resolved
1469
+ provider (pin or distribution) and its pre-dispatch gates — configured
1470
+ AND capability-advertised, reason: ready | provider not configured |
1471
+ capability not advertised — but nothing executes: no transport, no
1472
+ cache reads or writes, no per-op output files. Per-handler flag
1473
+ semantics (post-compiler) are not validated in a dry run.
1474
+
1475
+ Examples:
1476
+ scoutline batch manifest.json
1477
+ scoutline batch manifest.json --concurrency 2 --fail-fast
1478
+ cat manifest.json | scoutline batch -
1479
+ `.trim();
1480
+ /** Flags `scoutline batch` itself accepts (strict surface; D1). */
1481
+ const BATCH_FLAGS = new Set([
1482
+ "help",
1483
+ "h",
1484
+ "concurrency",
1485
+ "fail-fast",
1486
+ "dry-run",
1487
+ ]);
1488
+ /**
1489
+ * The real 7-entry handler map (D5): each allowed batch command runs
1490
+ * the same handler the main dispatch switch calls, with the runner's
1491
+ * per-op spread overriding `provider` (the assignment pin) and
1492
+ * `invocation` (the capture adapter).
1493
+ */
1494
+ const BATCH_HANDLERS = {
1495
+ search: handleSearch,
1496
+ read: handleRead,
1497
+ research: handleResearch,
1498
+ repo: handleRepo,
1499
+ vision: handleVision,
1500
+ crawl: handleCrawl,
1501
+ map: handleMap,
1502
+ };
1503
+ /**
1504
+ * `scoutline batch` handler (batch-runner plan Ticket 4, DESIGN D1).
1505
+ *
1506
+ * Order of operations: strict flag surface -> help short-circuit ->
1507
+ * `--concurrency` gate (D8: integer 1..8, validation never clamping;
1508
+ * the runner re-validates the number before the pool) -> manifest read
1509
+ * (file, or `-` via the GLOBAL invocation's readStdin, exactly once,
1510
+ * before the pool starts - the per-op adapters throw on readStdin so
1511
+ * the manifest can never be consulted from an op) -> strict D2 parse
1512
+ * -> D4 assignment -> shared runner.
1513
+ *
1514
+ * Every whole-batch failure (unknown flag, bad concurrency, unreadable
1515
+ * manifest, parse/assignment rejection) throws ValidationError BEFORE
1516
+ * any stdout write, so the main dispatch catch owns the process-level
1517
+ * error envelope (AC1: stderr JSON contract, no summary envelope).
1518
+ */
1519
+ async function handleBatch(args, outputMode, deps) {
1520
+ const { flags, positional } = parseArgs(args);
1521
+ // Unknown flags reject BEFORE the help short-circuit: parseArgs
1522
+ // assigns the next non-dash argument as a flag's value, so
1523
+ // `batch --frobnicate manifest.json` would otherwise swallow the
1524
+ // manifest into the unknown flag, leave `positional` empty, and
1525
+ // render help instead of naming the offender.
1526
+ for (const key of Object.keys(flags)) {
1527
+ if (!BATCH_FLAGS.has(key)) {
1528
+ throw new ValidationError(`unknown batch flag "--${key}"`, 'Run "scoutline batch --help" for the accepted flags.');
1529
+ }
1530
+ }
1531
+ // Boolean-only flags never take a value: `--dry-run false` would
1532
+ // otherwise silently disable the safety boundary and RUN providers,
1533
+ // because the flag value never equals `true`. Same for --fail-fast.
1534
+ for (const key of ["fail-fast", "dry-run"]) {
1535
+ const value = flags[key];
1536
+ if (value !== undefined && value !== true) {
1537
+ throw new ValidationError(`--${key} is a boolean flag and takes no value`, `Pass the bare --${key} to enable it, or omit it.`);
1538
+ }
1539
+ }
1540
+ if (flags.help || flags.h || positional.length === 0) {
1541
+ deps.invocation.writeStdout(BATCH_HELP);
1542
+ return 0;
1543
+ }
1544
+ if (positional.length > 1) {
1545
+ throw new ValidationError("batch takes exactly one manifest argument (a file path or '-')", "Pass the manifest path, or '-' to read it from stdin.");
1546
+ }
1547
+ // D8 gate at the command seam: the flag arrives as a string (or
1548
+ // `true` when valueless); convert and let the runner's own
1549
+ // normalizeConcurrency own the integer/range VALIDATION_ERROR, which
1550
+ // fires before the pool and before any stdout write.
1551
+ let concurrency;
1552
+ if (flags.concurrency !== undefined) {
1553
+ if (typeof flags.concurrency !== "string") {
1554
+ throw new ValidationError(`batch concurrency must be an integer between 1 and ${BATCH_MAX_CONCURRENCY}`, "Omit --concurrency for the default of 4, or pass an integer in 1..8.");
1555
+ }
1556
+ concurrency = Number(flags.concurrency);
1557
+ }
1558
+ // Manifest source (D1): a file path, or `-` for stdin through the
1559
+ // GLOBAL invocation adapter (the tools.ts `context.readStdin()`
1560
+ // precedent) — read exactly once, before the pool starts. The read
1561
+ // happens outside invokeCommand (the runner owns its process
1562
+ // effects), directly on the adapter the switch handed in.
1563
+ const manifestArg = positional[0] ?? "";
1564
+ let manifestText;
1565
+ if (manifestArg === "-") {
1566
+ try {
1567
+ manifestText = await deps.invocation.readStdin();
1568
+ }
1569
+ catch (error) {
1570
+ const message = error instanceof Error ? error.message : String(error);
1571
+ throw new ValidationError(`failed to read batch manifest from stdin: ${message}`);
1572
+ }
1573
+ }
1574
+ else {
1575
+ try {
1576
+ manifestText = await fs.readFile(manifestArg, "utf8");
1577
+ }
1578
+ catch (error) {
1579
+ const message = error instanceof Error ? error.message : String(error);
1580
+ throw new ValidationError(`failed to read batch manifest file "${manifestArg}": ${message}`);
1581
+ }
1582
+ }
1583
+ let rawManifest;
1584
+ try {
1585
+ rawManifest = JSON.parse(manifestText);
1586
+ }
1587
+ catch (error) {
1588
+ const message = error instanceof Error ? error.message : String(error);
1589
+ throw new ValidationError(`batch manifest is not valid JSON: ${message}`);
1590
+ }
1591
+ const manifest = parseBatchManifest(rawManifest, {
1592
+ descriptors: deps.providerDescriptors,
1593
+ dirExists: (dir) => existsSync(dir),
1594
+ });
1595
+ // D4 precedence: per-op pin > global --provider > distribution. The
1596
+ // global pin was already extracted from argv by the switch layer.
1597
+ const assignments = assignBatchProviders(manifest, {
1598
+ descriptors: deps.providerDescriptors,
1599
+ env: deps.env,
1600
+ globalProvider: deps.provider,
1601
+ });
1602
+ const { exitCode } = await runBatch(manifest, assignments, {
1603
+ handlerDeps: deps,
1604
+ handlers: BATCH_HANDLERS,
1605
+ invocation: deps.invocation,
1606
+ outputMode,
1607
+ now: deps.now,
1608
+ }, {
1609
+ ...(concurrency !== undefined ? { concurrency } : {}),
1610
+ failFast: flags["fail-fast"] === true,
1611
+ dryRun: flags["dry-run"] === true,
1612
+ });
1613
+ return exitCode;
1614
+ }
1173
1615
  async function handleTools(args, outputMode, deps) {
1174
1616
  const { flags } = parseArgs(args);
1175
1617
  if (flags.help || flags.h) {
@@ -1438,6 +1880,83 @@ export async function handleCache(args, outputMode, deps) {
1438
1880
  throw new ValidationError(`Unknown cache command: ${subcommand}`, 'Run "scoutline cache --help" for available commands');
1439
1881
  }
1440
1882
  }
1883
+ /**
1884
+ * `scoutline usage [--days N] [--provider <id>]` — report the local
1885
+ * usage ledger (usage-ledger plan, Ticket 5). Credential-free and
1886
+ * read-only: like `cache` it bypasses Provider resolution entirely, and
1887
+ * like `handleCache` it keeps the injection-free posture — the ledger
1888
+ * path resolves at handler time via
1889
+ * `resolveConfigRootPure(deps.env, ...)` (no new `MainDependencies`
1890
+ * field, no injected reader object). Production reads
1891
+ * `readUsageLedger(resolveUsageLedgerPath(...))` with DEFAULT deps
1892
+ * (real reader, no `onWarning`) so DESIGN D8's silent-on-corrupt
1893
+ * contract holds: a missing, corrupt, or wrong-version ledger yields an
1894
+ * empty window with exit 0 and no stderr noise.
1895
+ *
1896
+ * Flag contract (DESIGN D8): `--days` must be an integer ≥ 1 (unlike
1897
+ * `--count`, 0 is invalid) and defaults to 7; `--provider` must be a
1898
+ * known registry id (unknown ids are a VALIDATION_ERROR listing the
1899
+ * accepted ids; a known-but-unrecorded id is an empty result, exit 0).
1900
+ * `--provider` may appear before or after the command token —
1901
+ * `extractGlobalOptions` strips it either way, so the handler recovers
1902
+ * it from `deps.provider` (same recovery as `cache prune`).
1903
+ */
1904
+ export async function handleUsage(args, outputMode, deps) {
1905
+ const { flags } = parseArgs(args);
1906
+ if (flags.help || flags.h) {
1907
+ deps.invocation.writeStdout(USAGE_HELP);
1908
+ return 0;
1909
+ }
1910
+ // `--days`: positive integer >= 1 (DESIGN D8). parseArgs yields `true`
1911
+ // for a valueless flag, so the bare case is guarded separately.
1912
+ let windowDays = DEFAULT_USAGE_WINDOW_DAYS;
1913
+ const rawDays = flags.days;
1914
+ if (rawDays !== undefined) {
1915
+ if (rawDays === true) {
1916
+ throw new ValidationError("--days requires a value.", "Pass a positive integer, e.g. --days 7.");
1917
+ }
1918
+ const str = typeof rawDays === "string" ? rawDays : String(rawDays);
1919
+ // Strict decimal gate (same class as parseAndValidateCount's /^\d+$/):
1920
+ // Number() alone would coerce "1e3", "0x0A", " 7", and "7.0" into
1921
+ // integers, accepting spellings the documented contract (USAGE_HELP,
1922
+ // DESIGN D8 — a decimal integer 1..MAX) does not include.
1923
+ if (!/^\d+$/.test(str)) {
1924
+ throw new ValidationError(`Invalid --days value "${rawDays}"`, `--days must be an integer between 1 and ${MAX_USAGE_WINDOW_DAYS}. Examples: --days 7, --days 30.`);
1925
+ }
1926
+ const parsed = Number(str);
1927
+ // Upper bound (review P2): values far beyond the window the ledger
1928
+ // can ever hold (retention is 90 days) would otherwise pass and
1929
+ // crash the cutoff computation — JS Dates only span ~±100,000,000
1930
+ // days, so e.g. --days 1000000000 throws RangeError in usageDayKey.
1931
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_USAGE_WINDOW_DAYS) {
1932
+ throw new ValidationError(`Invalid --days value "${rawDays}"`, `--days must be an integer between 1 and ${MAX_USAGE_WINDOW_DAYS}. Examples: --days 7, --days 30.`);
1933
+ }
1934
+ windowDays = parsed;
1935
+ }
1936
+ // `--provider` selector: command-local flag wins over the
1937
+ // `deps.provider` fallback (extractGlobalOptions already stripped the
1938
+ // global spelling from the rest stream).
1939
+ const rawProvider = flags.provider;
1940
+ if (rawProvider === true) {
1941
+ throw new ValidationError("--provider requires a value.", "Pass a Provider id after --provider, e.g. --provider zai.");
1942
+ }
1943
+ const providerSelector = typeof rawProvider === "string" ? rawProvider : deps.provider;
1944
+ if (providerSelector !== undefined &&
1945
+ !PROVIDER_IDS.includes(providerSelector)) {
1946
+ throw new ValidationError(`Unknown provider "${providerSelector}".`, `Accepted provider IDs: ${PROVIDER_IDS.join(", ")}.`);
1947
+ }
1948
+ // Read the ledger with default deps — real reader, no `onWarning`
1949
+ // (silent-on-corrupt, DESIGN D8).
1950
+ const configRoot = resolveConfigRootPure(deps.env, { homedir: os.homedir() });
1951
+ const ledgerPath = resolveUsageLedgerPath(configRoot);
1952
+ const now = deps.now ?? Date.now;
1953
+ return invokeCommand(deps.invocation, () => usageCommand({
1954
+ readLedger: () => readUsageLedger(ledgerPath),
1955
+ windowDays,
1956
+ ...(providerSelector !== undefined ? { provider: providerSelector } : {}),
1957
+ now,
1958
+ }), outputMode, deps.now, deps.secrets);
1959
+ }
1441
1960
  async function handleQuota(args, outputMode, deps) {
1442
1961
  const { flags } = parseArgs(args);
1443
1962
  if (flags.help || flags.h) {
@@ -1659,9 +2178,26 @@ export async function main(args, dependencies) {
1659
2178
  // surviving the bin's immediate `process.exit(status)`.
1660
2179
  const quotaRefreshEnabled = !dependencies.loadScoutlineConfig && !dependencies.providerDescriptors;
1661
2180
  const quotaStore = dependencies.quotaStore ?? createDefaultQuotaStore();
2181
+ // Production records consumption through BOTH sinks (usage-ledger
2182
+ // DESIGN D3): the PB-T1 quota-store snapshot store (unchanged,
2183
+ // including its no-op-before-snapshot posture) and the usage ledger
2184
+ // (sibling `usage.json` under the same config root, resolved through
2185
+ // the pure `resolveUsageLedgerPath()`; warnings default to stderr
2186
+ // like the quota sink). The composite isolates each side — one
2187
+ // sink's failure becomes one redacted warning and never blocks or
2188
+ // fails the other.
1662
2189
  const consume = dependencies.consume ??
1663
2190
  (quotaRefreshEnabled
1664
- ? createQuotaStoreConsumptionSink({ store: quotaStore, now: now ?? Date.now })
2191
+ ? createCompositeConsumptionSink(createQuotaStoreConsumptionSink({ store: quotaStore, now: now ?? Date.now }),
2192
+ // The ledger path resolves from the SAME injected-env config
2193
+ // root `handleUsage` reads through (resolveConfigRootPure over
2194
+ // MainDependencies.env) — not from ambient process.env — so an
2195
+ // embedded caller or hermetic run that injects
2196
+ // SCOUTLINE_CONFIG_DIR sees its recorded usage in the root the
2197
+ // `usage` command reports from (review P2).
2198
+ createUsageLedgerSink({
2199
+ filePath: resolveUsageLedgerPath(resolveConfigRootPure(env, { homedir: os.homedir() })),
2200
+ }))
1665
2201
  : undefined);
1666
2202
  // PB-T4: quota snapshot for selection. Declared here so
1667
2203
  // `buildHandlerDeps` closes over the binding; assigned AFTER the
@@ -1758,6 +2294,21 @@ export async function main(args, dependencies) {
1758
2294
  return getErrorExitCode(error);
1759
2295
  }
1760
2296
  }
2297
+ // `usage` is credential-free (reads only <config-root>/usage.json; no
2298
+ // Provider resolution, no Adapter, no transport — the same
2299
+ // short-circuit class as `cache`). Dispatching before config load
2300
+ // keeps a corrupt config.json from blocking the usage report and
2301
+ // keeps trigger detection unreached. Fail-open ledger reads mean a
2302
+ // missing/corrupt file still exits 0 with an empty window (DESIGN D8).
2303
+ if (command === "usage") {
2304
+ try {
2305
+ return await handleUsage(commandArgs, outputMode, buildHandlerDeps(env, envSecrets, true));
2306
+ }
2307
+ catch (error) {
2308
+ invocation.writeStderr(formatErrorOutput(error, outputMode, envSecrets));
2309
+ return getErrorExitCode(error);
2310
+ }
2311
+ }
1761
2312
  // `init` manages config itself (it inspects + writes via the T1
1762
2313
  // primitives, never reads the resolvedEnv the credentialed path
1763
2314
  // produces). Short-circuit before the credentialed config load so a
@@ -1974,6 +2525,10 @@ export async function main(args, dependencies) {
1974
2525
  // earlier alongside `buildHandlerDeps` so the sink closes over a
1975
2526
  // defined binding — see PB-T2 above.)
1976
2527
  const isQuotaObservationalCommand = command === "quota" || command === "doctor";
2528
+ // Dry-run batches (`batch --dry-run`, `vision batch --dry-run`) promise
2529
+ // a no-transport preview (batch DESIGN D7/D10) — the due-refresh below
2530
+ // must not live-probe stale providers for them (review fix).
2531
+ const isDryRunBatch = isDryRunBatchInvocation(command, commandArgs);
1977
2532
  const refreshOnError = (_providerId, _error) => {
1978
2533
  // Silent: quota-refresh failures are best-effort. Writing to stderr
1979
2534
  // would violate the JSON error contract (stderr is reserved for the
@@ -2041,6 +2596,10 @@ export async function main(args, dependencies) {
2041
2596
  commandRecognized = true;
2042
2597
  exitCode = await handleRepo(commandArgs, outputMode, handlerDepsWithSelection);
2043
2598
  break;
2599
+ case "batch":
2600
+ commandRecognized = true;
2601
+ exitCode = await handleBatch(commandArgs, outputMode, handlerDepsWithSelection);
2602
+ break;
2044
2603
  case "tools":
2045
2604
  commandRecognized = true;
2046
2605
  exitCode = await handleTools(commandArgs, outputMode, handlerDepsWithSelection);
@@ -2084,6 +2643,8 @@ export async function main(args, dependencies) {
2084
2643
  // - `quota`/`doctor` (already force-refreshed before the handler)
2085
2644
  // - help invocations (`<cmd> --help` exits 0 but did no real work)
2086
2645
  // - unrecognized commands (the default case above)
2646
+ // - dry-run batch invocations — the preview contract promises no
2647
+ // transport, and this refresh live-probes stale providers
2087
2648
  // Run even when the handler threw (commandRecognized + caught):
2088
2649
  // the refresh is independent of the command's outcome and the
2089
2650
  // snapshot stays live regardless. The staleness check inside
@@ -2093,7 +2654,8 @@ export async function main(args, dependencies) {
2093
2654
  if (quotaRefreshEnabled &&
2094
2655
  commandRecognized &&
2095
2656
  !isQuotaObservationalCommand &&
2096
- !isHelpInvocation) {
2657
+ !isHelpInvocation &&
2658
+ !isDryRunBatch) {
2097
2659
  await refreshQuotaSnapshots({
2098
2660
  descriptors: providerDescriptors,
2099
2661
  env: resolvedEnv,