@taurgis/bonsai 3.0.3 → 3.2.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 CHANGED
@@ -112,6 +112,7 @@ Common flags:
112
112
  * `--dry-run`: Scrape and validate without writing to cache.
113
113
  * `--allow-stale`: Serve stale cache if the remote site is offline.
114
114
  * `--rendered`: Use browser-rendered extraction for SPA pages.
115
+ * `--read-only` (alias `--plan`): Block all filesystem writes/deletes across every command; network fetches still run. See [Read-only / plan mode](#read-only--plan-mode) below.
115
116
  * `--json`: Return structured machine-readable output.
116
117
 
117
118
  ### `import [url]`
@@ -148,7 +149,7 @@ npx @taurgis/bonsai list [flags]
148
149
 
149
150
  ### `prune`
150
151
 
151
- Delete old or inactive cache entries. Requires a filter (`--older-than`, `--inactive`, `--artifact-type`, or `--url`) and exactly one of `--dry-run` or `--yes` (passing both is a usage error).
152
+ Delete old or inactive cache entries. Requires a filter (`--older-than`, `--inactive`, `--artifact-type`, or `--url`) and exactly one of `--dry-run` or `--yes` (passing both is a usage error). In [read-only mode](#read-only--plan-mode), `--yes` is rejected and the safety check is implicitly satisfied by a preview.
152
153
 
153
154
  ```bash
154
155
  npx @taurgis/bonsai prune --older-than 90d --dry-run
@@ -170,6 +171,18 @@ npx @taurgis/bonsai config set summary balanced
170
171
 
171
172
  Both keys can also be overridden per-invocation via the `BONSAI_STORAGE` and `BONSAI_SUMMARY` environment variables. Project config is stored in `.bonsai.json`; project cache files are stored under `.bonsai/research/`.
172
173
 
174
+ ### Read-only / plan mode
175
+
176
+ Every command accepts a global `--read-only` flag (alias `--plan`) that blocks all filesystem writes and deletes — cache persistence, config file writes, `prune` deletions — while still allowing network fetches to run. This is for agent harnesses (Claude Code and others) that enter a read-only "plan mode": set it once and every `bonsai` invocation for the rest of that session automatically skips writes, without needing the flag on every call.
177
+
178
+ ```bash
179
+ BONSAI_READ_ONLY=1 npx @taurgis/bonsai https://example.com/docs
180
+ npx @taurgis/bonsai import https://example.com/docs --file notes.md --read-only
181
+ npx @taurgis/bonsai prune --older-than 90d --plan
182
+ ```
183
+
184
+ It can also be set via the `BONSAI_READ_ONLY` or `BONSAI_PLAN_MODE` environment variables (both are equivalent). Composition is OR, not override: read-only mode is active if *either* env var or the flag is set, and there is no way to force writes back on once one of them is — this is a safety gate, not a preference. Commands that already expose `--dry-run` (`fetch`, `prune`, `config set`/`unset`) treat it the same as an explicit `--dry-run`; `import` (which has no `--dry-run` of its own) previews the write and reports `dryRun: true` instead of persisting.
185
+
173
186
  ---
174
187
 
175
188
  ## Shell Autocomplete
@@ -273,6 +286,7 @@ When run with `--json`, commands return a stable envelope:
273
286
  | --- | --- |
274
287
  | `BONSAI_STORAGE` | Override the default storage location per-invocation (`global` or `project`). |
275
288
  | `BONSAI_SUMMARY` | Override the summary compression level per-invocation (`conservative`, `balanced`, or `aggressive`). |
289
+ | `BONSAI_READ_ONLY` / `BONSAI_PLAN_MODE` | Set to `1`/`true`/`yes` to enable [read-only/plan mode](#read-only--plan-mode) for every command in the session, equivalent to passing `--read-only` (`--plan`) each time. Either variable is sufficient; there is no way to override it back off from a single invocation. |
276
290
  | `NO_COLOR` | Set to any non-empty value to disable all ANSI color output. An empty `NO_COLOR` is treated as unset. |
277
291
  | `FORCE_COLOR` | Set to any value other than an empty string, `0`, or `false` to force ANSI color on, even in non-TTY environments. |
278
292
  | `TERM=dumb` | Disables ANSI color output when the terminal cannot render escape sequences. |
@@ -8,6 +8,15 @@ import { type ResolveResearchTargetOptions, type ResolvedResearchTarget } from '
8
8
  */
9
9
  export declare abstract class BaseCommand<T extends typeof Command> extends Command {
10
10
  static enableJsonFlag: boolean;
11
+ /**
12
+ * Flags shared by every command. Passed as `baseFlags` to `this.parse()` in `init()` below, which
13
+ * is oclif's own mechanism for merging them into `this.ctor.flags` at both parse time and
14
+ * `--help`/manifest generation — no per-command `static flags` changes are needed to expose these.
15
+ * `--plan` mirrors agent-harness "plan mode" terminology; `--read-only` is the canonical name.
16
+ */
17
+ static baseFlags: {
18
+ 'read-only': Interfaces.BooleanFlag<boolean>;
19
+ };
11
20
  flags: Interfaces.InferredFlags<T['flags']>;
12
21
  args: Interfaces.InferredArgs<T['args']>;
13
22
  parsedArgv: string[];
@@ -19,6 +28,14 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
19
28
  * JSON envelope, so machine output stays clean.
20
29
  */
21
30
  warn(input: string | Error): string | Error;
31
+ /** Whether read-only/plan mode is active for this invocation (flag OR either env var). */
32
+ protected get readOnly(): boolean;
33
+ /**
34
+ * Combines a command's own explicit dry-run intent with global read-only mode. Warns once (on
35
+ * stderr, so it never pollutes `--json`) when read-only mode is what's actually suppressing the
36
+ * write, so a skipped write is never a silent surprise to the caller.
37
+ */
38
+ protected effectiveDryRun(explicitDryRun: boolean): boolean;
22
39
  /**
23
40
  * The exit code an error maps to. oclif `CLIError`s carry it in `err.oclif.exit`; other errors may
24
41
  * expose `err.exitCode`. Defined once so the process exit code (`catch`) and the JSON envelope
@@ -1,5 +1,5 @@
1
- import { Command, Errors, toConfiguredId } from '@oclif/core';
2
- import { invalidEnvOverrideWarnings } from './lib/config/index.js';
1
+ import { Command, Errors, Flags, toConfiguredId } from '@oclif/core';
2
+ import { invalidEnvOverrideWarnings, resolveReadOnly } from './lib/config/index.js';
3
3
  import { buildEnvelope, formatErrorForJson, normalizeCliErrorMessage, stableErrorCodeFrom, } from './lib/envelope.js';
4
4
  import { resolveResearchTarget, } from './lib/research/resolve-target.js';
5
5
  import { looksLikeSchemelessUrl } from './lib/research/url.js';
@@ -11,6 +11,20 @@ import { looksLikeSchemelessUrl } from './lib/research/url.js';
11
11
  */
12
12
  export class BaseCommand extends Command {
13
13
  static enableJsonFlag = true;
14
+ /**
15
+ * Flags shared by every command. Passed as `baseFlags` to `this.parse()` in `init()` below, which
16
+ * is oclif's own mechanism for merging them into `this.ctor.flags` at both parse time and
17
+ * `--help`/manifest generation — no per-command `static flags` changes are needed to expose these.
18
+ * `--plan` mirrors agent-harness "plan mode" terminology; `--read-only` is the canonical name.
19
+ */
20
+ static baseFlags = {
21
+ 'read-only': Flags.boolean({
22
+ aliases: ['plan'],
23
+ default: false,
24
+ description: 'Block all filesystem writes/deletes (cache and config); network fetches still run. ' +
25
+ 'Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.',
26
+ }),
27
+ };
14
28
  flags;
15
29
  args;
16
30
  parsedArgv;
@@ -18,6 +32,7 @@ export class BaseCommand extends Command {
18
32
  await super.init();
19
33
  const { args, flags, argv } = await this.parse({
20
34
  flags: this.ctor.flags,
35
+ baseFlags: this.ctor.baseFlags,
21
36
  enableJsonFlag: this.ctor.enableJsonFlag,
22
37
  args: this.ctor.args,
23
38
  strict: this.ctor.strict,
@@ -41,6 +56,21 @@ export class BaseCommand extends Command {
41
56
  Errors.warn(input);
42
57
  return input;
43
58
  }
59
+ /** Whether read-only/plan mode is active for this invocation (flag OR either env var). */
60
+ get readOnly() {
61
+ return resolveReadOnly({ flag: Boolean(this.flags?.['read-only']), env: process.env });
62
+ }
63
+ /**
64
+ * Combines a command's own explicit dry-run intent with global read-only mode. Warns once (on
65
+ * stderr, so it never pollutes `--json`) when read-only mode is what's actually suppressing the
66
+ * write, so a skipped write is never a silent surprise to the caller.
67
+ */
68
+ effectiveDryRun(explicitDryRun) {
69
+ if (this.readOnly && !explicitDryRun) {
70
+ this.warn('Read-only mode active (--read-only/--plan or BONSAI_READ_ONLY/BONSAI_PLAN_MODE): skipping filesystem writes.');
71
+ }
72
+ return explicitDryRun || this.readOnly;
73
+ }
44
74
  /**
45
75
  * The exit code an error maps to. oclif `CLIError`s carry it in `err.oclif.exit`; other errors may
46
76
  * expose `err.exitCode`. Defined once so the process exit code (`catch`) and the JSON envelope
@@ -51,7 +51,7 @@ export default class ConfigSet extends ConfigCommand {
51
51
  }
52
52
  const scope = this.writeScope(this.flags.local);
53
53
  const formatted = meta.format(parsed);
54
- if (this.flags['dry-run']) {
54
+ if (this.effectiveDryRun(this.flags['dry-run'])) {
55
55
  if (!this.jsonEnabled())
56
56
  this.log(`[dry-run] Would set ${keyArg} = ${formatted} (${scope})`);
57
57
  return { key: keyArg, value: parsed, scope, dryRun: true };
@@ -33,7 +33,7 @@ export default class ConfigUnset extends ConfigCommand {
33
33
  const key = this.args.key;
34
34
  this.validateConfigKeyAndScope(key, this.flags.global, this.flags.local);
35
35
  const scope = this.writeScope(this.flags.local);
36
- if (this.flags['dry-run']) {
36
+ if (this.effectiveDryRun(this.flags['dry-run'])) {
37
37
  if (!this.jsonEnabled())
38
38
  this.log(`[dry-run] Would unset ${key} (${scope})`);
39
39
  return { key, scope, dryRun: true };
@@ -15,6 +15,7 @@ import { capturePage } from '../lib/research/capture.js';
15
15
  import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
16
16
  import { applyAutoTags } from '../lib/research/keywords.js';
17
17
  import { detectSite } from '../sites/index.js';
18
+ import { applySiteFetchProvenance } from '../sites/types.js';
18
19
  const CAPTURE_DEPS = {
19
20
  fetchStatic: (url) => fetchStaticHtml(url),
20
21
  fetchRendered: (url) => fetchRenderedHtml(url),
@@ -124,9 +125,11 @@ export default class FetchCommand extends BaseCommand {
124
125
  let fetchResult;
125
126
  let extraction;
126
127
  let capture = null;
128
+ let siteFetch = null;
127
129
  if (siteModule?.fetchPage) {
128
130
  // Custom site modules own their fetch/extract strategy; the generic capture path is skipped.
129
- ({ fetchResult, extraction } = await siteModule.fetchPage(normalizedUrl));
131
+ siteFetch = await siteModule.fetchPage(normalizedUrl);
132
+ ({ fetchResult, extraction } = siteFetch);
130
133
  }
131
134
  else {
132
135
  capture = await capturePage(normalizedUrl, { forceRendered: useRendered }, CAPTURE_DEPS);
@@ -140,6 +143,9 @@ export default class FetchCommand extends BaseCommand {
140
143
  if (capture) {
141
144
  applyCaptureMetadata(artifact, capture);
142
145
  }
146
+ else if (siteFetch?.captureMethod) {
147
+ applySiteFetchProvenance(artifact.metadata, siteFetch);
148
+ }
143
149
  else if (useRendered) {
144
150
  artifact.metadata.capture_method = 'browser_fallback';
145
151
  }
@@ -264,8 +270,9 @@ export default class FetchCommand extends BaseCommand {
264
270
  }
265
271
  async run() {
266
272
  const urls = this.parsedArgv;
267
- const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
273
+ const { format, ttl, 'max-age': maxAge } = this.flags;
268
274
  this.validateDurationFlags(ttl, maxAge);
275
+ const dryRun = this.effectiveDryRun(this.flags['dry-run']);
269
276
  const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
270
277
  const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
271
278
  const currentTime = new Date();
@@ -343,13 +343,16 @@ export default class ResearchImport extends BaseCommand {
343
343
  dataDir: this.config.dataDir,
344
344
  flagOverride: this.flags.storage,
345
345
  });
346
- const writeResult = writeArtifactSecurely(roots, cacheKey, artifact);
346
+ const dryRun = this.effectiveDryRun(false);
347
+ const writeResult = writeArtifactSecurely(roots, cacheKey, artifact, { dryRun });
347
348
  const storagePath = getArtifactPath(writeResult.dataDir, cacheKey);
348
349
  if (writeResult.redirected) {
349
350
  this.warn(`Detected ${writeResult.secretLabel} in the imported content; stored in the global cache instead of the project to avoid committing secrets.`);
350
351
  }
351
352
  if (!this.jsonEnabled()) {
352
- this.log(`Successfully imported research artifact.`);
353
+ this.log(dryRun
354
+ ? `[dry-run] Would import research artifact.`
355
+ : `Successfully imported research artifact.`);
353
356
  this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
354
357
  this.log(`${'Storage Path:'.padEnd(25)} ${storagePath}`);
355
358
  }
@@ -357,9 +360,10 @@ export default class ResearchImport extends BaseCommand {
357
360
  return {
358
361
  schemaVersion: 1,
359
362
  command: 'import',
363
+ dryRun,
360
364
  cache: {
361
365
  key: cacheKey,
362
- status: 'imported',
366
+ status: dryRun ? 'would_import' : 'imported',
363
367
  freshness: 'fresh',
364
368
  path: storagePath,
365
369
  storage: roots.mode,
@@ -68,10 +68,21 @@ export default class ResearchPrune extends BaseCommand {
68
68
  ],
69
69
  });
70
70
  }
71
+ // Checked before the generic --dry-run/--yes conflict below so a combination like
72
+ // `--dry-run --yes --read-only` reports the more specific READ_ONLY_MODE code, not
73
+ // CONFLICTING_FLAGS — read-only mode is the more fundamental constraint being violated.
74
+ if (this.readOnly && this.flags.yes) {
75
+ this.error('--yes cannot be used while read-only mode is active (--read-only/--plan or BONSAI_READ_ONLY/BONSAI_PLAN_MODE): mutations are disabled.', {
76
+ exit: 2,
77
+ code: 'READ_ONLY_MODE',
78
+ suggestions: [`Preview instead: ${this.config.bin} prune --dry-run`],
79
+ });
80
+ }
71
81
  if (this.flags['dry-run'] && this.flags.yes) {
72
82
  this.error('--dry-run and --yes are mutually exclusive: --dry-run previews without deleting, --yes confirms deletion. Choose one.', { exit: 2, code: 'CONFLICTING_FLAGS' });
73
83
  }
74
- if (!this.flags['dry-run'] && !this.flags.yes) {
84
+ // Read-only mode implicitly previews, so the usual "pick one" safety check is redundant here.
85
+ if (!this.readOnly && !this.flags['dry-run'] && !this.flags.yes) {
75
86
  const olderThanPart = this.flags['older-than']
76
87
  ? ` --older-than ${this.flags['older-than']}`
77
88
  : '';
@@ -155,7 +166,7 @@ export default class ResearchPrune extends BaseCommand {
155
166
  });
156
167
  const currentTime = new Date();
157
168
  const filesToPrune = this.findPruneCandidates(roots.readRoots, currentTime);
158
- const dryRun = this.flags['dry-run'];
169
+ const dryRun = this.effectiveDryRun(this.flags['dry-run']);
159
170
  const count = filesToPrune.length;
160
171
  // Track deletions actually performed so the JSON envelope and human output agree even when an
161
172
  // unlink fails (e.g. a permission error). Reporting candidate count as `prunedCount` would
@@ -1,6 +1,6 @@
1
1
  export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
2
  export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
- export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
3
+ export { resolveStorageMode, resolveSummaryLevel, resolveReadOnly, parseEnvStorage, parseEnvSummary, parseEnvBoolean, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, READ_ONLY_ENV_VAR, PLAN_MODE_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, type ResolveReadOnlyInput, } from './resolve.js';
4
4
  import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
5
5
  /**
6
6
  * Read both config files once and resolve the effective storage mode.
@@ -1,6 +1,6 @@
1
1
  export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
2
  export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
- export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
3
+ export { resolveStorageMode, resolveSummaryLevel, resolveReadOnly, parseEnvStorage, parseEnvSummary, parseEnvBoolean, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, READ_ONLY_ENV_VAR, PLAN_MODE_ENV_VAR, } from './resolve.js';
4
4
  import { readUserConfig, readProjectConfig } from './io.js';
5
5
  import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
6
6
  /**
@@ -2,6 +2,15 @@ import type { ConfigValues, StorageMode, SummaryLevel } from './schema.js';
2
2
  /** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
3
3
  export declare const STORAGE_ENV_VAR = "BONSAI_STORAGE";
4
4
  export declare const SUMMARY_ENV_VAR = "BONSAI_SUMMARY";
5
+ /** Two names for the same safety switch: `PLAN_MODE` mirrors agent-harness "plan mode" terminology. */
6
+ export declare const READ_ONLY_ENV_VAR = "BONSAI_READ_ONLY";
7
+ export declare const PLAN_MODE_ENV_VAR = "BONSAI_PLAN_MODE";
8
+ /**
9
+ * Parses a boolean-ish env value. Empty/unset is `undefined` (not present); an unrecognized
10
+ * non-empty value is also `undefined` (invalid — see {@link invalidEnvOverrideWarnings}), so a
11
+ * typo never silently reads as `true`.
12
+ */
13
+ export declare function parseEnvBoolean(raw: string | undefined): boolean | undefined;
5
14
  /**
6
15
  * Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
7
16
  * dropped during resolution (falling back to config/default), which would otherwise silently mask
@@ -33,3 +42,15 @@ export interface ResolveSummaryInput {
33
42
  * flag > env > project file > user file > built-in default.
34
43
  */
35
44
  export declare function resolveSummaryLevel(input: ResolveSummaryInput): SummaryLevel;
45
+ export interface ResolveReadOnlyInput {
46
+ /** True when `--read-only`/`--plan` was passed on this invocation. */
47
+ flag: boolean;
48
+ env: Record<string, string | undefined>;
49
+ }
50
+ /**
51
+ * Resolve whether read-only (plan) mode is active. Unlike storage/summary, this is a safety gate
52
+ * rather than a preference: composition is OR, not override-by-precedence — if the flag or either
53
+ * env var says read-only, the run is read-only. There is no way to force writes back on when an
54
+ * ambient `BONSAI_READ_ONLY`/`BONSAI_PLAN_MODE` is set, by design.
55
+ */
56
+ export declare function resolveReadOnly(input: ResolveReadOnlyInput): boolean;
@@ -2,6 +2,26 @@ import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
2
2
  /** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
3
3
  export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
4
4
  export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
5
+ /** Two names for the same safety switch: `PLAN_MODE` mirrors agent-harness "plan mode" terminology. */
6
+ export const READ_ONLY_ENV_VAR = 'BONSAI_READ_ONLY';
7
+ export const PLAN_MODE_ENV_VAR = 'BONSAI_PLAN_MODE';
8
+ const BOOLEAN_TRUE_VALUES = ['1', 'true', 'yes'];
9
+ const BOOLEAN_FALSE_VALUES = ['0', 'false', 'no'];
10
+ /**
11
+ * Parses a boolean-ish env value. Empty/unset is `undefined` (not present); an unrecognized
12
+ * non-empty value is also `undefined` (invalid — see {@link invalidEnvOverrideWarnings}), so a
13
+ * typo never silently reads as `true`.
14
+ */
15
+ export function parseEnvBoolean(raw) {
16
+ const v = raw?.trim().toLowerCase();
17
+ if (!v)
18
+ return undefined;
19
+ if (BOOLEAN_TRUE_VALUES.includes(v))
20
+ return true;
21
+ if (BOOLEAN_FALSE_VALUES.includes(v))
22
+ return false;
23
+ return undefined;
24
+ }
5
25
  /**
6
26
  * Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
7
27
  * dropped during resolution (falling back to config/default), which would otherwise silently mask
@@ -9,18 +29,36 @@ export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
9
29
  * is visible without breaking the run. Returns one message per offending variable, or empty.
10
30
  */
11
31
  export function invalidEnvOverrideWarnings(env) {
32
+ // Each check names the var, how to tell a value is invalid, and the hint to show when it is.
12
33
  const checks = [
13
- [STORAGE_ENV_VAR, STORAGE_MODES],
14
- [SUMMARY_ENV_VAR, SUMMARY_LEVELS],
34
+ [
35
+ STORAGE_ENV_VAR,
36
+ (raw) => STORAGE_MODES.includes(raw),
37
+ `not one of ${STORAGE_MODES.join(', ')}. Using the configured value or default instead.`,
38
+ ],
39
+ [
40
+ SUMMARY_ENV_VAR,
41
+ (raw) => SUMMARY_LEVELS.includes(raw),
42
+ `not one of ${SUMMARY_LEVELS.join(', ')}. Using the configured value or default instead.`,
43
+ ],
44
+ [
45
+ READ_ONLY_ENV_VAR,
46
+ (raw) => parseEnvBoolean(raw) !== undefined,
47
+ 'expected 1/true/yes or 0/false/no. Read-only mode was not toggled by this value.',
48
+ ],
49
+ [
50
+ PLAN_MODE_ENV_VAR,
51
+ (raw) => parseEnvBoolean(raw) !== undefined,
52
+ 'expected 1/true/yes or 0/false/no. Read-only mode was not toggled by this value.',
53
+ ],
15
54
  ];
16
55
  const warnings = [];
17
- for (const [name, valid] of checks) {
56
+ for (const [name, isValid, hint] of checks) {
18
57
  // After trim, an empty/whitespace-only value is falsy and treated as "unset" (no warning),
19
58
  // matching parseEnv*; matching is case-sensitive, so a wrong-case value is invalid and warns.
20
59
  const raw = env[name]?.trim();
21
- if (raw && !valid.includes(raw)) {
22
- warnings.push(`Ignoring ${name}="${raw}": not one of ${valid.join(', ')}. ` +
23
- `Using the configured value or default instead.`);
60
+ if (raw && !isValid(raw)) {
61
+ warnings.push(`Ignoring ${name}="${raw}": ${hint}`);
24
62
  }
25
63
  }
26
64
  return warnings;
@@ -59,3 +97,14 @@ export function resolveSummaryLevel(input) {
59
97
  input.userConfig.summary ??
60
98
  BUILT_IN_DEFAULTS.summary);
61
99
  }
100
+ /**
101
+ * Resolve whether read-only (plan) mode is active. Unlike storage/summary, this is a safety gate
102
+ * rather than a preference: composition is OR, not override-by-precedence — if the flag or either
103
+ * env var says read-only, the run is read-only. There is no way to force writes back on when an
104
+ * ambient `BONSAI_READ_ONLY`/`BONSAI_PLAN_MODE` is set, by design.
105
+ */
106
+ export function resolveReadOnly(input) {
107
+ return Boolean(input.flag ||
108
+ parseEnvBoolean(input.env[READ_ONLY_ENV_VAR]) ||
109
+ parseEnvBoolean(input.env[PLAN_MODE_ENV_VAR]));
110
+ }
@@ -127,6 +127,9 @@ export function buildChromeArgs() {
127
127
  ...getChromeTlsCompatibilityArgs(),
128
128
  ];
129
129
  }
130
+ // CI runners occasionally starve headless Chrome's startup (disk/CPU contention from concurrent
131
+ // jobs), so this is generous enough to absorb that rather than a "typical" local startup time.
132
+ const CHROME_STARTUP_TIMEOUT_MS = 20_000;
130
133
  async function spawnChrome(chromePath) {
131
134
  const chromeProcess = spawn(chromePath, buildChromeArgs());
132
135
  try {
@@ -135,7 +138,7 @@ async function spawnChrome(chromePath) {
135
138
  const timeoutId = setTimeout(() => {
136
139
  chromeProcess.kill('SIGKILL');
137
140
  reject(new Error('Timed out waiting for Chrome to start.'));
138
- }, 10000);
141
+ }, CHROME_STARTUP_TIMEOUT_MS);
139
142
  const onData = (data) => {
140
143
  output += data.toString();
141
144
  const match = output.match(/ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\/[a-zA-Z0-9-]+/);
@@ -363,9 +366,17 @@ export function describeNavigationFailure(errorText, url) {
363
366
  }
364
367
  // Chrome can emit these on first navigation in headless CI when the cert verifier reloads mid-run.
365
368
  const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED'];
366
- function isTransientBrowserNavigationError(err) {
369
+ // Resource contention in CI (concurrent jobs competing for CPU/disk) can make Chrome itself fail
370
+ // to come up in time, or die before its devtools websocket URL is printed. Neither reflects a
371
+ // problem with the target page, so it's worth a fresh attempt rather than failing the whole fetch.
372
+ const TRANSIENT_CHROME_STARTUP_MESSAGES = [
373
+ 'Timed out waiting for Chrome to start.',
374
+ 'Chrome exited prematurely',
375
+ ];
376
+ function isTransientBrowserError(err) {
367
377
  const message = err instanceof Error ? err.message : String(err);
368
- return TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code));
378
+ return (TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code)) ||
379
+ TRANSIENT_CHROME_STARTUP_MESSAGES.some((text) => message.includes(text)));
369
380
  }
370
381
  async function fetchRenderedHtmlOnce(url, options = {}) {
371
382
  const timeout = options.timeoutMs ?? 15_000;
@@ -432,7 +443,7 @@ export async function fetchRenderedHtml(url, options = {}) {
432
443
  return await fetchRenderedHtmlOnce(url, options);
433
444
  }
434
445
  catch (err) {
435
- if (attempt === maxAttempts || !isTransientBrowserNavigationError(err))
446
+ if (attempt === maxAttempts || !isTransientBrowserError(err))
436
447
  throw err;
437
448
  }
438
449
  }
@@ -9,6 +9,13 @@ export declare function parseFrontmatter(md: string): ParsedFrontmatter;
9
9
  * handler attributes, and javascript: URLs. Leaves ordinary Markdown and benign inline HTML intact.
10
10
  */
11
11
  export declare function sanitizeSourceMarkdown(md: string): string;
12
+ /**
13
+ * Applies a cleanup transform to the parts of a document OUTSIDE fenced code blocks, passing the
14
+ * fences through verbatim. Every text-level cleanup in this pipeline must go through this guard:
15
+ * fenced code is quoted material, and "cleaning" it destroys the samples readers came for
16
+ * (a `<script>` line in an example, a bash `# comment`, a literal `javascript:` URL).
17
+ */
18
+ export declare function outsideFences(md: string, transform: (segment: string) => string): string;
12
19
  /**
13
20
  * Builds an ExtractionResult from public Markdown/MDX source. Frontmatter is parsed for the title
14
21
  * and the body is sanitized. The detailed Markdown is the source itself, preserving code fences,
@@ -98,28 +98,31 @@ function replaceMacro(_match, inner) {
98
98
  function stripMdnHiddenSamples(md) {
99
99
  return md.replace(/<pre\b[^>]*\bhidden\b[^>]*>[\s\S]*?<\/pre>/gi, '');
100
100
  }
101
- function cleanKumaScript(md) {
102
- // Never touch fenced code blocks.
101
+ /**
102
+ * Applies a cleanup transform to the parts of a document OUTSIDE fenced code blocks, passing the
103
+ * fences through verbatim. Every text-level cleanup in this pipeline must go through this guard:
104
+ * fenced code is quoted material, and "cleaning" it destroys the samples readers came for
105
+ * (a `<script>` line in an example, a bash `# comment`, a literal `javascript:` URL).
106
+ */
107
+ export function outsideFences(md, transform) {
103
108
  return md
104
109
  .split(/(```[\s\S]*?```)/)
105
- .map((segment, i) => (i % 2 === 1 ? segment : segment.replace(/\{\{(.+?)\}\}/g, replaceMacro)))
110
+ .map((segment, i) => (i % 2 === 1 ? segment : transform(segment)))
106
111
  .join('');
107
112
  }
113
+ function cleanKumaScript(md) {
114
+ return outsideFences(md, (segment) => segment.replace(/\{\{(.+?)\}\}/g, replaceMacro));
115
+ }
108
116
  // MDN (and occasionally other docs) author tables as raw HTML inside their Markdown source, which
109
117
  // otherwise leaks verbatim into the artifact and wastes agent tokens. Turndown already emits clean
110
118
  // GFM tables, so convert any literal <table> region to Markdown. Code fences are skipped so an HTML
111
119
  // table shown as a *code example* (```html …<table>…) is preserved, not rewritten.
112
120
  function convertHtmlTables(md) {
113
- return md
114
- .split(/(```[\s\S]*?```)/)
115
- .map((segment, i) => i % 2 === 1
116
- ? segment
117
- : // ponytail: non-greedy per-table match. A <table> nested in a cell would stop the match at
118
- // the inner </table>, leaving the outer wrapper's closing tags as inert residue (no attrs,
119
- // so it can't trip the leak gate). Doc sources use flat tables; upgrade to a DOM walk over
120
- // the whole segment via htmlToMarkdown if a nested-table source ever appears.
121
- segment.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlToMarkdown(table).trim()}\n\n`))
122
- .join('');
121
+ // ponytail: non-greedy per-table match. A <table> nested in a cell would stop the match at
122
+ // the inner </table>, leaving the outer wrapper's closing tags as inert residue (no attrs,
123
+ // so it can't trip the leak gate). Doc sources use flat tables; upgrade to a DOM walk over
124
+ // the whole segment via htmlToMarkdown if a nested-table source ever appears.
125
+ return outsideFences(md, (segment) => segment.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlToMarkdown(table).trim()}\n\n`));
123
126
  }
124
127
  const HEADING = /^(#{1,6})\s/;
125
128
  function headingLevel(line) {
@@ -140,17 +143,37 @@ function dropEmptySections(md) {
140
143
  } while (current !== prev);
141
144
  return current;
142
145
  }
146
+ const FENCE_DELIMITER = /^\s*```/;
147
+ // Marks each line that belongs to fenced code, delimiters included. Fenced lines are content,
148
+ // never headings: a bash `# comment` in an example must not be classified — and deleted — as an
149
+ // empty section.
150
+ function fencedLineMask(lines) {
151
+ let fenced = false;
152
+ return lines.map((line) => {
153
+ if (!FENCE_DELIMITER.test(line))
154
+ return fenced;
155
+ fenced = !fenced;
156
+ return true;
157
+ });
158
+ }
159
+ /** Index of the first non-blank line after `start`, or lines.length. */
160
+ function nextContentLine(lines, start) {
161
+ let next = start + 1;
162
+ while (next < lines.length && lines[next].trim() === '')
163
+ next++;
164
+ return next;
165
+ }
143
166
  function dropEmptySectionsOnce(md) {
144
167
  const lines = md.split('\n');
168
+ const inFence = fencedLineMask(lines);
145
169
  const keep = lines.map(() => true);
146
170
  for (let i = 0; i < lines.length; i++) {
147
- const level = headingLevel(lines[i]);
171
+ const level = inFence[i] ? 0 : headingLevel(lines[i]);
148
172
  if (!level)
149
173
  continue;
150
- let next = i + 1;
151
- while (next < lines.length && lines[next].trim() === '')
152
- next++;
153
- const nextLevel = headingLevel(lines[next]);
174
+ const next = nextContentLine(lines, i);
175
+ // A fenced line after the heading means the section has content (nextLevel 0 → not empty).
176
+ const nextLevel = next < lines.length && !inFence[next] ? headingLevel(lines[next]) : 0;
154
177
  const sectionIsEmpty = next >= lines.length || (nextLevel > 0 && nextLevel <= level);
155
178
  if (sectionIsEmpty)
156
179
  for (let k = i; k < next; k++)
@@ -183,8 +206,11 @@ export function extractFromSource(md, sourceUrl) {
183
206
  const deMacroed = /mdn\/content/.test(sourceUrl)
184
207
  ? cleanKumaScript(stripMdnHiddenSamples(body))
185
208
  : body;
186
- const tablesAsMarkdown = convertHtmlTables(sanitizeSourceMarkdown(deMacroed));
187
- const cleaned = sanitizePromptInjection(dropEmptySections(dropEmptyLinks(tablesAsMarkdown))).trim();
209
+ // Sanitization and link/section cleanup are fence-guarded: code samples legitimately contain
210
+ // <script>/<button>/on*=/javascript: and bare [](…) lines. sanitizePromptInjection deliberately
211
+ // is NOT guarded — injection text is just as live when the agent reads it inside a code block.
212
+ const tablesAsMarkdown = convertHtmlTables(outsideFences(deMacroed, sanitizeSourceMarkdown));
213
+ const cleaned = sanitizePromptInjection(dropEmptySections(outsideFences(tablesAsMarkdown, dropEmptyLinks))).trim();
188
214
  const notes = [`captured from public Markdown/MDX source: ${sourceUrl}`];
189
215
  return {
190
216
  title: titleFrom(frontmatter, cleaned),
@@ -93,7 +93,10 @@ export function contentMetrics(markdown) {
93
93
  return {
94
94
  chars: markdown.length,
95
95
  codeBlocks: Math.floor((markdown.match(/```/g) || []).length / 2),
96
- tables: (markdown.match(/^\|(?:\s*---\s*\|)+\s*$/gm) || []).length,
96
+ // Separator rows come in every authored width and alignment (| --- |, | ------ |, | :---: |).
97
+ // Converted HTML always emits exactly ---, but route/source Markdown keeps the author's
98
+ // padding, so a width-sensitive count would misreport source captures as losing tables.
99
+ tables: (markdown.match(/^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/gm) || []).length,
97
100
  images: (markdown.match(/!\[[^\]]*\]\(/g) || []).length,
98
101
  orderedSteps: (markdown.match(/^\d+\.\s+/gm) || []).length,
99
102
  bullets: (markdown.match(/^[-*]\s+/gm) || []).length,
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { getSiteModuleById } from '../../sites/index.js';
3
+ import { applySiteFetchProvenance } from '../../sites/types.js';
3
4
  import { fetchStaticHtml } from './fetcher.js';
4
5
  import { extractHtmlContent } from './extract.js';
5
6
  import { writeArtifact } from './storage.js';
@@ -111,9 +112,13 @@ function preserveUserMetadata(source, target, rendered) {
111
112
  // Builds a refreshed artifact from a fresh fetch, carries over user metadata, persists it, and
112
113
  // returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
113
114
  // and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
114
- function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options) {
115
+ function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options, siteFetch) {
115
116
  const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime, options.summaryLevel);
116
117
  preserveUserMetadata(meta, refreshed, options.rendered);
118
+ // A site-module refresh reports how it actually captured this time; that overrides the
119
+ // carried-over provenance, so a withdrawn .md twin can't leave a stale source_doc_url behind.
120
+ if (siteFetch)
121
+ applySiteFetchProvenance(refreshed.metadata, siteFetch);
117
122
  // Back-fill keyword tags when the carried-over set is empty (e.g. an artifact first cached before
118
123
  // auto-tagging, or one originally stored without tags), so refreshing keeps it searchable.
119
124
  applyAutoTags(refreshed);
@@ -162,8 +167,8 @@ export async function revalidateCache(dataDir, existing, currentTime, options) {
162
167
  if (siteModule?.fetchPage) {
163
168
  // Site modules use custom fetch strategies that don't speak HTTP conditional requests
164
169
  // (no ETag/If-Modified-Since), so a full re-fetch is the only correct revalidation here.
165
- const { fetchResult, extraction } = await siteModule.fetchPage(meta.source_url);
166
- return persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options);
170
+ const siteFetch = await siteModule.fetchPage(meta.source_url);
171
+ return persistRefreshedArtifact(dataDir, meta, siteFetch.fetchResult, siteFetch.extraction, currentTime, options, siteFetch);
167
172
  }
168
173
  const fetchResult = options.rendered
169
174
  ? await fetchRenderedHtml(meta.source_url)
@@ -12,5 +12,11 @@ export interface SecureWriteResult {
12
12
  * Write an artifact to the configured write root, except when it contains a secret and the
13
13
  * target is a (potentially committed) project cache — those are redirected to the global cache.
14
14
  * Returns where it landed so the caller can warn the user and report the real path.
15
+ *
16
+ * `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
17
+ * actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
18
+ * about where secret-bearing content would land.
15
19
  */
16
- export declare function writeArtifactSecurely(roots: StoreRoots, key: string, artifact: ResearchArtifact): SecureWriteResult;
20
+ export declare function writeArtifactSecurely(roots: StoreRoots, key: string, artifact: ResearchArtifact, options?: {
21
+ dryRun?: boolean;
22
+ }): SecureWriteResult;
@@ -4,14 +4,16 @@ import { scanArtifactForSecret } from './secret-scan.js';
4
4
  * Write an artifact to the configured write root, except when it contains a secret and the
5
5
  * target is a (potentially committed) project cache — those are redirected to the global cache.
6
6
  * Returns where it landed so the caller can warn the user and report the real path.
7
+ *
8
+ * `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
9
+ * actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
10
+ * about where secret-bearing content would land.
7
11
  */
8
- export function writeArtifactSecurely(roots, key, artifact) {
12
+ export function writeArtifactSecurely(roots, key, artifact, options = {}) {
9
13
  const isProjectWrite = roots.writeRoot !== roots.globalRoot;
10
14
  const secretLabel = isProjectWrite ? scanArtifactForSecret(artifact) : null;
11
- if (secretLabel) {
12
- writeArtifact(roots.globalRoot, key, artifact);
13
- return { dataDir: roots.globalRoot, redirected: true, secretLabel };
14
- }
15
- writeArtifact(roots.writeRoot, key, artifact);
16
- return { dataDir: roots.writeRoot, redirected: false, secretLabel: null };
15
+ const dataDir = secretLabel ? roots.globalRoot : roots.writeRoot;
16
+ if (!options.dryRun)
17
+ writeArtifact(dataDir, key, artifact);
18
+ return { dataDir, redirected: Boolean(secretLabel), secretLabel };
17
19
  }
@@ -1,3 +1,8 @@
1
1
  import type { SiteFetchResult } from '../types.js';
2
- /** Fetches a developer.salesforce.com doc page (guides + API reference) via the shared LWR fetcher. */
2
+ /**
3
+ * Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
4
+ * derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
5
+ * browser render, and it returns the article's source. Pages without a validated twin (API
6
+ * reference, atlas.* books, landing pages) fall back to the shared LWR shadow-DOM fetcher.
7
+ */
3
8
  export declare function fetchDeveloperPage(url: string): Promise<SiteFetchResult>;
@@ -1,4 +1,5 @@
1
1
  import { fetchSalesforceDoc } from '../salesforce-doc-fetch.js';
2
+ import { fetchDeveloperRouteMarkdown } from './route-markdown.js';
2
3
  // Containers a developer doc renders into. doc-content-layout / doc-amf-reference are the
3
4
  // web-component hosts for guide pages and API-reference (AMF) pages respectively.
4
5
  const CONTENT_SELECTORS = [
@@ -11,8 +12,16 @@ const CONTENT_SELECTORS = [
11
12
  'article',
12
13
  'main',
13
14
  ];
14
- /** Fetches a developer.salesforce.com doc page (guides + API reference) via the shared LWR fetcher. */
15
+ /**
16
+ * Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
17
+ * derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
18
+ * browser render, and it returns the article's source. Pages without a validated twin (API
19
+ * reference, atlas.* books, landing pages) fall back to the shared LWR shadow-DOM fetcher.
20
+ */
15
21
  export async function fetchDeveloperPage(url) {
22
+ const fromMarkdownRoute = await fetchDeveloperRouteMarkdown(url);
23
+ if (fromMarkdownRoute)
24
+ return fromMarkdownRoute;
16
25
  return fetchSalesforceDoc(url, {
17
26
  allowedHost: 'developer.salesforce.com',
18
27
  contentSelectors: CONTENT_SELECTORS,
@@ -0,0 +1,23 @@
1
+ import { type FetchResult } from '../../lib/research/fetcher.js';
2
+ import type { SiteFetchResult } from '../types.js';
3
+ /**
4
+ * Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
5
+ * can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
6
+ * with an HTML shell, never Markdown). Query/hash are view state and are dropped.
7
+ */
8
+ export declare function deriveMarkdownUrl(url: string): string | null;
9
+ /** Removes unresolvable `::include{…}` directive lines, reporting how many were dropped. */
10
+ export declare function stripIncludeDirectives(md: string): {
11
+ body: string;
12
+ dropped: number;
13
+ };
14
+ export type RouteMarkdownFetcher = (url: string, options?: {
15
+ headers?: Record<string, string>;
16
+ timeoutMs?: number;
17
+ }) => Promise<FetchResult>;
18
+ /**
19
+ * Probes the derived `.md` route for a developer doc URL. Returns a complete SiteFetchResult
20
+ * (with route_markdown provenance) when the twin validates, or null so the caller falls back to
21
+ * the browser capture. Never throws: any network/validation failure is just "no twin".
22
+ */
23
+ export declare function fetchDeveloperRouteMarkdown(url: string, fetcher?: RouteMarkdownFetcher): Promise<SiteFetchResult | null>;
@@ -0,0 +1,142 @@
1
+ import { fetchText } from '../../lib/research/fetcher.js';
2
+ import { validateTextArtifact } from '../../lib/research/docs/validate.js';
3
+ import { extractFromSource } from '../../lib/research/docs/markdown-source.js';
4
+ // developer.salesforce.com now publishes a Markdown twin for supported doc articles (the
5
+ // "View as Markdown" link): /docs/<cloud>/<book>/guide/<article>[.html] also serves
6
+ // <article>.md with Content-Type text/markdown. The URL mapping is deterministic, so we derive
7
+ // the .md URL and probe it BEFORE the browser path — one static request replaces a ~45s
8
+ // shadow-DOM render and returns the article's actual source (code fences, admonitions intact).
9
+ //
10
+ // Coverage is rolling out per doc set, so the probe result is only a candidate (T-19/T-24):
11
+ // unsupported articles 404, and atlas.* books answer the .md route with an HTTP 200 HTML SPA
12
+ // shell — which makes the strict content-type + body validation below load-bearing, not
13
+ // defensive garnish.
14
+ const DEVELOPER_HOST = 'developer.salesforce.com';
15
+ const MARKDOWN_CONTENT_TYPE = 'text/markdown';
16
+ // Salesforce's WAF answers 403 unless the User-Agent LEADS with a recognized HTTP-tool token
17
+ // (curl/wget/python-requests pass; bare product names and browser UAs on a non-browser TLS stack
18
+ // are blocked). Lead with the compatibility token — the same convention as browsers' Mozilla/5.0
19
+ // prefix — and carry Bonsai's honest identity after it.
20
+ const PROBE_HEADERS = {
21
+ 'user-agent': 'curl/8.7.1 bonsai-research-cache (+https://bonsai.rhino-inquisitor.com)',
22
+ };
23
+ /**
24
+ * Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
25
+ * can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
26
+ * with an HTML shell, never Markdown). Query/hash are view state and are dropped.
27
+ */
28
+ export function deriveMarkdownUrl(url) {
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(url);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ if (parsed.hostname.toLowerCase() !== DEVELOPER_HOST)
37
+ return null;
38
+ let path = parsed.pathname.replace(/\/+$/, '');
39
+ if (!path.startsWith('/docs/') || path.startsWith('/docs/atlas.'))
40
+ return null;
41
+ if (path.endsWith('.md'))
42
+ return `https://${DEVELOPER_HOST}${path}`;
43
+ path = path.replace(/\.html?$/i, '');
44
+ return `https://${DEVELOPER_HOST}${path}.md`;
45
+ }
46
+ // `::include{src="../../shared/….md"}` directives reference build-time snippet files that are
47
+ // not published on the .md route (they 404), so they'd survive as inert noise in the artifact.
48
+ const INCLUDE_DIRECTIVE = /^\s*::include\{[^}]*\}\s*$/;
49
+ /** Removes unresolvable `::include{…}` directive lines, reporting how many were dropped. */
50
+ export function stripIncludeDirectives(md) {
51
+ let dropped = 0;
52
+ const body = md
53
+ .split(/(```[\s\S]*?```)/)
54
+ .map((segment, i) => {
55
+ // Fenced code is untouched: an ::include shown as a syntax example is content.
56
+ if (i % 2 === 1)
57
+ return segment;
58
+ return segment
59
+ .split('\n')
60
+ .filter((line) => {
61
+ if (!INCLUDE_DIRECTIVE.test(line))
62
+ return true;
63
+ dropped++;
64
+ return false;
65
+ })
66
+ .join('\n');
67
+ })
68
+ .join('');
69
+ return { body, dropped };
70
+ }
71
+ // A probe response counts as the Markdown twin only when the server labels it text/markdown AND
72
+ // the redirect chain ended on https at the developer host AND the body validates as non-HTML,
73
+ // non-error text. Atlas shells fail the content-type check; non-2xx statuses already threw in
74
+ // the fetcher (the probe sends no conditional headers, so a 304 can't occur either).
75
+ function isMarkdownResponse(res) {
76
+ if (!res.content)
77
+ return false;
78
+ const mediaType = (res.contentType?.split(';')[0] ?? '').trim().toLowerCase();
79
+ if (mediaType !== MARKDOWN_CONTENT_TYPE)
80
+ return false;
81
+ let finalUrl;
82
+ try {
83
+ finalUrl = new URL(res.finalUrl);
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ // Scheme matters, not just host: a redirect hop downgrading to plain http would let an on-path
89
+ // response be cached as the trusted twin.
90
+ if (finalUrl.protocol !== 'https:' || finalUrl.hostname.toLowerCase() !== DEVELOPER_HOST) {
91
+ return false;
92
+ }
93
+ return validateTextArtifact(res.content).ok;
94
+ }
95
+ // A real twin is a small static file that answers fast; anything slower should mean "no twin,
96
+ // fall back now" instead of stacking the fetcher's default 10s on top of the browser render.
97
+ const PROBE_TIMEOUT_MS = 4_000;
98
+ // The browser path refuses to cache captures under this floor (MIN_CONTAINER_CHARS); a twin that
99
+ // thin is a rollout stub, and falling back gives the rendered page a chance to do better.
100
+ const MIN_TWIN_CHARS = 100;
101
+ /**
102
+ * Probes the derived `.md` route for a developer doc URL. Returns a complete SiteFetchResult
103
+ * (with route_markdown provenance) when the twin validates, or null so the caller falls back to
104
+ * the browser capture. Never throws: any network/validation failure is just "no twin".
105
+ */
106
+ export async function fetchDeveloperRouteMarkdown(url, fetcher = fetchText) {
107
+ const candidate = deriveMarkdownUrl(url);
108
+ if (!candidate)
109
+ return null;
110
+ let res;
111
+ try {
112
+ res = await fetcher(candidate, { headers: PROBE_HEADERS, timeoutMs: PROBE_TIMEOUT_MS });
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ if (!isMarkdownResponse(res))
118
+ return null;
119
+ const { body, dropped } = stripIncludeDirectives(res.content);
120
+ const extraction = extractFromSource(body, res.finalUrl);
121
+ if (extraction.detailedMarkdown.length < MIN_TWIN_CHARS)
122
+ return null;
123
+ if (dropped > 0) {
124
+ extraction.qualityNotes = [
125
+ ...(extraction.qualityNotes ?? []),
126
+ `${dropped} ::include directive(s) removed (shared snippets are not published on the .md route)`,
127
+ ];
128
+ }
129
+ return {
130
+ fetchResult: {
131
+ contentType: res.contentType,
132
+ etag: res.etag,
133
+ lastModified: res.lastModified,
134
+ finalUrl: res.finalUrl,
135
+ responseSize: res.responseSize,
136
+ content: res.content,
137
+ },
138
+ extraction,
139
+ captureMethod: 'route_markdown',
140
+ sourceDocUrl: res.finalUrl,
141
+ };
142
+ }
@@ -64,13 +64,19 @@ function confidenceFor(length) {
64
64
  // page. Help wraps it in a removable component (see fetch-page.ts), but Developer renders it
65
65
  // as bare divs, so we also strip its lines from the converted Markdown for both sites.
66
66
  const BOILERPLATE_LINE = /(did this article solve your issue|let us know so we can improve|share your feedback)/i;
67
+ // Toolbar of the "View as Markdown" feature on Developer articles. When the .md route probe
68
+ // succeeds the browser never runs, but rollout/network asymmetries can still land such a page
69
+ // here, and the toolbar labels serialize as bare paragraph lines.
70
+ const MARKDOWN_TOOLBAR_LINE = /^(copy as markdown|view as markdown|copy url to markdown)$/i;
67
71
  // A link with empty anchor text — e.g. an icon-only "home" logo link. It carries no readable
68
72
  // content and renders as a bare `[](url)`. (Images `![](url)` are intentionally not matched.)
69
73
  const EMPTY_LINK_LINE = /^\[\]\([^)]*\)$/;
70
74
  export function stripBoilerplate(markdown) {
71
75
  return markdown
72
76
  .split('\n')
73
- .filter((line) => !BOILERPLATE_LINE.test(line) && !EMPTY_LINK_LINE.test(line.trim()))
77
+ .filter((line) => !BOILERPLATE_LINE.test(line) &&
78
+ !MARKDOWN_TOOLBAR_LINE.test(line.trim()) &&
79
+ !EMPTY_LINK_LINE.test(line.trim()))
74
80
  .join('\n')
75
81
  .replace(/\n{3,}/g, '\n\n')
76
82
  .trim();
@@ -336,6 +342,9 @@ function buildSalesforceFetchResult(url, html, title, detailedMarkdown, qualityN
336
342
  confidence: confidenceFor(detailedMarkdown.length),
337
343
  qualityNotes,
338
344
  },
345
+ // Rendered capture is this fetcher's only strategy; reporting it lets a refresh overwrite
346
+ // route_markdown provenance when an article's .md twin has since vanished.
347
+ captureMethod: 'browser_fallback',
339
348
  };
340
349
  }
341
350
  /**
@@ -1,4 +1,5 @@
1
1
  import type { ExtractionResult } from '../lib/research/extract.js';
2
+ import type { CaptureMethod } from '../lib/research/schema.js';
2
3
  export interface SiteFetchResult {
3
4
  fetchResult: {
4
5
  contentType: string | null;
@@ -9,7 +10,19 @@ export interface SiteFetchResult {
9
10
  content: string;
10
11
  };
11
12
  extraction: ExtractionResult;
13
+ captureMethod?: CaptureMethod;
14
+ sourceDocUrl?: string;
12
15
  }
16
+ /**
17
+ * Stamps a site module's reported provenance onto artifact metadata. Shared by the cache-miss
18
+ * fetch and revalidation so the two write paths can't drift. A result without captureMethod (a
19
+ * module predating provenance reporting) leaves the metadata untouched.
20
+ */
21
+ export declare function applySiteFetchProvenance(metadata: {
22
+ capture_method: CaptureMethod | null;
23
+ source_doc_url: string | null;
24
+ source_urls: string[];
25
+ }, siteFetch: SiteFetchResult): void;
13
26
  export interface SiteModule {
14
27
  id: string;
15
28
  name: string;
@@ -1 +1,14 @@
1
- export {};
1
+ /**
2
+ * Stamps a site module's reported provenance onto artifact metadata. Shared by the cache-miss
3
+ * fetch and revalidation so the two write paths can't drift. A result without captureMethod (a
4
+ * module predating provenance reporting) leaves the metadata untouched.
5
+ */
6
+ export function applySiteFetchProvenance(metadata, siteFetch) {
7
+ if (!siteFetch.captureMethod)
8
+ return;
9
+ metadata.capture_method = siteFetch.captureMethod;
10
+ metadata.source_doc_url = siteFetch.sourceDocUrl ?? null;
11
+ if (siteFetch.sourceDocUrl && !metadata.source_urls.includes(siteFetch.sourceDocUrl)) {
12
+ metadata.source_urls.push(siteFetch.sourceDocUrl);
13
+ }
14
+ }
@@ -28,6 +28,15 @@
28
28
  "allowNo": false,
29
29
  "type": "boolean"
30
30
  },
31
+ "read-only": {
32
+ "aliases": [
33
+ "plan"
34
+ ],
35
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
36
+ "name": "read-only",
37
+ "allowNo": false,
38
+ "type": "boolean"
39
+ },
31
40
  "topic": {
32
41
  "char": "t",
33
42
  "description": "the main category/topic of the research for metadata tagging",
@@ -169,6 +178,15 @@
169
178
  "allowNo": false,
170
179
  "type": "boolean"
171
180
  },
181
+ "read-only": {
182
+ "aliases": [
183
+ "plan"
184
+ ],
185
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
186
+ "name": "read-only",
187
+ "allowNo": false,
188
+ "type": "boolean"
189
+ },
172
190
  "stdin": {
173
191
  "description": "read Markdown from stdin",
174
192
  "name": "stdin",
@@ -288,6 +306,15 @@
288
306
  "name": "json",
289
307
  "allowNo": false,
290
308
  "type": "boolean"
309
+ },
310
+ "read-only": {
311
+ "aliases": [
312
+ "plan"
313
+ ],
314
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
315
+ "name": "read-only",
316
+ "allowNo": false,
317
+ "type": "boolean"
291
318
  }
292
319
  },
293
320
  "hasDynamicHelp": false,
@@ -331,6 +358,15 @@
331
358
  "allowNo": false,
332
359
  "type": "boolean"
333
360
  },
361
+ "read-only": {
362
+ "aliases": [
363
+ "plan"
364
+ ],
365
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
366
+ "name": "read-only",
367
+ "allowNo": false,
368
+ "type": "boolean"
369
+ },
334
370
  "topic": {
335
371
  "char": "t",
336
372
  "description": "filter by exact topic (case-insensitive)",
@@ -438,6 +474,15 @@
438
474
  "allowNo": false,
439
475
  "type": "boolean"
440
476
  },
477
+ "read-only": {
478
+ "aliases": [
479
+ "plan"
480
+ ],
481
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
482
+ "name": "read-only",
483
+ "allowNo": false,
484
+ "type": "boolean"
485
+ },
441
486
  "older-than": {
442
487
  "description": "prune entries older than duration (e.g. \"30d\", \"90d\")",
443
488
  "name": "older-than",
@@ -525,6 +570,15 @@
525
570
  "allowNo": false,
526
571
  "type": "boolean"
527
572
  },
573
+ "read-only": {
574
+ "aliases": [
575
+ "plan"
576
+ ],
577
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
578
+ "name": "read-only",
579
+ "allowNo": false,
580
+ "type": "boolean"
581
+ },
528
582
  "tier": {
529
583
  "description": "freshness tier policy to evaluate against",
530
584
  "name": "tier",
@@ -586,6 +640,15 @@
586
640
  "name": "json",
587
641
  "allowNo": false,
588
642
  "type": "boolean"
643
+ },
644
+ "read-only": {
645
+ "aliases": [
646
+ "plan"
647
+ ],
648
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
649
+ "name": "read-only",
650
+ "allowNo": false,
651
+ "type": "boolean"
589
652
  }
590
653
  },
591
654
  "hasDynamicHelp": false,
@@ -627,6 +690,15 @@
627
690
  "allowNo": false,
628
691
  "type": "boolean"
629
692
  },
693
+ "read-only": {
694
+ "aliases": [
695
+ "plan"
696
+ ],
697
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
698
+ "name": "read-only",
699
+ "allowNo": false,
700
+ "type": "boolean"
701
+ },
630
702
  "global": {
631
703
  "char": "g",
632
704
  "description": "Read from user-level config only.",
@@ -679,6 +751,15 @@
679
751
  "allowNo": false,
680
752
  "type": "boolean"
681
753
  },
754
+ "read-only": {
755
+ "aliases": [
756
+ "plan"
757
+ ],
758
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
759
+ "name": "read-only",
760
+ "allowNo": false,
761
+ "type": "boolean"
762
+ },
682
763
  "global": {
683
764
  "char": "g",
684
765
  "description": "Show user-level config only.",
@@ -742,6 +823,15 @@
742
823
  "allowNo": false,
743
824
  "type": "boolean"
744
825
  },
826
+ "read-only": {
827
+ "aliases": [
828
+ "plan"
829
+ ],
830
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
831
+ "name": "read-only",
832
+ "allowNo": false,
833
+ "type": "boolean"
834
+ },
745
835
  "global": {
746
836
  "char": "g",
747
837
  "description": "Write to user-level config (default).",
@@ -806,6 +896,15 @@
806
896
  "allowNo": false,
807
897
  "type": "boolean"
808
898
  },
899
+ "read-only": {
900
+ "aliases": [
901
+ "plan"
902
+ ],
903
+ "description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
904
+ "name": "read-only",
905
+ "allowNo": false,
906
+ "type": "boolean"
907
+ },
809
908
  "global": {
810
909
  "char": "g",
811
910
  "description": "Remove from user-level config (default).",
@@ -843,5 +942,5 @@
843
942
  "stdoutIsPrimaryData": true
844
943
  }
845
944
  },
846
- "version": "3.0.3"
945
+ "version": "3.2.0"
847
946
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taurgis/bonsai",
3
- "version": "3.0.3",
3
+ "version": "3.2.0",
4
4
  "description": "Bonsai is a standalone local research cache CLI for AI agents",
5
5
  "repository": {
6
6
  "type": "git",