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/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 { ValidationError, UnsupportedCapabilityError, getErrorExitCode, } from "./lib/errors.js";
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 { outputFormat, forcePretty, forceRaw, provider, noFallback, rest };
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, handlerDepsWithSelection);
3231
+ exitCode = await handleVision(commandArgs, outputMode, handlerDepsWithSave);
2581
3232
  break;
2582
3233
  case "search":
2583
3234
  commandRecognized = true;
2584
- exitCode = await handleSearch(commandArgs, outputMode, handlerDepsWithSelection);
3235
+ exitCode = await handleSearch(commandArgs, outputMode, handlerDepsWithSave);
2585
3236
  break;
2586
3237
  case "read":
2587
3238
  commandRecognized = true;
2588
- exitCode = await handleRead(commandArgs, outputMode, handlerDepsWithSelection);
3239
+ exitCode = await handleRead(commandArgs, outputMode, handlerDepsWithSave);
2589
3240
  break;
2590
3241
  case "crawl":
2591
3242
  commandRecognized = true;
2592
- exitCode = await handleCrawl(commandArgs, outputMode, handlerDepsWithSelection);
3243
+ exitCode = await handleCrawl(commandArgs, outputMode, handlerDepsWithSave);
2593
3244
  break;
2594
3245
  case "map":
2595
3246
  commandRecognized = true;
2596
- exitCode = await handleMap(commandArgs, outputMode, handlerDepsWithSelection);
3247
+ exitCode = await handleMap(commandArgs, outputMode, handlerDepsWithSave);
2597
3248
  break;
2598
3249
  case "research":
2599
3250
  commandRecognized = true;
2600
- exitCode = await handleResearch(commandArgs, outputMode, handlerDepsWithSelection);
3251
+ exitCode = await handleResearch(commandArgs, outputMode, handlerDepsWithSave);
2601
3252
  break;
2602
3253
  case "repo":
2603
3254
  commandRecognized = true;
2604
- exitCode = await handleRepo(commandArgs, outputMode, handlerDepsWithSelection);
3255
+ exitCode = await handleRepo(commandArgs, outputMode, handlerDepsWithSave);
2605
3256
  break;
2606
3257
  case "batch":
2607
3258
  commandRecognized = true;