scoutline 0.18.1 → 0.19.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 +12 -0
- package/dist/command-invocation.d.ts +21 -1
- package/dist/command-invocation.d.ts.map +1 -1
- package/dist/command-invocation.js +8 -1
- package/dist/command-invocation.js.map +1 -1
- package/dist/commands/history.d.ts +120 -0
- package/dist/commands/history.d.ts.map +1 -0
- package/dist/commands/history.js +249 -0
- package/dist/commands/history.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +671 -20
- package/dist/index.js.map +1 -1
- package/dist/lib/artifacts.d.ts +149 -0
- package/dist/lib/artifacts.d.ts.map +1 -0
- package/dist/lib/artifacts.js +380 -0
- package/dist/lib/artifacts.js.map +1 -0
- package/dist/lib/async-file-lock.d.ts +18 -0
- package/dist/lib/async-file-lock.d.ts.map +1 -1
- package/dist/lib/async-file-lock.js +31 -1
- package/dist/lib/async-file-lock.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ 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
16
|
import { usageCommand, USAGE_HELP, DEFAULT_USAGE_WINDOW_DAYS, MAX_USAGE_WINDOW_DAYS, } from "./commands/usage.js";
|
|
17
|
+
import { historyCommand, HISTORY_HELP } from "./commands/history.js";
|
|
17
18
|
import { cacheStats, clearAllCaches, parsePruneDuration, pruneCaches } from "./lib/cache.js";
|
|
18
19
|
import { parseBatchManifest } from "./lib/batch-manifest.js";
|
|
19
20
|
import { assignBatchProviders } from "./lib/batch-assign.js";
|
|
@@ -21,16 +22,18 @@ import { BATCH_MAX_CONCURRENCY, runBatch, } from "./lib/batch-runner.js";
|
|
|
21
22
|
import { isExtractMode } from "./lib/extract.js";
|
|
22
23
|
import { runCodeFile, evalCode, printInterfaces, printPromptTemplate, CODE_HELP, } from "./commands/code.js";
|
|
23
24
|
import { isOutputMode, OUTPUT_MODES } from "./lib/output.js";
|
|
24
|
-
import { formatErrorOutput } from "./lib/output.js";
|
|
25
|
-
import {
|
|
25
|
+
import { formatErrorOutput, formatSuccessOutput } from "./lib/output.js";
|
|
26
|
+
import { appendLogEntry, atomicPlaceNoClobber, CLI_VERSION, newRequestId, readLog, resolveArtifactsDir, writeArtifact, } from "./lib/artifacts.js";
|
|
27
|
+
import { FileError, ValidationError, UnsupportedCapabilityError, getErrorExitCode, } from "./lib/errors.js";
|
|
26
28
|
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
27
30
|
import * as fs from "node:fs/promises";
|
|
28
31
|
import { existsSync } from "node:fs";
|
|
29
32
|
import { invokeCommand, } from "./command-invocation.js";
|
|
30
33
|
import { defaultResponseCache } from "./lib/cache.js";
|
|
31
34
|
import { MAX_SUBQUERIES, parseContextText, readContextSource } from "./lib/context-file.js";
|
|
32
|
-
import { configuredSecrets } from "./lib/redact.js";
|
|
33
|
-
import { configFilePath, readConfig, resolveConfigRootPure, resolveEnvFromConfig, setConfigValue, unsetConfigValue, } from "./lib/config-store.js";
|
|
35
|
+
import { configuredSecrets, redactSecrets } from "./lib/redact.js";
|
|
36
|
+
import { configFilePath, atomicReplaceFile, readConfig, resolveConfigRootPure, resolveEnvFromConfig, setConfigValue, unsetConfigValue, } from "./lib/config-store.js";
|
|
34
37
|
import { inspectConfig, createDefaultVerificationPromoter, createDefaultHintShownStore, } from "./lib/config-store.js";
|
|
35
38
|
import { createDefaultQuotaStore, refreshQuotaSnapshots, } from "./lib/quota-store.js";
|
|
36
39
|
import { createCompositeConsumptionSink, createQuotaStoreConsumptionSink, } from "./lib/consumption.js";
|
|
@@ -73,6 +76,8 @@ Commands:
|
|
|
73
76
|
cache Inspect or clear the local cache (stats / clear)
|
|
74
77
|
usage Report local call-usage history (usage.json ledger,
|
|
75
78
|
credential-free)
|
|
79
|
+
history Inventory of saved --save artifacts (list / show / stats,
|
|
80
|
+
credential-free)
|
|
76
81
|
code Execute TypeScript tool chains (Code Mode, Z.AI)
|
|
77
82
|
init Interactive onboarding wizard (writes ~/.scoutline/config.json)
|
|
78
83
|
|
|
@@ -100,6 +105,9 @@ per-Provider; --provider picks the effective Provider for metadata.
|
|
|
100
105
|
Global Options:
|
|
101
106
|
--output-format <data|json|pretty|compact|markdown|refs|tty> Output mode (default: data)
|
|
102
107
|
-O <mode> Alias for --output-format
|
|
108
|
+
--save [<path>] Save the result as a clean report (content + requestId) after a successful shared-capability run (search/read/crawl/map/research/repo/vision). Master copy in the artifact store; <path> also receives an export copy. A valueless --save (trailing, or followed by another option, e.g. --save --save-format markdown) writes the master only. Refuses an existing export target without --save-force.
|
|
109
|
+
--save-format <json|markdown> Report format (default: json)
|
|
110
|
+
--save-force Overwrite an existing export target
|
|
103
111
|
|
|
104
112
|
Help:
|
|
105
113
|
scoutline --help
|
|
@@ -116,6 +124,7 @@ Help:
|
|
|
116
124
|
scoutline code --help
|
|
117
125
|
scoutline cache --help
|
|
118
126
|
scoutline usage --help
|
|
127
|
+
scoutline history --help
|
|
119
128
|
scoutline init --help
|
|
120
129
|
`.trim();
|
|
121
130
|
function parseArgs(args) {
|
|
@@ -188,6 +197,33 @@ function collectLongFlagValues(args, name) {
|
|
|
188
197
|
}
|
|
189
198
|
return values;
|
|
190
199
|
}
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Save-artifacts flag surface (batch ticket T3). `--save [<path>]`,
|
|
202
|
+
// `--save-format <json|markdown>`, and `--save-force` are global options:
|
|
203
|
+
// extracted for every command, removed from the rest stream, and consumed
|
|
204
|
+
// only by the save-capable families (search / read / crawl / map /
|
|
205
|
+
// research / repo / vision). Non-capable commands accept and silently drop
|
|
206
|
+
// them — the same posture as `--no-fallback`. T3 wires extraction plus the
|
|
207
|
+
// pre-dispatch guards; the actual artifact writes are ticket T4's.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
/** Artifact serialization formats (spec ruling: json | markdown, default json). */
|
|
210
|
+
const SAVE_FORMATS = ["json", "markdown"];
|
|
211
|
+
function isSaveFormat(value) {
|
|
212
|
+
return SAVE_FORMATS.includes(value);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Commands whose successful results can be saved as artifacts (spec
|
|
216
|
+
* coverage ruling). Every other command ignores the `--save*` flags.
|
|
217
|
+
*/
|
|
218
|
+
const SAVE_CAPABLE_COMMANDS = new Set([
|
|
219
|
+
"search",
|
|
220
|
+
"read",
|
|
221
|
+
"crawl",
|
|
222
|
+
"map",
|
|
223
|
+
"research",
|
|
224
|
+
"repo",
|
|
225
|
+
"vision",
|
|
226
|
+
]);
|
|
191
227
|
function extractGlobalOptions(args) {
|
|
192
228
|
const rest = [];
|
|
193
229
|
let outputFormat;
|
|
@@ -195,6 +231,10 @@ function extractGlobalOptions(args) {
|
|
|
195
231
|
let forceRaw = false;
|
|
196
232
|
let provider;
|
|
197
233
|
let noFallback = false;
|
|
234
|
+
let savePath;
|
|
235
|
+
let saveSeen = false;
|
|
236
|
+
let saveFormat = "json";
|
|
237
|
+
let saveForce = false;
|
|
198
238
|
for (let i = 0; i < args.length; i += 1) {
|
|
199
239
|
const arg = args[i];
|
|
200
240
|
if (arg === undefined)
|
|
@@ -243,9 +283,117 @@ function extractGlobalOptions(args) {
|
|
|
243
283
|
noFallback = true;
|
|
244
284
|
continue;
|
|
245
285
|
}
|
|
286
|
+
if (arg === "--save") {
|
|
287
|
+
// save-artifacts T3. Optional-value global flag with parseArgs'
|
|
288
|
+
// binding rule: the next token is the export path iff it exists,
|
|
289
|
+
// is non-empty, and does not start with "-". Anything else (trailing
|
|
290
|
+
// end-of-argv, an empty string, another option token) leaves `--save`
|
|
291
|
+
// valueless = the MASTER-ONLY save, and the token stays in the rest
|
|
292
|
+
// stream for its own parser (review fixup: `--save --save-format
|
|
293
|
+
// markdown` is the README-documented master-only markdown form and
|
|
294
|
+
// must run the provider, not exit VALIDATION_ERROR; the same rule
|
|
295
|
+
// makes `--save --limit 5` a master-only save with the limit still
|
|
296
|
+
// applied — the binding rule never binds a dash-prefixed token as a
|
|
297
|
+
// path, because paths never start with "-").
|
|
298
|
+
const value = args[i + 1];
|
|
299
|
+
saveSeen = true;
|
|
300
|
+
if (value === undefined)
|
|
301
|
+
continue;
|
|
302
|
+
if (value.startsWith("-"))
|
|
303
|
+
continue; // another option: stays in the rest stream
|
|
304
|
+
// A non-dash follower is consumed either way; an empty string cannot
|
|
305
|
+
// be an export path (and an empty argv token must not leak into the
|
|
306
|
+
// command's positionals and change the request), so it consumes as
|
|
307
|
+
// the valueless master-only form (review fixup).
|
|
308
|
+
if (value.length > 0)
|
|
309
|
+
savePath = value;
|
|
310
|
+
i += 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (arg === "--save-format") {
|
|
314
|
+
// save-artifacts T3. Required-value flag; validated at the global
|
|
315
|
+
// surface (before the command is even known) so a malformed value
|
|
316
|
+
// fails identically on capable and drop-the-flags commands.
|
|
317
|
+
const value = args[i + 1];
|
|
318
|
+
if (value === undefined || value.length === 0 || value.startsWith("-")) {
|
|
319
|
+
throw new ValidationError("--save-format requires a value.", `Use one of: ${SAVE_FORMATS.join(", ")}`);
|
|
320
|
+
}
|
|
321
|
+
if (!isSaveFormat(value)) {
|
|
322
|
+
throw new ValidationError(`Invalid save format: ${value}`, `Use one of: ${SAVE_FORMATS.join(", ")}`);
|
|
323
|
+
}
|
|
324
|
+
saveFormat = value;
|
|
325
|
+
i += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (arg === "--save-force") {
|
|
329
|
+
// save-artifacts T3. Boolean switch: bypasses the pre-dispatch
|
|
330
|
+
// exists guard for the export target (the write itself is T4's).
|
|
331
|
+
saveForce = true;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
246
334
|
rest.push(arg);
|
|
247
335
|
}
|
|
248
|
-
return {
|
|
336
|
+
return {
|
|
337
|
+
outputFormat,
|
|
338
|
+
forcePretty,
|
|
339
|
+
forceRaw,
|
|
340
|
+
provider,
|
|
341
|
+
noFallback,
|
|
342
|
+
save: saveSeen ? { exportPath: savePath, format: saveFormat, force: saveForce } : undefined,
|
|
343
|
+
rest,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* T3 pre-dispatch export guard (DESIGN D6 step 2): cheap, read-only
|
|
348
|
+
* filesystem checks that run in `main` before any provider/network work so
|
|
349
|
+
* the common save failures cost nothing.
|
|
350
|
+
*
|
|
351
|
+
* - Export target exists and `--save-force` is absent -> FileError
|
|
352
|
+
* ("artifact exists", the D8-greppable wording; the help names
|
|
353
|
+
* --save-force).
|
|
354
|
+
* - Export parent missing, not a directory, or not writable -> FileError
|
|
355
|
+
* (T3 does not mkdir -p; the parent must already accept writes).
|
|
356
|
+
*
|
|
357
|
+
* A master-only save (`--save` with no path) skips the guard entirely:
|
|
358
|
+
* there is no export target to conflict with, and no filesystem access.
|
|
359
|
+
* The pre-check race (target created between this check and T4's write)
|
|
360
|
+
* is closed at write time by T4's exists-recheck, not here.
|
|
361
|
+
*/
|
|
362
|
+
async function assertExportTargetAcceptable(request) {
|
|
363
|
+
const exportPath = request.exportPath;
|
|
364
|
+
if (exportPath === undefined)
|
|
365
|
+
return;
|
|
366
|
+
let targetExists = false;
|
|
367
|
+
try {
|
|
368
|
+
// lstat, not stat: a dangling symlink at the target is an existing
|
|
369
|
+
// entry and must be refused without --save-force (review fixup) —
|
|
370
|
+
// stat would miss it (ENOENT through the dangling link).
|
|
371
|
+
await fs.lstat(exportPath);
|
|
372
|
+
targetExists = true;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
targetExists = false;
|
|
376
|
+
}
|
|
377
|
+
if (targetExists && !request.force) {
|
|
378
|
+
throw new FileError(`artifact exists: ${exportPath}`, "Pass --save-force to overwrite the existing export target.");
|
|
379
|
+
}
|
|
380
|
+
const parent = path.dirname(exportPath);
|
|
381
|
+
let parentStat;
|
|
382
|
+
try {
|
|
383
|
+
parentStat = await fs.stat(parent);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
parentStat = undefined;
|
|
387
|
+
}
|
|
388
|
+
if (parentStat === undefined || !parentStat.isDirectory()) {
|
|
389
|
+
throw new FileError(`export parent directory does not exist: ${parent}`, "Create the parent directory first or choose an export path inside an existing directory.");
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await fs.access(parent, fs.constants.W_OK);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
throw new FileError(`export parent directory is not writable: ${parent}`, "Choose an export path in a writable directory or fix the directory's permissions.");
|
|
396
|
+
}
|
|
249
397
|
}
|
|
250
398
|
/**
|
|
251
399
|
* The single shared output-mode resolver for every path in `main`:
|
|
@@ -381,6 +529,23 @@ async function handleVision(args, outputMode, deps) {
|
|
|
381
529
|
sleep: deps.searchSleep,
|
|
382
530
|
random: deps.searchRandom,
|
|
383
531
|
};
|
|
532
|
+
// save-artifacts T4: one save hook for this run (inert unless main wired
|
|
533
|
+
// a save). args = the provider-influencing allow-list only. The batch
|
|
534
|
+
// subcommand returns before this point (per-op providers live in the
|
|
535
|
+
// runner), so batch stays accept-and-drop in v1.
|
|
536
|
+
const save = createSaveArtifactHook(deps, {
|
|
537
|
+
command: "vision",
|
|
538
|
+
outputMode,
|
|
539
|
+
args: {
|
|
540
|
+
...(deps.provider !== undefined ? { provider: deps.provider } : {}),
|
|
541
|
+
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
542
|
+
},
|
|
543
|
+
provider: {
|
|
544
|
+
mode: "single",
|
|
545
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
546
|
+
effective: providerId,
|
|
547
|
+
},
|
|
548
|
+
});
|
|
384
549
|
// Provider-fallback Ticket 02: route every vision operation through
|
|
385
550
|
// the shared executor. The existing vision URL→temp-file fallback
|
|
386
551
|
// stays inside the Z.AI adapter's invoke, layered beneath provider
|
|
@@ -450,7 +615,7 @@ async function handleVision(args, outputMode, deps) {
|
|
|
450
615
|
}
|
|
451
616
|
});
|
|
452
617
|
return outcome.result;
|
|
453
|
-
}, outputMode, deps.now, deps.secrets);
|
|
618
|
+
}, outputMode, deps.now, deps.secrets, save);
|
|
454
619
|
}
|
|
455
620
|
/**
|
|
456
621
|
* Map a Vision subcommand to its discriminated operation id. Used by
|
|
@@ -657,6 +822,36 @@ async function handleSearch(args, outputMode, deps) {
|
|
|
657
822
|
quotaSnapshot: deps.quotaState,
|
|
658
823
|
routing: deps.routing,
|
|
659
824
|
});
|
|
825
|
+
// save-artifacts T4: one save hook for this run (inert unless main wired
|
|
826
|
+
// a save). The provider routing mirrors the in-code vocabulary (DESIGN
|
|
827
|
+
// D5): fan-out records the arm list with no single effective; single
|
|
828
|
+
// records requested + effective (the hook overrides effective with the
|
|
829
|
+
// executor's actual server when runtime fallback switched providers).
|
|
830
|
+
const save = createSaveArtifactHook(deps, {
|
|
831
|
+
command: "search",
|
|
832
|
+
outputMode,
|
|
833
|
+
args: {
|
|
834
|
+
...(deps.provider !== undefined ? { provider: deps.provider } : {}),
|
|
835
|
+
...(count !== undefined ? { count } : {}),
|
|
836
|
+
...(type !== undefined ? { type } : {}),
|
|
837
|
+
...(topic !== undefined ? { topic } : {}),
|
|
838
|
+
...(flags.merge === true ? { merge: true } : {}),
|
|
839
|
+
...(flags["no-cache"] === true ? { "no-cache": true } : {}),
|
|
840
|
+
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
841
|
+
},
|
|
842
|
+
provider: fanoutPlan.mode === "fanout"
|
|
843
|
+
? {
|
|
844
|
+
mode: "fanout",
|
|
845
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
846
|
+
arms: fanoutPlan.arms,
|
|
847
|
+
}
|
|
848
|
+
: {
|
|
849
|
+
mode: "single",
|
|
850
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
851
|
+
// Single mode always resolved a provider above (the ternary).
|
|
852
|
+
effective: providerId,
|
|
853
|
+
},
|
|
854
|
+
});
|
|
660
855
|
const query = positional.join(" ");
|
|
661
856
|
const fieldsRaw = flags.fields;
|
|
662
857
|
const fields = fieldsRaw
|
|
@@ -838,7 +1033,7 @@ async function handleSearch(args, outputMode, deps) {
|
|
|
838
1033
|
}, context);
|
|
839
1034
|
});
|
|
840
1035
|
return applyContextWrapper(outcome.result);
|
|
841
|
-
}, outputMode, deps.now, deps.secrets);
|
|
1036
|
+
}, outputMode, deps.now, deps.secrets, save);
|
|
842
1037
|
}
|
|
843
1038
|
async function handleRead(args, outputMode, deps) {
|
|
844
1039
|
const { flags, positional } = parseArgs(args);
|
|
@@ -877,6 +1072,22 @@ async function handleRead(args, outputMode, deps) {
|
|
|
877
1072
|
quotaSnapshot: deps.quotaState,
|
|
878
1073
|
routing: deps.routing,
|
|
879
1074
|
});
|
|
1075
|
+
// save-artifacts T4: one save hook for this run (inert unless main wired
|
|
1076
|
+
// a save). args = the provider-influencing allow-list only.
|
|
1077
|
+
const save = createSaveArtifactHook(deps, {
|
|
1078
|
+
command: "read",
|
|
1079
|
+
outputMode,
|
|
1080
|
+
args: {
|
|
1081
|
+
...(deps.provider !== undefined ? { provider: deps.provider } : {}),
|
|
1082
|
+
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
1083
|
+
...(flags["no-cache"] === true ? { "no-cache": true } : {}),
|
|
1084
|
+
},
|
|
1085
|
+
provider: {
|
|
1086
|
+
mode: "single",
|
|
1087
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
1088
|
+
effective: providerId,
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
880
1091
|
const readOptions = {
|
|
881
1092
|
format: flags.format,
|
|
882
1093
|
noImages: flags["no-images"] === true,
|
|
@@ -925,7 +1136,7 @@ async function handleRead(args, outputMode, deps) {
|
|
|
925
1136
|
}, context);
|
|
926
1137
|
});
|
|
927
1138
|
return outcome.result;
|
|
928
|
-
}, outputMode, deps.now, deps.secrets);
|
|
1139
|
+
}, outputMode, deps.now, deps.secrets, save);
|
|
929
1140
|
}
|
|
930
1141
|
async function handleCrawl(args, outputMode, deps) {
|
|
931
1142
|
const { flags, positional } = parseArgs(args);
|
|
@@ -956,6 +1167,22 @@ async function handleCrawl(args, outputMode, deps) {
|
|
|
956
1167
|
// to the same production values as Search/Repository/Reader but are
|
|
957
1168
|
// kept as separate optional MainDependencies so crawl tests can inject
|
|
958
1169
|
// isolated in-memory doubles.
|
|
1170
|
+
// save-artifacts T4: one save hook for this run (inert unless main wired
|
|
1171
|
+
// a save). args = the provider-influencing allow-list only.
|
|
1172
|
+
const save = createSaveArtifactHook(deps, {
|
|
1173
|
+
command: "crawl",
|
|
1174
|
+
outputMode,
|
|
1175
|
+
args: {
|
|
1176
|
+
...(deps.provider !== undefined ? { provider: deps.provider } : {}),
|
|
1177
|
+
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
1178
|
+
...(typeof flags.limit === "string" ? { limit: flags.limit } : {}),
|
|
1179
|
+
},
|
|
1180
|
+
provider: {
|
|
1181
|
+
mode: "single",
|
|
1182
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
1183
|
+
effective: providerId,
|
|
1184
|
+
},
|
|
1185
|
+
});
|
|
959
1186
|
const executionDeps = {
|
|
960
1187
|
cache: deps.crawlCache,
|
|
961
1188
|
sleep: deps.crawlSleep,
|
|
@@ -1001,7 +1228,7 @@ async function handleCrawl(args, outputMode, deps) {
|
|
|
1001
1228
|
}, context);
|
|
1002
1229
|
});
|
|
1003
1230
|
return outcome.result;
|
|
1004
|
-
}, outputMode, deps.now, deps.secrets);
|
|
1231
|
+
}, outputMode, deps.now, deps.secrets, save);
|
|
1005
1232
|
}
|
|
1006
1233
|
async function handleMap(args, outputMode, deps) {
|
|
1007
1234
|
const { flags, positional } = parseArgs(args);
|
|
@@ -1032,6 +1259,22 @@ async function handleMap(args, outputMode, deps) {
|
|
|
1032
1259
|
// to the same production values as Search/Repository/Reader/Crawl but
|
|
1033
1260
|
// are kept as separate optional MainDependencies so map tests can
|
|
1034
1261
|
// inject isolated in-memory doubles.
|
|
1262
|
+
// save-artifacts T4: one save hook for this run (inert unless main wired
|
|
1263
|
+
// a save). args = the provider-influencing allow-list only.
|
|
1264
|
+
const save = createSaveArtifactHook(deps, {
|
|
1265
|
+
command: "map",
|
|
1266
|
+
outputMode,
|
|
1267
|
+
args: {
|
|
1268
|
+
...(deps.provider !== undefined ? { provider: deps.provider } : {}),
|
|
1269
|
+
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
1270
|
+
...(typeof flags.limit === "string" ? { limit: flags.limit } : {}),
|
|
1271
|
+
},
|
|
1272
|
+
provider: {
|
|
1273
|
+
mode: "single",
|
|
1274
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
1275
|
+
effective: providerId,
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1035
1278
|
const executionDeps = {
|
|
1036
1279
|
cache: deps.mapCache,
|
|
1037
1280
|
sleep: deps.mapSleep,
|
|
@@ -1071,7 +1314,7 @@ async function handleMap(args, outputMode, deps) {
|
|
|
1071
1314
|
}, context);
|
|
1072
1315
|
});
|
|
1073
1316
|
return outcome.result;
|
|
1074
|
-
}, outputMode, deps.now, deps.secrets);
|
|
1317
|
+
}, outputMode, deps.now, deps.secrets, save);
|
|
1075
1318
|
}
|
|
1076
1319
|
async function handleResearch(args, outputMode, deps) {
|
|
1077
1320
|
const { flags, positional } = parseArgs(args);
|
|
@@ -1129,6 +1372,29 @@ async function handleResearch(args, outputMode, deps) {
|
|
|
1129
1372
|
routing: deps.routing,
|
|
1130
1373
|
});
|
|
1131
1374
|
// Shared Research execution dependencies.
|
|
1375
|
+
// save-artifacts T4: one save hook for this run (inert unless main wired
|
|
1376
|
+
// a save). args = the provider-influencing allow-list only.
|
|
1377
|
+
const save = createSaveArtifactHook(deps, {
|
|
1378
|
+
command: "research",
|
|
1379
|
+
outputMode,
|
|
1380
|
+
args: {
|
|
1381
|
+
...(deps.provider !== undefined ? { provider: deps.provider } : {}),
|
|
1382
|
+
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
1383
|
+
// Request-affecting controls (DESIGN D6/G6 allow-list: provider-
|
|
1384
|
+
// influencing options, "capability controls"; review round 2):
|
|
1385
|
+
// recorded set-only-when-given, the search --no-cache convention.
|
|
1386
|
+
...(model !== undefined ? { model } : {}),
|
|
1387
|
+
...(outputLength !== undefined ? { "output-length": outputLength } : {}),
|
|
1388
|
+
...(citationFormat !== undefined ? { "citation-format": citationFormat } : {}),
|
|
1389
|
+
...(typeof flags.domain === "string" ? { domain: flags.domain } : {}),
|
|
1390
|
+
...(flags["no-cache"] === true ? { "no-cache": true } : {}),
|
|
1391
|
+
},
|
|
1392
|
+
provider: {
|
|
1393
|
+
mode: "single",
|
|
1394
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
1395
|
+
effective: providerId,
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1132
1398
|
const executionDeps = {
|
|
1133
1399
|
cache: deps.researchCache,
|
|
1134
1400
|
sleep: deps.researchSleep,
|
|
@@ -1238,7 +1504,7 @@ async function handleResearch(args, outputMode, deps) {
|
|
|
1238
1504
|
}, context);
|
|
1239
1505
|
});
|
|
1240
1506
|
return outcome.result;
|
|
1241
|
-
}, outputMode, deps.now, deps.secrets);
|
|
1507
|
+
}, outputMode, deps.now, deps.secrets, save);
|
|
1242
1508
|
}
|
|
1243
1509
|
/**
|
|
1244
1510
|
* Validate a research enum flag (--model, --output-length,
|
|
@@ -1383,6 +1649,23 @@ async function handleRepo(args, outputMode, deps) {
|
|
|
1383
1649
|
const noCache = flags["no-cache"] === true;
|
|
1384
1650
|
const treePath = flags.path;
|
|
1385
1651
|
const depth = flags.depth ? parseInt(flags.depth, 10) : undefined;
|
|
1652
|
+
// save-artifacts T4: one save hook for this run (inert unless main wired
|
|
1653
|
+
// a save). args = the provider-influencing allow-list only.
|
|
1654
|
+
const save = createSaveArtifactHook(deps, {
|
|
1655
|
+
command: "repo",
|
|
1656
|
+
outputMode,
|
|
1657
|
+
args: {
|
|
1658
|
+
...(deps.provider !== undefined ? { provider: deps.provider } : {}),
|
|
1659
|
+
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
1660
|
+
...(noCache ? { "no-cache": true } : {}),
|
|
1661
|
+
...(depth !== undefined ? { depth } : {}),
|
|
1662
|
+
},
|
|
1663
|
+
provider: {
|
|
1664
|
+
mode: "single",
|
|
1665
|
+
...(deps.provider !== undefined ? { requested: deps.provider } : {}),
|
|
1666
|
+
effective: providerId,
|
|
1667
|
+
},
|
|
1668
|
+
});
|
|
1386
1669
|
// Provider-fallback Ticket 02: route every subcommand through the
|
|
1387
1670
|
// shared executor. All three projections (search/tree/read) share
|
|
1388
1671
|
// the `repository-exploration` Capability, so a single executor
|
|
@@ -1422,7 +1705,7 @@ async function handleRepo(args, outputMode, deps) {
|
|
|
1422
1705
|
}
|
|
1423
1706
|
});
|
|
1424
1707
|
return outcome.result;
|
|
1425
|
-
}, outputMode, deps.now, deps.secrets);
|
|
1708
|
+
}, outputMode, deps.now, deps.secrets, save);
|
|
1426
1709
|
}
|
|
1427
1710
|
// ---------------------------------------------------------------------------
|
|
1428
1711
|
// Batch manifest runner (batch-runner DESIGN D1, D8)
|
|
@@ -1956,6 +2239,100 @@ export async function handleUsage(args, outputMode, deps) {
|
|
|
1956
2239
|
now,
|
|
1957
2240
|
}), outputMode, deps.now, deps.secrets);
|
|
1958
2241
|
}
|
|
2242
|
+
/**
|
|
2243
|
+
* `history` dispatcher (save-artifacts T5): flag/subcommand validation
|
|
2244
|
+
* up front, then the pure `historyCommand` through the invocation seam
|
|
2245
|
+
* with the artifacts store as the only I/O. The store path resolves
|
|
2246
|
+
* against `SCOUTLINE_ARTIFACTS_DIR` / the config root; reads are
|
|
2247
|
+
* fail-open (`readLog` never throws) so a missing or corrupt store is
|
|
2248
|
+
* an empty inventory, exit 0. FILE_ERROR paths (unknown id, missing
|
|
2249
|
+
* master) ride the seam's existing error boundary.
|
|
2250
|
+
*/
|
|
2251
|
+
export async function handleHistory(args, outputMode, deps) {
|
|
2252
|
+
const { flags, positional } = parseArgs(args);
|
|
2253
|
+
if (flags.help || flags.h) {
|
|
2254
|
+
deps.invocation.writeStdout(HISTORY_HELP);
|
|
2255
|
+
return 0;
|
|
2256
|
+
}
|
|
2257
|
+
const subcommand = positional[0];
|
|
2258
|
+
if (subcommand === undefined) {
|
|
2259
|
+
// Bare `scoutline history` is a discovery affordance, not an error.
|
|
2260
|
+
deps.invocation.writeStdout(HISTORY_HELP);
|
|
2261
|
+
return 0;
|
|
2262
|
+
}
|
|
2263
|
+
if (subcommand !== "list" && subcommand !== "show" && subcommand !== "stats") {
|
|
2264
|
+
throw new ValidationError(`Unknown history subcommand "${subcommand}".`, "Valid subcommands: list, show, stats.");
|
|
2265
|
+
}
|
|
2266
|
+
// `--since N` / `--limit N`: strict decimal integers >= 1 (the same
|
|
2267
|
+
// gate class as usage `--days` — Number() alone would admit "1e3",
|
|
2268
|
+
// " 7", and "7.0" spellings the documented contract excludes).
|
|
2269
|
+
let sinceDays;
|
|
2270
|
+
const rawSince = flags.since;
|
|
2271
|
+
if (rawSince !== undefined) {
|
|
2272
|
+
if (rawSince === true) {
|
|
2273
|
+
throw new ValidationError("--since requires a value.", "Pass a positive integer, e.g. --since 7.");
|
|
2274
|
+
}
|
|
2275
|
+
const str = String(rawSince);
|
|
2276
|
+
if (!/^\d+$/.test(str) || Number(str) < 1) {
|
|
2277
|
+
throw new ValidationError(`Invalid --since value "${str}".`, "--since must be a positive integer, e.g. --since 7.");
|
|
2278
|
+
}
|
|
2279
|
+
sinceDays = Number(str);
|
|
2280
|
+
}
|
|
2281
|
+
let limit;
|
|
2282
|
+
const rawLimit = flags.limit;
|
|
2283
|
+
if (rawLimit !== undefined) {
|
|
2284
|
+
if (rawLimit === true) {
|
|
2285
|
+
throw new ValidationError("--limit requires a value.", "Pass a positive integer, e.g. --limit 20.");
|
|
2286
|
+
}
|
|
2287
|
+
const str = String(rawLimit);
|
|
2288
|
+
if (!/^\d+$/.test(str) || Number(str) < 1) {
|
|
2289
|
+
throw new ValidationError(`Invalid --limit value "${str}".`, "--limit must be a positive integer, e.g. --limit 20.");
|
|
2290
|
+
}
|
|
2291
|
+
limit = Number(str);
|
|
2292
|
+
}
|
|
2293
|
+
const rawCommand = flags.command;
|
|
2294
|
+
if (rawCommand === true) {
|
|
2295
|
+
throw new ValidationError("--command requires a value.", "Pass a command name, e.g. --command search.");
|
|
2296
|
+
}
|
|
2297
|
+
const commandFilter = typeof rawCommand === "string" ? rawCommand : undefined;
|
|
2298
|
+
let requestId;
|
|
2299
|
+
if (subcommand === "show") {
|
|
2300
|
+
requestId = positional[1];
|
|
2301
|
+
if (requestId === undefined) {
|
|
2302
|
+
throw new ValidationError("history show requires a requestId.", "Run history list to see saved request ids.");
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const dir = resolveArtifactsDir(deps.env);
|
|
2306
|
+
const now = deps.now ?? Date.now;
|
|
2307
|
+
return invokeCommand(deps.invocation, (context) => historyCommand({
|
|
2308
|
+
subcommand,
|
|
2309
|
+
readLog: () => readLog(dir),
|
|
2310
|
+
readMaster: async (entry) => {
|
|
2311
|
+
try {
|
|
2312
|
+
return await fs.readFile(path.join(dir, entry.masterPath), "utf8");
|
|
2313
|
+
}
|
|
2314
|
+
catch (error) {
|
|
2315
|
+
if (error.code === "ENOENT")
|
|
2316
|
+
return undefined;
|
|
2317
|
+
throw error;
|
|
2318
|
+
}
|
|
2319
|
+
},
|
|
2320
|
+
masterSizeOf: async (entry) => {
|
|
2321
|
+
try {
|
|
2322
|
+
return (await fs.stat(path.join(dir, entry.masterPath))).size;
|
|
2323
|
+
}
|
|
2324
|
+
catch {
|
|
2325
|
+
return 0;
|
|
2326
|
+
}
|
|
2327
|
+
},
|
|
2328
|
+
notice: context.notice,
|
|
2329
|
+
now,
|
|
2330
|
+
...(sinceDays !== undefined ? { sinceDays } : {}),
|
|
2331
|
+
...(limit !== undefined ? { limit } : {}),
|
|
2332
|
+
...(commandFilter !== undefined ? { command: commandFilter } : {}),
|
|
2333
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
2334
|
+
}), outputMode, deps.now, deps.secrets);
|
|
2335
|
+
}
|
|
1959
2336
|
async function handleQuota(args, outputMode, deps) {
|
|
1960
2337
|
const { flags } = parseArgs(args);
|
|
1961
2338
|
if (flags.help || flags.h) {
|
|
@@ -2033,6 +2410,222 @@ async function handleCode(args, outputMode, deps) {
|
|
|
2033
2410
|
throw new ValidationError(`Unknown code command: ${command}`, 'Run "scoutline code --help" for available commands');
|
|
2034
2411
|
}
|
|
2035
2412
|
}
|
|
2413
|
+
// ---------------------------------------------------------------------------
|
|
2414
|
+
// Save-artifacts T4 - the hook at the invocation seam. T3's inert
|
|
2415
|
+
// saveRequest is consumed here: main wraps the injected provider
|
|
2416
|
+
// descriptors once (so the log can record the provider that ACTUALLY
|
|
2417
|
+
// served the run - runtime fallback is invisible to the pre-run
|
|
2418
|
+
// resolver), and each save-capable handler turns deps.save into a
|
|
2419
|
+
// SaveHook via createSaveArtifactHook. Write order (DESIGN D6): master,
|
|
2420
|
+
// log append, export copy - the export re-checks target existence so the
|
|
2421
|
+
// T3 pre-dispatch race window closes at write time. Any hook throw rides
|
|
2422
|
+
// invokeCommand's existing catch: notices flushed, one FILE_ERROR
|
|
2423
|
+
// envelope, stdout suppressed.
|
|
2424
|
+
// ---------------------------------------------------------------------------
|
|
2425
|
+
/** The report file's own schema version (DESIGN D4 namespace, log-agnostic). */
|
|
2426
|
+
const REPORT_SCHEMA_VERSION = 1;
|
|
2427
|
+
/**
|
|
2428
|
+
* Wrap every capability slot's invoke() so the first resolving invoke
|
|
2429
|
+
* records its provider id. Transparent pass-through: validate and
|
|
2430
|
+
* cacheIdentity (and everything else on the handle) are untouched, so
|
|
2431
|
+
* executor preflight, cache identity, and retry behavior are
|
|
2432
|
+
* byte-identical to the unwrapped descriptors.
|
|
2433
|
+
*/
|
|
2434
|
+
/** Wrap one operation object so the serving provider records into the capture. */
|
|
2435
|
+
function withCaptureInvoke(slot, id, capture) {
|
|
2436
|
+
const invoke = slot.invoke;
|
|
2437
|
+
const wrapped = {
|
|
2438
|
+
...slot,
|
|
2439
|
+
invoke: async (...args) => {
|
|
2440
|
+
const outcome = await invoke(...args);
|
|
2441
|
+
capture.servedProvider = id;
|
|
2442
|
+
return outcome;
|
|
2443
|
+
},
|
|
2444
|
+
};
|
|
2445
|
+
// Review fixup: a CACHE-HIT attempt never reaches invoke() — the shared
|
|
2446
|
+
// executors return straight after the cache lookup (src/lib/execution.ts
|
|
2447
|
+
// steps 2-3), so a fallback candidate that serves from cache left the
|
|
2448
|
+
// capture unset and the log recorded the pre-run effective instead of
|
|
2449
|
+
// the provider whose cache actually served. cacheIdentity runs on EVERY
|
|
2450
|
+
// attempt immediately before the cache consult, and it is the last
|
|
2451
|
+
// per-attempt hook of the attempt that serves, so capturing here names
|
|
2452
|
+
// the serving provider in both the live and cache-hit paths.
|
|
2453
|
+
const cacheIdentity = slot.cacheIdentity;
|
|
2454
|
+
if (typeof cacheIdentity === "function") {
|
|
2455
|
+
wrapped.cacheIdentity = (...args) => {
|
|
2456
|
+
capture.servedProvider = id;
|
|
2457
|
+
return cacheIdentity.apply(slot, args);
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
return wrapped;
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Wrap every capability slot's operations so the first resolving invoke
|
|
2464
|
+
* records its provider id. Two adapter shapes exist (cold-review round 1
|
|
2465
|
+
* finding 1): direct-invoke slots (`search`, `vision`) and nested
|
|
2466
|
+
* operation slots — `reader`/`crawl`/`map` expose `{ fetch: Operation }`,
|
|
2467
|
+
* `research` exposes `{ run: Operation }`, `repository` exposes one
|
|
2468
|
+
* operation per name — so the wrap recurses exactly one level. Transparent
|
|
2469
|
+
* pass-through: validate, cacheIdentity, decodeCached (and everything else
|
|
2470
|
+
* on the slot) are untouched, so executor preflight, cache identity, and
|
|
2471
|
+
* retry behavior are byte-identical to the unwrapped descriptors.
|
|
2472
|
+
*/
|
|
2473
|
+
function captureAdapterInvoke(adapter, id, capture) {
|
|
2474
|
+
const wrapped = { ...adapter };
|
|
2475
|
+
for (const key of Object.keys(adapter)) {
|
|
2476
|
+
const slot = adapter[key];
|
|
2477
|
+
if (slot === null || typeof slot !== "object")
|
|
2478
|
+
continue;
|
|
2479
|
+
const record = slot;
|
|
2480
|
+
if (typeof record.invoke === "function") {
|
|
2481
|
+
wrapped[key] = withCaptureInvoke(record, id, capture);
|
|
2482
|
+
continue;
|
|
2483
|
+
}
|
|
2484
|
+
// Nested operation shapes: wrap each own property that is itself an
|
|
2485
|
+
// operation (has invoke). Slots with no operations pass through.
|
|
2486
|
+
const nested = { ...record };
|
|
2487
|
+
let nestedTouched = false;
|
|
2488
|
+
for (const nestedKey of Object.keys(record)) {
|
|
2489
|
+
const operation = record[nestedKey];
|
|
2490
|
+
if (operation !== null &&
|
|
2491
|
+
typeof operation === "object" &&
|
|
2492
|
+
typeof operation.invoke === "function") {
|
|
2493
|
+
nested[nestedKey] = withCaptureInvoke(operation, id, capture);
|
|
2494
|
+
nestedTouched = true;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
if (nestedTouched)
|
|
2498
|
+
wrapped[key] = nested;
|
|
2499
|
+
}
|
|
2500
|
+
return wrapped;
|
|
2501
|
+
}
|
|
2502
|
+
function captureServingDescriptors(descriptors, capture) {
|
|
2503
|
+
return descriptors.map((descriptor) => ({
|
|
2504
|
+
...descriptor,
|
|
2505
|
+
create: (context) => captureAdapterInvoke(descriptor.create(context), descriptor.id, capture),
|
|
2506
|
+
}));
|
|
2507
|
+
}
|
|
2508
|
+
function buildSaveWiring(request, descriptors) {
|
|
2509
|
+
if (request === undefined)
|
|
2510
|
+
return undefined;
|
|
2511
|
+
const capture = {};
|
|
2512
|
+
return {
|
|
2513
|
+
descriptors: captureServingDescriptors(descriptors, capture),
|
|
2514
|
+
input: { request, capture },
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
function artifactHeaderComment(requestId) {
|
|
2518
|
+
return `<!-- scoutline artifact requestId=${requestId} schemaVersion=${REPORT_SCHEMA_VERSION} -->`;
|
|
2519
|
+
}
|
|
2520
|
+
/**
|
|
2521
|
+
* The markdown artifact body: what stdout's markdown mode would print -
|
|
2522
|
+
* the redacted presentation override when the command supplies one,
|
|
2523
|
+
* otherwise formatSuccessOutput over the redacted data (DESIGN D4).
|
|
2524
|
+
*/
|
|
2525
|
+
function renderMarkdownArtifactBody(result, redactedData, resolvedSecrets, now) {
|
|
2526
|
+
const override = result.kind === "data" ? result.presentations?.markdown : undefined;
|
|
2527
|
+
return typeof override === "string"
|
|
2528
|
+
? redactSecrets(override, resolvedSecrets)
|
|
2529
|
+
: formatSuccessOutput(redactedData, "markdown", now);
|
|
2530
|
+
}
|
|
2531
|
+
async function exportTargetExists(filePath) {
|
|
2532
|
+
try {
|
|
2533
|
+
// lstat so a dangling symlink counts as existing (review fixup; see
|
|
2534
|
+
// assertExportTargetAcceptable).
|
|
2535
|
+
await fs.lstat(filePath);
|
|
2536
|
+
return true;
|
|
2537
|
+
}
|
|
2538
|
+
catch (error) {
|
|
2539
|
+
if (error.code === "ENOENT")
|
|
2540
|
+
return false;
|
|
2541
|
+
throw error;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Build one run's SaveHook. Returns undefined unless main wired a save
|
|
2546
|
+
* (deps.save) - the accept-and-drop posture of every non-saving command
|
|
2547
|
+
* is preserved by construction. The report is the clean envelope
|
|
2548
|
+
* {schemaVersion, requestId, result} with result =
|
|
2549
|
+
* redactSecrets(result.data, resolvedSecrets) - the exact value the
|
|
2550
|
+
* data-mode stdout path serializes (DESIGN D4). Log args carry only the
|
|
2551
|
+
* handler-supplied provider-influencing allow-list. Failures: known
|
|
2552
|
+
* artifact refusals pass through as FileError; unexpected I/O faults are
|
|
2553
|
+
* wrapped into FileError so every artifact-path failure keeps the D8
|
|
2554
|
+
* FILE_ERROR contract.
|
|
2555
|
+
*/
|
|
2556
|
+
function createSaveArtifactHook(deps, meta) {
|
|
2557
|
+
const save = deps.save;
|
|
2558
|
+
if (save === undefined)
|
|
2559
|
+
return undefined;
|
|
2560
|
+
const { request, capture } = save;
|
|
2561
|
+
return async ({ result, resolvedSecrets, now, notice }) => {
|
|
2562
|
+
try {
|
|
2563
|
+
const dir = resolveArtifactsDir(deps.env);
|
|
2564
|
+
const requestId = newRequestId(now());
|
|
2565
|
+
const data = result.kind === "data" ? result.data : result.text;
|
|
2566
|
+
const redactedData = redactSecrets(data, resolvedSecrets);
|
|
2567
|
+
const content = request.format === "markdown"
|
|
2568
|
+
? `${artifactHeaderComment(requestId)}\n${renderMarkdownArtifactBody(result, redactedData, resolvedSecrets, now)}\n`
|
|
2569
|
+
: `${JSON.stringify({ schemaVersion: REPORT_SCHEMA_VERSION, requestId, result: redactedData }, null, 2)}\n`;
|
|
2570
|
+
const masterPath = await writeArtifact(dir, requestId, content, { format: request.format });
|
|
2571
|
+
const provider = meta.provider.mode === "fanout"
|
|
2572
|
+
? meta.provider
|
|
2573
|
+
: {
|
|
2574
|
+
...meta.provider,
|
|
2575
|
+
// The executor's actual server wins over the pre-run
|
|
2576
|
+
// resolution when runtime fallback switched providers (D5).
|
|
2577
|
+
effective: capture.servedProvider ?? meta.provider.effective,
|
|
2578
|
+
};
|
|
2579
|
+
const entry = {
|
|
2580
|
+
kind: "save",
|
|
2581
|
+
requestId,
|
|
2582
|
+
timestamp: now(),
|
|
2583
|
+
command: meta.command,
|
|
2584
|
+
args: meta.args,
|
|
2585
|
+
provider,
|
|
2586
|
+
outputFormat: meta.outputMode,
|
|
2587
|
+
artifactFormat: request.format,
|
|
2588
|
+
cliVersion: CLI_VERSION,
|
|
2589
|
+
masterPath: path.basename(masterPath),
|
|
2590
|
+
...(request.exportPath !== undefined ? { exportPath: request.exportPath } : {}),
|
|
2591
|
+
};
|
|
2592
|
+
const logNotice = await appendLogEntry(dir, entry);
|
|
2593
|
+
if (logNotice !== undefined)
|
|
2594
|
+
notice(logNotice);
|
|
2595
|
+
if (request.exportPath !== undefined) {
|
|
2596
|
+
// Write-time exists-recheck: closes the T3 pre-dispatch race
|
|
2597
|
+
// window (DESIGN D6). Without --save-force a target that appeared
|
|
2598
|
+
// mid-run is refused, byte-identical.
|
|
2599
|
+
if (!request.force && (await exportTargetExists(request.exportPath))) {
|
|
2600
|
+
throw new FileError(`artifact exists: ${request.exportPath}`, "Pass --save-force to overwrite the existing export target.");
|
|
2601
|
+
}
|
|
2602
|
+
if (request.force) {
|
|
2603
|
+
await atomicReplaceFile(request.exportPath, content);
|
|
2604
|
+
}
|
|
2605
|
+
else {
|
|
2606
|
+
// Atomic check-and-place (review fixup): fs.link fails EEXIST
|
|
2607
|
+
// when a target appeared between the recheck and the write, so
|
|
2608
|
+
// the no-overwrite refusal is one atomic step, byte-identical
|
|
2609
|
+
// for the target, never a mid-run overwrite.
|
|
2610
|
+
const placed = await atomicPlaceNoClobber(request.exportPath, content);
|
|
2611
|
+
if (!placed) {
|
|
2612
|
+
throw new FileError(`artifact exists: ${request.exportPath}`, "Pass --save-force to overwrite the existing export target.");
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
notice(`ℹ️ saved artifact ${requestId} (master: ${masterPath}; export: ${request.exportPath})`);
|
|
2616
|
+
}
|
|
2617
|
+
else {
|
|
2618
|
+
notice(`ℹ️ saved artifact ${requestId} (master: ${masterPath})`);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
catch (error) {
|
|
2622
|
+
if (error instanceof FileError)
|
|
2623
|
+
throw error;
|
|
2624
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2625
|
+
throw new FileError(`Failed to save artifact: ${message}`, "Check the artifacts directory and export path, then retry.");
|
|
2626
|
+
}
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2036
2629
|
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2037
2630
|
/**
|
|
2038
2631
|
* Map Plan A's `ProviderVerification` records (config-store shape) to
|
|
@@ -2162,6 +2755,12 @@ export async function main(args, dependencies) {
|
|
|
2162
2755
|
}
|
|
2163
2756
|
const command = rest[0] ?? "";
|
|
2164
2757
|
const commandArgs = rest.slice(1);
|
|
2758
|
+
// Hoisted above the save guards and the credential-free short-circuits:
|
|
2759
|
+
// a command-help invocation (`<cmd> --help`) is documentation, not a
|
|
2760
|
+
// run, so the pre-dispatch save guards must not refuse it even when the
|
|
2761
|
+
// `--save` export target already exists. The credentialed section below
|
|
2762
|
+
// reuses this binding.
|
|
2763
|
+
const isHelpInvocation = isCommandHelpInvocation(commandArgs);
|
|
2165
2764
|
// PB-T1/PB-T2 — Quota snapshot store + consumption sink.
|
|
2166
2765
|
//
|
|
2167
2766
|
// Constructed once here so `buildHandlerDeps` can close over
|
|
@@ -2314,6 +2913,21 @@ export async function main(args, dependencies) {
|
|
|
2314
2913
|
return getErrorExitCode(error);
|
|
2315
2914
|
}
|
|
2316
2915
|
}
|
|
2916
|
+
// `history` is credential-free (reads only the artifacts store; no
|
|
2917
|
+
// Provider resolution, no Adapter, no transport — the same
|
|
2918
|
+
// short-circuit class as `usage`/`cache`). Dispatching before config
|
|
2919
|
+
// load keeps a corrupt config.json from blocking the inventory.
|
|
2920
|
+
// Fail-open log reads mean a missing/corrupt store still exits 0 with
|
|
2921
|
+
// an empty inventory (save-artifacts T5, DESIGN D7).
|
|
2922
|
+
if (command === "history") {
|
|
2923
|
+
try {
|
|
2924
|
+
return await handleHistory(commandArgs, outputMode, buildHandlerDeps(env, envSecrets, true));
|
|
2925
|
+
}
|
|
2926
|
+
catch (error) {
|
|
2927
|
+
invocation.writeStderr(formatErrorOutput(error, outputMode, envSecrets));
|
|
2928
|
+
return getErrorExitCode(error);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2317
2931
|
// `init` manages config itself (it inspects + writes via the T1
|
|
2318
2932
|
// primitives, never reads the resolvedEnv the credentialed path
|
|
2319
2933
|
// produces). Short-circuit before the credentialed config load so a
|
|
@@ -2357,6 +2971,28 @@ export async function main(args, dependencies) {
|
|
|
2357
2971
|
return getErrorExitCode(error);
|
|
2358
2972
|
}
|
|
2359
2973
|
}
|
|
2974
|
+
// save-artifacts T3 — pre-dispatch guards (DESIGN D6 step 2). The save
|
|
2975
|
+
// request only exists for a save-capable command with `--save` present;
|
|
2976
|
+
// every other command (capable-but-no-save, non-capable) already had the
|
|
2977
|
+
// `--save*` flags stripped in extraction and proceeds untouched. The
|
|
2978
|
+
// guards run BEFORE the credentialed config load and any provider or
|
|
2979
|
+
// network work: a refused overwrite must not spend the run. Empty stdout
|
|
2980
|
+
// is preserved by construction — nothing has written stdout at this
|
|
2981
|
+
// point, and the guard surfaces a FILE_ERROR envelope via stderr only.
|
|
2982
|
+
// `saveRequest` is the typed seam ticket T4 consumes to perform the
|
|
2983
|
+
// actual write; in T3 it stays inert (nothing on this path writes).
|
|
2984
|
+
const saveRequest = extracted.save !== undefined && SAVE_CAPABLE_COMMANDS.has(command)
|
|
2985
|
+
? extracted.save
|
|
2986
|
+
: undefined;
|
|
2987
|
+
if (saveRequest !== undefined && !isHelpInvocation) {
|
|
2988
|
+
try {
|
|
2989
|
+
await assertExportTargetAcceptable(saveRequest);
|
|
2990
|
+
}
|
|
2991
|
+
catch (error) {
|
|
2992
|
+
invocation.writeStderr(formatErrorOutput(error, outputMode, envSecrets));
|
|
2993
|
+
return getErrorExitCode(error);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2360
2996
|
// Credentialed path: load the config file (T2a — Plan A). T3b makes
|
|
2361
2997
|
// the load TOLERANT so command help remains usable under a corrupt
|
|
2362
2998
|
// config (review item 9: "do not force a credential check merely to
|
|
@@ -2373,7 +3009,6 @@ export async function main(args, dependencies) {
|
|
|
2373
3009
|
// error's help points at `init` as the recovery path.
|
|
2374
3010
|
// Observational commands do NOT bypass the corrupt refuse — they
|
|
2375
3011
|
// still need `resolvedEnv` (which needs config) to probe/report.
|
|
2376
|
-
const isHelpInvocation = isCommandHelpInvocation(commandArgs);
|
|
2377
3012
|
const isObservational = OBSERVATIONAL_COMMANDS.has(command);
|
|
2378
3013
|
let config;
|
|
2379
3014
|
if (loadScoutlineConfig) {
|
|
@@ -2571,37 +3206,53 @@ export async function main(args, dependencies) {
|
|
|
2571
3206
|
const handlerDepsWithSelection = quotaState === undefined
|
|
2572
3207
|
? handlerDepsWithVerification
|
|
2573
3208
|
: { ...handlerDepsWithVerification, quotaState };
|
|
3209
|
+
// save-artifacts T4 - build the save wiring once, only when a save will
|
|
3210
|
+
// actually happen (save-capable command + --save, never a help
|
|
3211
|
+
// invocation: help is documentation, not a run). The wrapped descriptors
|
|
3212
|
+
// flow ONLY into handler execution; quota refresh and every other
|
|
3213
|
+
// consumer keep the original list. Without a save this is the identical
|
|
3214
|
+
// deps object and the whole path is byte-identical to pre-T4.
|
|
3215
|
+
const saveWiring = saveRequest === undefined || isHelpInvocation
|
|
3216
|
+
? undefined
|
|
3217
|
+
: buildSaveWiring(saveRequest, providerDescriptors);
|
|
3218
|
+
const handlerDepsWithSave = saveWiring === undefined
|
|
3219
|
+
? handlerDepsWithSelection
|
|
3220
|
+
: {
|
|
3221
|
+
...handlerDepsWithSelection,
|
|
3222
|
+
providerDescriptors: saveWiring.descriptors,
|
|
3223
|
+
save: saveWiring.input,
|
|
3224
|
+
};
|
|
2574
3225
|
let exitCode;
|
|
2575
3226
|
let commandRecognized = false;
|
|
2576
3227
|
try {
|
|
2577
3228
|
switch (command) {
|
|
2578
3229
|
case "vision":
|
|
2579
3230
|
commandRecognized = true;
|
|
2580
|
-
exitCode = await handleVision(commandArgs, outputMode,
|
|
3231
|
+
exitCode = await handleVision(commandArgs, outputMode, handlerDepsWithSave);
|
|
2581
3232
|
break;
|
|
2582
3233
|
case "search":
|
|
2583
3234
|
commandRecognized = true;
|
|
2584
|
-
exitCode = await handleSearch(commandArgs, outputMode,
|
|
3235
|
+
exitCode = await handleSearch(commandArgs, outputMode, handlerDepsWithSave);
|
|
2585
3236
|
break;
|
|
2586
3237
|
case "read":
|
|
2587
3238
|
commandRecognized = true;
|
|
2588
|
-
exitCode = await handleRead(commandArgs, outputMode,
|
|
3239
|
+
exitCode = await handleRead(commandArgs, outputMode, handlerDepsWithSave);
|
|
2589
3240
|
break;
|
|
2590
3241
|
case "crawl":
|
|
2591
3242
|
commandRecognized = true;
|
|
2592
|
-
exitCode = await handleCrawl(commandArgs, outputMode,
|
|
3243
|
+
exitCode = await handleCrawl(commandArgs, outputMode, handlerDepsWithSave);
|
|
2593
3244
|
break;
|
|
2594
3245
|
case "map":
|
|
2595
3246
|
commandRecognized = true;
|
|
2596
|
-
exitCode = await handleMap(commandArgs, outputMode,
|
|
3247
|
+
exitCode = await handleMap(commandArgs, outputMode, handlerDepsWithSave);
|
|
2597
3248
|
break;
|
|
2598
3249
|
case "research":
|
|
2599
3250
|
commandRecognized = true;
|
|
2600
|
-
exitCode = await handleResearch(commandArgs, outputMode,
|
|
3251
|
+
exitCode = await handleResearch(commandArgs, outputMode, handlerDepsWithSave);
|
|
2601
3252
|
break;
|
|
2602
3253
|
case "repo":
|
|
2603
3254
|
commandRecognized = true;
|
|
2604
|
-
exitCode = await handleRepo(commandArgs, outputMode,
|
|
3255
|
+
exitCode = await handleRepo(commandArgs, outputMode, handlerDepsWithSave);
|
|
2605
3256
|
break;
|
|
2606
3257
|
case "batch":
|
|
2607
3258
|
commandRecognized = true;
|