@basein/runner 0.3.20 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +6 -5
  2. package/dist/bin/bir-hooks.d.ts +17 -19
  3. package/dist/bin/bir-hooks.js +17 -19
  4. package/dist/bin/bir-scenario.js +1 -1
  5. package/dist/bin/bir.js +1 -21
  6. package/dist/bin/investigate.d.ts +0 -9
  7. package/dist/bin/investigate.js +15 -43
  8. package/dist/bin/scenario-edit.js +13 -44
  9. package/dist/control/recorder-main.d.ts +0 -15
  10. package/dist/control/recorder-main.js +22 -42
  11. package/dist/control/server.d.ts +24 -137
  12. package/dist/control/server.js +107 -1001
  13. package/dist/record/recorder.d.ts +11 -142
  14. package/dist/record/recorder.js +0 -3
  15. package/dist/record/remote-recorder.d.ts +1 -9
  16. package/dist/record/remote-recorder.js +3 -49
  17. package/dist/record/switchable-recorder.js +0 -2
  18. package/dist/replay/builtins.d.ts +45 -9
  19. package/dist/replay/builtins.js +131 -59
  20. package/dist/replay/bundle.d.ts +2 -2
  21. package/dist/replay/bundle.js +3 -3
  22. package/dist/replay/controller.d.ts +34 -125
  23. package/dist/replay/controller.js +171 -528
  24. package/dist/replay/derive.d.ts +0 -10
  25. package/dist/replay/derive.js +0 -3
  26. package/dist/replay/handover.d.ts +2 -37
  27. package/dist/replay/handover.js +38 -106
  28. package/dist/replay/logic.d.ts +0 -11
  29. package/dist/replay/logic.js +0 -24
  30. package/dist/replay/plan.d.ts +43 -163
  31. package/dist/replay/plan.js +144 -556
  32. package/dist/replay/safety.d.ts +4 -5
  33. package/dist/replay/safety.js +7 -7
  34. package/dist/replay/source-run.d.ts +15 -24
  35. package/dist/replay/source-run.js +25 -58
  36. package/dist/replay/targets.d.ts +11 -21
  37. package/dist/replay/targets.js +30 -69
  38. package/dist/replay/types.d.ts +9 -96
  39. package/dist/replay/types.js +3 -16
  40. package/dist/util/service-headers.d.ts +1 -2
  41. package/dist/util/service-headers.js +1 -2
  42. package/docs/calculatedReplay.md +30 -81
  43. package/docs/calculatedReplayGuide.md +17 -34
  44. package/package.json +1 -1
  45. package/production.md +7 -36
  46. package/dist/replay/condition-words.d.ts +0 -28
  47. package/dist/replay/condition-words.js +0 -59
  48. package/dist/replay/flatten.d.ts +0 -161
  49. package/dist/replay/flatten.js +0 -222
  50. package/dist/replay/reads-only.d.ts +0 -29
  51. package/dist/replay/reads-only.js +0 -261
@@ -5,7 +5,7 @@
5
5
  * A built-in lives inside the host session, not behind any MCP server, so there
6
6
  * is no proxy to relay it to: historically every built-in step threw `not_run`
7
7
  * and the plan handed the task back to the model. This module is the other
8
- * option: execute the step here, against the *session's own project directory*,
8
+ * option: execute the step here, from the *session's own project directory*,
9
9
  * so a chain that starts with a `Read` no longer costs a hand-over.
10
10
  *
11
11
  * THE RULES, because this bypasses the host's permission prompts entirely:
@@ -17,10 +17,17 @@
17
17
  * already has — its pinned call is answered `allow` too. What running it
18
18
  * here changes is that no model sees the call before it runs, so an
19
19
  * operator who wants one in the loop takes the tool off the list.
20
- * - **Path jail.** Every path is resolved against, and confined to, the
21
- * session's project directory ({@link jailed}), symlinks included. The
22
- * daemon's own cwd is never used: it is `~/.baseinstrunner`, not the
23
- * project (§5.1 of the change plan).
20
+ * - **Any path the agent could reach, unless `BIR_PATH_ALLOW` narrows it.**
21
+ * A relative path resolves against the session's project directory, as
22
+ * the host's tools resolve it; the daemon's own cwd is never used: it is
23
+ * `~/.baseinstrunner`, not the project (§5.1 of the change plan). An
24
+ * absolute path is taken as it is, inside the project or not: the agent's
25
+ * own tools reach any folder its session allows — the recorded call was
26
+ * one of them — and a runner-side `Bash` reaches any folder already. Up
27
+ * to 0.3.20 every path was confined to the project directory;
28
+ * `BIR_PATH_ALLOW=project` brings that back, and a list of folders
29
+ * ({@link parsePathAllow}) confines the file tools to those, symlinks
30
+ * included ({@link allowedPath}).
24
31
  * - **Bash runs any command unless `BIR_BASH_ALLOW` narrows it.** Set, a
25
32
  * command must match one of its prefixes ({@link bashAllowed}); `none`
26
33
  * allows no command, and then `Bash` counts as off the list, so its steps
@@ -43,7 +50,8 @@ import { execFile } from "node:child_process";
43
50
  import { randomBytes } from "node:crypto";
44
51
  import { existsSync } from "node:fs";
45
52
  import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
46
- import { dirname, join, resolve, sep } from "node:path";
53
+ import { homedir } from "node:os";
54
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
47
55
  // ── configuration ────────────────────────────────────────────────────────────
48
56
  /** The built-ins {@link runBuiltin} actually implements. */
49
57
  export const EXECUTABLE_BUILTINS = new Set([
@@ -61,8 +69,24 @@ export const EXECUTABLE_BUILTINS = new Set([
61
69
  * set (`Read,Glob,Grep`).
62
70
  */
63
71
  export const DEFAULT_BUILTIN_TOOLS = [...EXECUTABLE_BUILTINS];
72
+ /**
73
+ * The built-ins that reach files by a path, the ones `BIR_PATH_ALLOW`
74
+ * governs. `Bash` is not among them: what a command touches is
75
+ * `BIR_BASH_ALLOW`'s business.
76
+ */
77
+ export const FILE_BUILTINS = new Set(["Read", "Glob", "Grep", "Edit", "Write"]);
64
78
  /** `read` → `Read`: the vocabulary is small enough to forgive casing. */
65
79
  const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.toLowerCase(), name]));
80
+ /**
81
+ * A switch's list once it is set: comma-separated, trimmed, empty entries
82
+ * dropped, and `none` / `off` / `0` meaning nothing at all.
83
+ */
84
+ function parseList(value) {
85
+ return value
86
+ .split(",")
87
+ .map((s) => s.trim())
88
+ .filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()));
89
+ }
66
90
  /**
67
91
  * Parse `BIR_BUILTIN_TOOLS`. Unset means {@link DEFAULT_BUILTIN_TOOLS}; set,
68
92
  * it is the whole list — `none` (or an empty value) turns built-ins off
@@ -73,12 +97,7 @@ const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.t
73
97
  export function parseBuiltinTools(value) {
74
98
  if (value === undefined)
75
99
  return new Set(DEFAULT_BUILTIN_TOOLS);
76
- const names = value
77
- .split(",")
78
- .map((s) => s.trim())
79
- .filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()))
80
- .map((s) => CANONICAL_BUILTIN.get(s.toLowerCase()) ?? s);
81
- return new Set(names);
100
+ return new Set(parseList(value).map((s) => CANONICAL_BUILTIN.get(s.toLowerCase()) ?? s));
82
101
  }
83
102
  /**
84
103
  * Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
@@ -89,10 +108,24 @@ export function parseBuiltinTools(value) {
89
108
  export function parseBashAllow(value) {
90
109
  if (value === undefined)
91
110
  return ["*"];
92
- return value
93
- .split(",")
94
- .map((s) => s.trim())
95
- .filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()));
111
+ return parseList(value);
112
+ }
113
+ /**
114
+ * Parse `BIR_PATH_ALLOW`: the folders a runner-side `Read`, `Glob`, `Grep`,
115
+ * `Edit` or `Write` may touch, comma-separated, `*` for anywhere. Unset means
116
+ * anywhere, as the agent's own tools reach any folder its session allows (up
117
+ * to 0.3.20 it was the project directory alone). In a list, `project` is the
118
+ * session's project directory, `~` the home folder (`~/.claude` a folder in
119
+ * it), and any other relative folder resolves against the project directory.
120
+ * Set, it is the whole list, as `BIR_BASH_ALLOW` is: `none` (or an empty
121
+ * value) allows no folder, and the file tools then count as off
122
+ * `BIR_BUILTIN_TOOLS`, so their steps are steered in the session
123
+ * (controller.ts).
124
+ */
125
+ export function parsePathAllow(value) {
126
+ if (value === undefined)
127
+ return ["*"];
128
+ return parseList(value);
96
129
  }
97
130
  /**
98
131
  * Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
@@ -117,8 +150,9 @@ export function bashAllowed(command, allow) {
117
150
  // ── the failure contract ─────────────────────────────────────────────────────
118
151
  /**
119
152
  * The step cannot run here — policy, not breakage: the tool is not
120
- * implemented, its path leaves the project, `Bash` is not allowed, or the
121
- * input is not even the right shape. Maps to `not_run`: nothing happened, the
153
+ * implemented, its path is outside the folders `BIR_PATH_ALLOW` names, the
154
+ * command is not one `BIR_BASH_ALLOW` allows, or the input is not even the
155
+ * right shape. Maps to `not_run`: nothing happened, the
122
156
  * session may still do it, a recorded output may stand in.
123
157
  */
124
158
  export class BuiltinNotRunnable extends Error {
@@ -186,7 +220,9 @@ export async function runBuiltin(toolName, input, ctx) {
186
220
  throw new BuiltinNotRunnable(`no runner-side implementation for ${toolName}`);
187
221
  }
188
222
  }
189
- // ── the path jail ────────────────────────────────────────────────────────────
223
+ // ── which paths a file step may touch ────────────────────────────────────────
224
+ /** The word in `BIR_PATH_ALLOW` that stands for the session's project directory. */
225
+ const PROJECT_WORD = "project";
190
226
  /** Case-blind on Windows, where `C:\Repo` and `c:\repo` are one directory. */
191
227
  function samePath(a, b) {
192
228
  return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
@@ -199,54 +235,87 @@ function underRoot(root, abs) {
199
235
  : abs.startsWith(prefix)));
200
236
  }
201
237
  /**
202
- * Resolve `p` against the session root and refuse anything outside it — first
203
- * textually, then through `realpath` so a symlink inside the project cannot
204
- * point the step at a file beyond it. The nearest existing ancestor is what
205
- * gets resolved, so a `Write` to a new file is judged by its real directory.
238
+ * The folders the context's `BIR_PATH_ALLOW` list names, as absolute paths,
239
+ * or `anywhere` for `*`. `project` is the session's project directory, `~`
240
+ * the home folder, and any other relative entry resolves against the project.
206
241
  */
207
- async function jailed(root, p, what = "file_path") {
208
- if (typeof p !== "string" || p.trim() === "") {
209
- throw new BuiltinNotRunnable(`${what} is required`);
210
- }
211
- const r = resolve(root);
212
- const abs = resolve(r, p);
213
- const outside = () => {
214
- throw new BuiltinNotRunnable(`${p} is outside the session's project directory, so bir does not touch it unattended`);
215
- };
216
- if (!underRoot(r, abs))
217
- outside();
218
- // The textual check passed; now the physical one. Walk up to what exists,
219
- // realpath it, and require *that* to sit under the root's own realpath.
242
+ function allowedFolders(ctx) {
243
+ const allow = ctx.pathAllow ?? [PROJECT_WORD];
244
+ if (allow.includes("*"))
245
+ return "anywhere";
246
+ const project = resolve(ctx.cwd);
247
+ return allow.map((entry) => {
248
+ if (entry.toLowerCase() === PROJECT_WORD)
249
+ return project;
250
+ if (entry === "~")
251
+ return homedir();
252
+ if (entry.startsWith("~/") || entry.startsWith("~\\"))
253
+ return resolve(homedir(), entry.slice(2));
254
+ return resolve(project, entry);
255
+ });
256
+ }
257
+ /** Why a path was refused, naming the switch that refused it. */
258
+ function outsideMessage(p, allow) {
259
+ return allow.length === 1 && allow[0].toLowerCase() === PROJECT_WORD
260
+ ? `${p} is outside the session's project directory, and BIR_PATH_ALLOW=project keeps bir inside it`
261
+ : `${p} is outside the folders BIR_PATH_ALLOW names (${allow.join(", ")}), so bir does not touch it unattended`;
262
+ }
263
+ /**
264
+ * `abs` as it is on disk: its longest existing ancestor replaced by that
265
+ * ancestor's realpath (symlinks resolved, and on macOS `/var` read as
266
+ * `/private/var`), the rest kept as written. A path none of which exists
267
+ * comes back as it is.
268
+ */
269
+ async function physical(abs) {
220
270
  let probe = abs;
221
- let real;
271
+ const rest = [];
222
272
  for (;;) {
223
273
  try {
224
- real = await realpath(probe);
225
- break;
274
+ const real = await realpath(probe);
275
+ return rest.length > 0 ? join(real, ...rest.reverse()) : real;
226
276
  }
227
277
  catch {
228
278
  const parent = dirname(probe);
229
279
  if (samePath(parent, probe))
230
- break; // hit the filesystem root: nothing exists
280
+ return abs; // the filesystem root: nothing exists
281
+ rest.push(basename(probe));
231
282
  probe = parent;
232
283
  }
233
284
  }
234
- if (real !== undefined) {
235
- let realRoot;
236
- try {
237
- realRoot = await realpath(r);
238
- }
239
- catch {
240
- throw new BuiltinNotRunnable(`the session's project directory does not exist: ${root}`);
241
- }
242
- if (!underRoot(realRoot, real))
243
- outside();
285
+ }
286
+ /**
287
+ * Resolve `p` as the host does — against the session's project directory
288
+ * when it is relative — and allow it anywhere, as the agent's own tools
289
+ * would, unless `BIR_PATH_ALLOW` names folders. Then anything outside them is
290
+ * refused: first as written, then physically ({@link physical}, both sides),
291
+ * so a symlink inside an allowed folder cannot point the step at a file
292
+ * beyond it, and a `Write` to a new file is judged by its real folder.
293
+ */
294
+ async function allowedPath(ctx, p, what = "file_path") {
295
+ if (typeof p !== "string" || p.trim() === "") {
296
+ throw new BuiltinNotRunnable(`${what} is required`);
244
297
  }
298
+ const abs = resolve(ctx.cwd, p);
299
+ const folders = allowedFolders(ctx);
300
+ if (folders === "anywhere")
301
+ return abs;
302
+ if (folders.length === 0) {
303
+ throw new BuiltinNotRunnable("BIR_PATH_ALLOW allows no folder, so bir touches no file unattended");
304
+ }
305
+ const outside = () => {
306
+ throw new BuiltinNotRunnable(outsideMessage(p, ctx.pathAllow ?? [PROJECT_WORD]));
307
+ };
308
+ if (!folders.some((folder) => underRoot(folder, abs)))
309
+ outside();
310
+ const real = await physical(abs);
311
+ const realFolders = await Promise.all(folders.map(physical));
312
+ if (!realFolders.some((folder) => underRoot(folder, real)))
313
+ outside();
245
314
  return abs;
246
315
  }
247
316
  // ── Read ─────────────────────────────────────────────────────────────────────
248
317
  async function runRead(input, ctx) {
249
- const filePath = await jailed(ctx.cwd, input.file_path);
318
+ const filePath = await allowedPath(ctx, input.file_path);
250
319
  const size = (await stat(filePath)).size;
251
320
  if (size > MAX_FILE_BYTES) {
252
321
  throw new Error(`file is ${size} bytes, more than the ${MAX_FILE_BYTES} this runner reads`);
@@ -276,7 +345,7 @@ async function runGlob(input, ctx, deadline) {
276
345
  if (typeof pattern !== "string" || pattern.trim() === "") {
277
346
  throw new BuiltinNotRunnable("pattern is required");
278
347
  }
279
- const root = await jailed(ctx.cwd, typeof input.path === "string" ? input.path : ".", "path");
348
+ const root = await allowedPath(ctx, typeof input.path === "string" ? input.path : ".", "path");
280
349
  const startedAt = Date.now();
281
350
  const re = globToRegExp(pattern);
282
351
  const found = [];
@@ -324,7 +393,7 @@ async function runGrep(input, ctx, deadline) {
324
393
  catch (err) {
325
394
  throw new BuiltinNotRunnable(`the pattern is not a regular expression this runner can run: ${err.message}`);
326
395
  }
327
- const target = await jailed(ctx.cwd, typeof input.path === "string" ? input.path : ".", "path");
396
+ const target = await allowedPath(ctx, typeof input.path === "string" ? input.path : ".", "path");
328
397
  // ripgrep's `-g '*.ts'` matches the basename at any depth; only a pattern
329
398
  // with a `/` in it speaks about the path.
330
399
  const globRe = typeof input.glob === "string" && input.glob
@@ -417,7 +486,7 @@ async function matchFile(path, re, firstOnly) {
417
486
  }
418
487
  // ── Edit / Write ─────────────────────────────────────────────────────────────
419
488
  async function runEdit(input, ctx) {
420
- const filePath = await jailed(ctx.cwd, input.file_path);
489
+ const filePath = await allowedPath(ctx, input.file_path);
421
490
  const oldString = input.old_string;
422
491
  const newString = input.new_string;
423
492
  if (typeof oldString !== "string" || oldString === "") {
@@ -452,7 +521,7 @@ async function runEdit(input, ctx) {
452
521
  };
453
522
  }
454
523
  async function runWrite(input, ctx) {
455
- const filePath = await jailed(ctx.cwd, input.file_path);
524
+ const filePath = await allowedPath(ctx, input.file_path);
456
525
  const content = input.content;
457
526
  if (typeof content !== "string")
458
527
  throw new BuiltinNotRunnable("content is required");
@@ -579,9 +648,10 @@ function takePwdMarker(stderr, mark) {
579
648
  const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
580
649
  /**
581
650
  * Walk `root`, calling `visit(relativePath, absolutePath, mtimeMs)` for every
582
- * regular file. Never follows directory symlinks — the jail was checked at
583
- * the root, and a link out of it must not re-open the door. Returns whether
584
- * the walk was cut short (entry cap, deadline, or `visit` saying stop).
651
+ * regular file. Never follows directory symlinks — the folder was checked at
652
+ * the root, and under a `BIR_PATH_ALLOW` list a link out of it must not
653
+ * re-open the door (nor may a link loop the walk). Returns whether the walk
654
+ * was cut short (entry cap, deadline, or `visit` saying stop).
585
655
  */
586
656
  async function walk(root, deadline, visit) {
587
657
  let seen = 0;
@@ -617,7 +687,9 @@ async function walk(root, deadline, visit) {
617
687
  catch {
618
688
  continue;
619
689
  }
620
- const rel = abs.slice(resolve(root).length + 1).split(sep).join("/");
690
+ // `relative`, not a slice: a walk may now start at a drive's or the
691
+ // disk's root (`C:\`, `/`), which already ends in a separator.
692
+ const rel = relative(root, abs).split(sep).join("/");
621
693
  if (!visit(rel, abs, mtime))
622
694
  return true;
623
695
  }
@@ -51,8 +51,8 @@ export interface AssembleOptions {
51
51
  /** The plan stopped part-way and the model is to carry on (fallbk.md). */
52
52
  handover?: boolean;
53
53
  /**
54
- * The plan was armed mid-task by intent (segmented.md R-OUT-13). Its results
55
- * feed the agent's own task, which continues. `handover` wins over this.
54
+ * The plan was armed mid-task by intent. Its results feed the agent's own
55
+ * task, which continues. `handover` wins over this.
56
56
  */
57
57
  subTask?: boolean;
58
58
  /**
@@ -100,8 +100,8 @@ const HANDOVER_HEADER = "[BaseInstRunner calculated replay] The tool calls below
100
100
  "continue the task yourself from where they end.";
101
101
  const HANDOVER_FOOTER = "\n\nThe calls above are done. Continue the rest of the task yourself, calling tools as needed.";
102
102
  /**
103
- * The plan ran inside the agent's own task (segmented.md R-OUT-13): it finished,
104
- * but the task did not, so the model must not be told to answer without tools.
103
+ * The plan ran inside the agent's own task (armed mid-task by intent): it
104
+ * finished, but the task did not, so the model must not be told to answer without tools.
105
105
  * This is {@link HANDOVER_HEADER} without "The scenario stopped before the task
106
106
  * was finished" — nothing stopped, the sub-task is simply done.
107
107
  */
@@ -121,7 +121,7 @@ export function assembleBundle(entries, maxChars, opts = {}) {
121
121
  const anyRecorded = entries.some((e) => e.recorded);
122
122
  // A hand-over outranks a sub-task: it says "continue" too, and it also has to
123
123
  // say that the plan stopped short. A sub-task outranks the live and mixed
124
- // headers, whose wording forbids further tool calls (segmented.md 10.8 §6).
124
+ // headers, whose wording forbids further tool calls.
125
125
  const header = opts.handover
126
126
  ? HANDOVER_HEADER
127
127
  : opts.subTask
@@ -11,14 +11,13 @@
11
11
  * degrades to "run the turn normally" or returns something the session can
12
12
  * ignore. Nothing throws at a hook.
13
13
  */
14
- import type { ExecutionReport, MatchKey, MatchSegment, MatchVerdict } from "../record/recorder.js";
14
+ import type { ExecutionReport } from "../record/recorder.js";
15
15
  import { type ReplayMode, type StepReach } from "./coverage.js";
16
16
  import { deriveParameters, type AgentDay, type DeriveContext, type DeriveResult } from "./derive.js";
17
17
  import { ProxyWorkQueue } from "./executor.js";
18
18
  import { ScenarioReplayPlan } from "./plan.js";
19
19
  import { type HostSinks } from "./safety.js";
20
20
  import { SourceRunOutputs } from "./source-run.js";
21
- import { type FlatStop } from "./flatten.js";
22
21
  import { type ExecutionOutcome, type ExecutionStepResult, type FallbackKind, type SerializedScenario, type SerializedScenarioStep } from "./types.js";
23
22
  /** The first-party tool a `direct` plan is delivered through (§6.3). */
24
23
  export declare const DIRECT_TOOL_NAME = "mcp__bir__run_scenario";
@@ -239,14 +238,6 @@ export declare const RUN_NO_PLAN = "no_plan";
239
238
  export interface ReplayOptions {
240
239
  /** On unless `BIR_REPLAY=0`. Everything here is inert when false. */
241
240
  enabled: boolean;
242
- /**
243
- * Whether a handed-out segment may run mid-task. The recorder sets it from
244
- * `BIR_SEGMENT_ARM`: on unless that is `0`, from runner 0.3.19 (the
245
- * service's segmentsbreakdown.md SB-21). Omitted, as by a test or an
246
- * embedder, it is off: observe-only, where the runner asks for no segment,
247
- * arms none, and only logs what would have armed (R-OUT-10).
248
- */
249
- segmentArm?: boolean;
250
241
  /** `BIR_REPLAY_ALLOW_SERVERS`; undefined means every wrapped server. */
251
242
  allowServers?: ReadonlySet<string>;
252
243
  /**
@@ -266,6 +257,16 @@ export interface ReplayOptions {
266
257
  * rather than armed `direct` and stopped at the first one.
267
258
  */
268
259
  bashAllow?: readonly string[];
260
+ /**
261
+ * `BIR_PATH_ALLOW` — the folders a runner-side `Read`, `Glob`, `Grep`,
262
+ * `Edit` or `Write` may touch (`*` for any; `project` is the session's
263
+ * project directory). Omitted, the environment decides; the default is
264
+ * `*`, any path the agent's own tools could reach (up to 0.3.20 the
265
+ * project directory alone). Empty allows no folder, and then the file tools
266
+ * count as off {@link ReplayOptions.builtinTools}, as `Bash` does under an
267
+ * empty {@link ReplayOptions.bashAllow}.
268
+ */
269
+ pathAllow?: readonly string[];
269
270
  budgets?: Partial<ReplayBudgets>;
270
271
  /**
271
272
  * The service: the lazy source-run fetch, and the parameter derivation this
@@ -297,21 +298,13 @@ export interface ReplayOptions {
297
298
  export interface IntentMatchOptions {
298
299
  /** `BIR_INTENT_MATCH`. */
299
300
  enabled: boolean;
300
- /**
301
- * How long a `PreToolUse` waits for the service. A timeout is a miss.
302
- *
303
- * 4 000 ms, not the 1 500 of Part 1: a request whose best segment lands in
304
- * the *not clear* band has one live question to ask under the server's own
305
- * 2 500 ms attempt, and must still answer well inside the hook's 30 s
306
- * (segmented.md R-HIT-11). A clear match or a miss answers as fast as before.
307
- */
301
+ /** How long a probe's request may take. A timeout is a miss. */
308
302
  budgetMs: number;
309
303
  /** Plans intent matching may arm in one turn. */
310
304
  maxPerTurn: number;
311
305
  /**
312
- * Probes one turn may *send*. Separate from `maxPerTurn`, and much larger:
313
- * arming three plans is a claim on the turn, while counting hits is only
314
- * bookkeeping, and it must not stop at the third arm (R-HIT-5).
306
+ * Probes one turn may *send*. Separate from `maxPerTurn`, and larger: most
307
+ * probes find nothing, and a turn that armed nothing yet may still match.
315
308
  */
316
309
  maxRequestsPerTurn: number;
317
310
  }
@@ -391,24 +384,10 @@ export interface ReplayState {
391
384
  armedAt: number;
392
385
  reported: boolean;
393
386
  sourceRun?: SourceRunOutputs;
394
- /**
395
- * Why the flattener stopped the plan, when it is what stopped it: a call this
396
- * chain cannot run right now (segmented.md R-CALL-29). The note names the
397
- * sub-task and the reason, and the report carries `unusable_call`.
398
- */
399
- flatStop?: FlatStop;
400
387
  /** Set once a plan has been retired, so nothing re-arms mid-turn. */
401
388
  retired: boolean;
402
389
  /** What armed it: the prompt, or a ReAct iteration's intent (fallbk.md). */
403
390
  armedBy: "prompt" | "intent";
404
- /**
405
- * The user's request this plan serves, in their own words: the prompt for a
406
- * plan the prompt armed (set in `arm`), the turn's prompt for one an intent
407
- * match armed (set by the control server). Read only for the note of a plan
408
- * stopped at its decision, which quotes the request's condition sentences
409
- * (the service's segmentsbreakdown.md SB-18). Never sent anywhere.
410
- */
411
- request?: string;
412
391
  /**
413
392
  * The Claude Code session this plan serves, set by the control server. Its
414
393
  * steps run on that session's own proxies (executor.ts). Absent for `bir
@@ -418,8 +397,9 @@ export interface ReplayState {
418
397
  /**
419
398
  * That session's project directory, set by the control server from the
420
399
  * hook payloads — never the daemon's own cwd, which is `~/.baseinstrunner`.
421
- * It is where an allow-listed built-in step executes and the jail it may
422
- * not leave (replay/builtins.ts). Absent — `bir replay`, an older server —
400
+ * It is where an allow-listed built-in step executes and what its relative
401
+ * paths resolve against (replay/builtins.ts; `BIR_PATH_ALLOW=project`
402
+ * confines the file tools to it). Absent — `bir replay`, an older server —
423
403
  * no built-in executes: the step is `not_run`, exactly as before.
424
404
  */
425
405
  sessionCwd?: string;
@@ -432,25 +412,6 @@ export interface ReplayState {
432
412
  * paths, which resolve against the project directory as the host's do.
433
413
  */
434
414
  bashCwd?: string;
435
- /**
436
- * Which kind of row was handed out (segmented.md R-OUT-7). A `segment` is a
437
- * named sub-task of a recording rather than a whole task, and it is judged
438
- * against its own similarity gate. Absent from an older server, and then read
439
- * as `scenario` (R-COMPAT-2).
440
- */
441
- kind: "scenario" | "segment";
442
- /** Where a handed-out segment sits in its recording (R-OUT-8). */
443
- segment?: MatchSegment;
444
- /** Which of the segment's two keys scored (R-OUT-3). For logs. */
445
- key?: MatchKey;
446
- /** The cheap model's answer, when the score sat in the not-clear band. */
447
- verified?: MatchVerdict;
448
- /**
449
- * Segments of this plan's own recording that armed later in the same turn
450
- * (R-MONEY-4). Their share comes off this plan's baseline, so one recording's
451
- * steps are never counted in two baselines of one turn.
452
- */
453
- sharedWith: string[];
454
415
  /** Set when the plan handed the task to the model part-way (fallbk.md). */
455
416
  handover?: Handover;
456
417
  /**
@@ -472,11 +433,9 @@ export interface ReplayState {
472
433
  */
473
434
  diverging?: boolean;
474
435
  /**
475
- * Which gate declined, as a fixed code (segmented.md R-HIT-14). The prose
476
- * `why` that `decline()` logs stays free text; this is what code reads — the
477
- * position rule (R-OUT-6) reports a position only for `known_bad_first_step`,
478
- * and reading prose for that would break the first time a sentence was
479
- * reworded.
436
+ * Which gate declined, as a fixed code. The prose `why` that `decline()`
437
+ * logs stays free text; this is what code and the report read, and reading
438
+ * prose would break the first time a sentence was reworded.
480
439
  */
481
440
  declined?: DeclineCode;
482
441
  /**
@@ -496,10 +455,7 @@ export interface ReplayState {
496
455
  * A plan that finished its work stops costing at that moment: the agent's
497
456
  * later work in the same turn is the agent's, not this plan's. A plan that
498
457
  * handed over or diverged takes no mark, because the follow-on work is the
499
- * direct consequence of its stopping (R-MONEY-3, R-FALL-5) — except a plan
500
- * stopped at its decision after a step ran: it did all it was for, and the
501
- * agent's decision and the half it chose are the agent's own (the service's
502
- * segmentsbreakdown.md SB-26).
458
+ * direct consequence of its stopping (R-MONEY-3, R-FALL-5).
503
459
  */
504
460
  retiredMark?: unknown;
505
461
  retiredAt?: number;
@@ -567,10 +523,7 @@ export interface ReplayState {
567
523
  * copies are cut to 3,800 bytes, and the agent's work is built on them.
568
524
  */
569
525
  keepCalls?: boolean;
570
- /**
571
- * Those calls, in the order the plan made them, keyed by frame and step —
572
- * a sub-task called twice keeps both calls of each step.
573
- */
526
+ /** Those calls, in the order the plan made them, keyed by step. */
574
527
  turnCalls?: Map<string, TurnCall>;
575
528
  /** What they hold, in characters: bounded by {@link TURN_CALLS_MAX_CHARS}. */
576
529
  turnCallChars?: number;
@@ -613,7 +566,7 @@ export interface TurnCall {
613
566
  * Why a match did not arm. One code per gate of the ladder, so the caller can
614
567
  * act on a decline without parsing the sentence that explains it.
615
568
  */
616
- export type DeclineCode = "replay_disabled" | "not_ready" | "flatten_failed" | "known_bad_first_step" | "nondeterministic_first_step" | "unusable_first_step" | "coverage" | "missing_target" | "no_derive_key"
569
+ export type DeclineCode = "replay_disabled" | "not_ready" | "known_bad_first_step" | "nondeterministic_first_step" | "coverage" | "missing_target" | "no_derive_key"
617
570
  /** The service handed over no plan: this runner is too old for it. */
618
571
  | "runner_update_required"
619
572
  /**
@@ -689,6 +642,8 @@ export declare class ReplayController {
689
642
  private readonly builtinTools;
690
643
  /** See {@link ReplayOptions.bashAllow}. */
691
644
  private readonly bashAllow;
645
+ /** See {@link ReplayOptions.pathAllow}. */
646
+ private readonly pathAllow;
692
647
  /** Calls waiting for a plan's pins to change (see `awaitPins`). */
693
648
  private readonly pinWaiters;
694
649
  /**
@@ -698,16 +653,6 @@ export declare class ReplayController {
698
653
  */
699
654
  private hostSignals;
700
655
  constructor(opts: ReplayOptions);
701
- /**
702
- * Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
703
- *
704
- * Only when the option says so. The recorder passes it on unless
705
- * `BIR_SEGMENT_ARM=0` (from 0.3.19; the service's segmentsbreakdown.md
706
- * SB-21); a controller built without it stays observe-only, asks for no
707
- * segment and arms none. The server enforces the same thing from its side
708
- * (R-OUT-9).
709
- */
710
- get segmentArm(): boolean;
711
656
  /** The plan's own delivery vehicle is never a scenario step. */
712
657
  isDirectTool(toolName: string): boolean;
713
658
  /**
@@ -745,16 +690,9 @@ export declare class ReplayController {
745
690
  * stops when that is before its end. For a host that steps through the plan
746
691
  * itself and must not have to pick the tool names out of a directive
747
692
  * written for a model — a reworded sentence would break every adapter in
748
- * the field. Built from the same entries the directive lists.
693
+ * the field. Built from the same steps the directive lists.
749
694
  */
750
695
  planData(state: ReplayState): PlanData | undefined;
751
- /**
752
- * Whether the plan's steps end in front of a decision (the service's
753
- * segmentsbreakdown.md SB-6): it runs the steps before it, then hands the
754
- * task back there. The control server gives such a plan a usage mark, so
755
- * that it is billed only up to the stop (SB-26).
756
- */
757
- stopsAtDecision(state: ReplayState): boolean;
758
696
  /**
759
697
  * Take back a plan that armed and has run nothing, as a decline.
760
698
  *
@@ -901,9 +839,9 @@ export declare class ReplayController {
901
839
  */
902
840
  postTool(state: ReplayState, toolUseId: string, output: string, failed?: string, how?: PostToolOptions): boolean;
903
841
  /**
904
- * A steer plan whose cursor reached its end: in front of a parked step (or a
905
- * call the service could not answer), which hands over; otherwise it ran to
906
- * its end — and from here every call is the model's own.
842
+ * A steer plan whose cursor reached its end: in front of a parked step,
843
+ * which hands over; otherwise it ran to its end — and from here every call
844
+ * is the model's own.
907
845
  */
908
846
  private finishSteer;
909
847
  /**
@@ -1044,10 +982,6 @@ export declare class ReplayController {
1044
982
  * there is nothing to be similar *to* and nothing to decline. It books no
1045
983
  * execution either — there is no ticket, and inventing a saving for a manual
1046
984
  * invocation is exactly the kind of number a ledger must never contain.
1047
- *
1048
- * One served stop is kept: a plan's decision (the service's
1049
- * segmentsbreakdown.md SB-6). The run stops in front of it, and `stopped`
1050
- * says so with the reason `decision`.
1051
985
  */
1052
986
  runAdHoc(scenario: SerializedScenario, prompt: string, wrapped: ReadonlySet<string>): Promise<{
1053
987
  ok: boolean;
@@ -1117,23 +1051,10 @@ export declare class ReplayController {
1117
1051
  */
1118
1052
  private handOver;
1119
1053
  /**
1120
- * Which kind of stop a plan's end is (plan-services.md D8). The stopping
1121
- * step's own served reason wins — the calculation's verdict is more exact
1122
- * than a counter — then a call the service could not answer, then a parked
1123
- * step.
1124
- */
1125
- private stopKindOf;
1126
- /**
1127
- * A divergence's bundle that ended at the plan's decision (SB-17): logged
1128
- * as `handOver` logs every other hand-over, and its usage mark taken there
1129
- * when a step of the plan ran (SB-26).
1130
- */
1131
- private decidedInDivergence;
1132
- /**
1133
- * The steps before flat position `bound` that ran, 1-based, and the ones
1134
- * their tests left out (AC5) — the plan cursor's own, and a divergence
1135
- * bundle's (`extra`). `left` is empty when no step was left out; `ran` is
1136
- * then not filled, since every step before `bound` ran.
1054
+ * The steps before position `bound` that ran, 1-based, and the ones their
1055
+ * tests left out (AC5) — the plan cursor's own, and a divergence bundle's
1056
+ * (`extra`). `left` is empty when no step was left out; `ran` is then not
1057
+ * filled, since every step before `bound` ran.
1137
1058
  */
1138
1059
  private ranBefore;
1139
1060
  private noteFor;
@@ -1242,12 +1163,6 @@ export declare class ReplayController {
1242
1163
  /** Outcomes only move forward; `not_steered` is the floor (§11.1). */
1243
1164
  private upgrade;
1244
1165
  private retire;
1245
- /**
1246
- * Where the plan's work ended, for its bill: now, and the transcript's
1247
- * watermark now. Taken at a completing retire, and at a decision hand-over
1248
- * after a step ran (the service's segmentsbreakdown.md SB-26).
1249
- */
1250
- private markRetired;
1251
1166
  }
1252
1167
  /** The subset of `RunMatch` replay consumes. Structural, so tests need no recorder. */
1253
1168
  export interface RunMatchLike {
@@ -1259,11 +1174,6 @@ export interface RunMatchLike {
1259
1174
  fallback?: {
1260
1175
  maxStepFailures: number;
1261
1176
  };
1262
- /** All absent from an older server (segmented.md R-COMPAT-2). */
1263
- kind?: "scenario" | "segment";
1264
- segment?: MatchSegment;
1265
- key?: MatchKey;
1266
- verified?: MatchVerdict;
1267
1177
  /** The frozen name, beside the runtime intent the plan runs on (R-INTENT-12). */
1268
1178
  intentName?: string;
1269
1179
  /** Why the service handed over no plan, when it says. Read for `runner_update_required`. */
@@ -1282,8 +1192,7 @@ export declare function knownBadStepIndex(steps: readonly SerializedScenarioStep
1282
1192
  *
1283
1193
  * A step that carries `stop` was decided by the service — `null` means run it,
1284
1194
  * even when its count is past the limit: its retry is due. A step without the
1285
- * field came from an older service, and the old counter rule decides it. Both
1286
- * can sit in one chain, since a called segment is served on its own terms.
1195
+ * field came from an older service, and the old counter rule decides it.
1287
1196
  */
1288
1197
  export declare function stopIndex(steps: readonly SerializedScenarioStep[], maxStepFailures: number | undefined): number | undefined;
1289
1198
  export {};