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.
- package/README.md +47 -0
- package/dist/command-invocation.d.ts +2 -2
- package/dist/command-invocation.d.ts.map +1 -1
- package/dist/command-invocation.js +1 -1
- package/dist/command-invocation.js.map +1 -1
- package/dist/commands/research.d.ts +45 -0
- package/dist/commands/research.d.ts.map +1 -1
- package/dist/commands/research.js +152 -4
- package/dist/commands/research.js.map +1 -1
- package/dist/commands/search.d.ts +11 -0
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +31 -2
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/usage.d.ts +101 -0
- package/dist/commands/usage.d.ts.map +1 -0
- package/dist/commands/usage.js +228 -0
- package/dist/commands/usage.js.map +1 -0
- package/dist/commands/vision.d.ts +28 -0
- package/dist/commands/vision.d.ts.map +1 -1
- package/dist/commands/vision.js +452 -0
- package/dist/commands/vision.js.map +1 -1
- package/dist/index.d.ts +25 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +572 -10
- package/dist/index.js.map +1 -1
- package/dist/lib/batch-assign.d.ts +61 -0
- package/dist/lib/batch-assign.d.ts.map +1 -0
- package/dist/lib/batch-assign.js +118 -0
- package/dist/lib/batch-assign.js.map +1 -0
- package/dist/lib/batch-manifest.d.ts +183 -0
- package/dist/lib/batch-manifest.d.ts.map +1 -0
- package/dist/lib/batch-manifest.js +644 -0
- package/dist/lib/batch-manifest.js.map +1 -0
- package/dist/lib/batch-runner.d.ts +169 -0
- package/dist/lib/batch-runner.d.ts.map +1 -0
- package/dist/lib/batch-runner.js +357 -0
- package/dist/lib/batch-runner.js.map +1 -0
- package/dist/lib/consumption.d.ts +17 -0
- package/dist/lib/consumption.d.ts.map +1 -1
- package/dist/lib/consumption.js +38 -0
- package/dist/lib/consumption.js.map +1 -1
- package/dist/lib/context-file.d.ts +90 -0
- package/dist/lib/context-file.d.ts.map +1 -0
- package/dist/lib/context-file.js +244 -0
- package/dist/lib/context-file.js.map +1 -0
- package/dist/lib/trigger-detection.d.ts +21 -0
- package/dist/lib/trigger-detection.d.ts.map +1 -1
- package/dist/lib/trigger-detection.js +63 -0
- package/dist/lib/trigger-detection.js.map +1 -1
- package/dist/lib/usage-ledger.d.ts +221 -0
- package/dist/lib/usage-ledger.d.ts.map +1 -0
- package/dist/lib/usage-ledger.js +408 -0
- package/dist/lib/usage-ledger.js.map +1 -0
- package/dist/node-command-invocation-adapter.d.ts +25 -0
- package/dist/node-command-invocation-adapter.d.ts.map +1 -1
- package/dist/node-command-invocation-adapter.js +73 -38
- package/dist/node-command-invocation-adapter.js.map +1 -1
- 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
|
|
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 {
|
|
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(
|
|
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,
|