@basein/runner 0.3.2 → 0.3.4

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
@@ -347,6 +347,18 @@ bir scenario calc scn_… --force --discard-edits # recalculate this
347
347
  bir scenario editing on # let Claude's `bir` tools change scenarios here
348
348
  ```
349
349
 
350
+ **A value no prompt names.** Built 2026-09-28, not yet released. Every
351
+ parameter is a *target* (a prompt must name it, or the plan is declined:
352
+ `missing_target`) or a *setting* (its recorded value is used when a prompt
353
+ names none). When the calculation made a database, a host or a working folder a
354
+ target, the plan never runs. Make it a setting, with a note; a recalculation
355
+ keeps the change ([the guide's §9.2](docs/calculatedReplayGuide.md)):
356
+
357
+ ```bash
358
+ bir scenario edit scn_… --param database --kind setting --note "why" # use the recorded database when a prompt names none
359
+ bir scenario undo scn_… --param database # take that back
360
+ ```
361
+
350
362
  ---
351
363
 
352
364
  ## Two things to know before you turn it on
@@ -414,7 +426,7 @@ what to do when they register, so there is one switch and no second copy of it:
414
426
  | `BIR_REPLAY` | `0` turns replay off and any other value on, over the project's stored switch (`bir replay on` / `off`; a first plain `bir install` stores off). Nothing below matters while it is off. |
415
427
  | `BIR_REPLAY_ALLOW_SERVERS` | Server keys bir may call unattended: **direct** execution, and a steered plan's recovery. Unset means the list `bir replay allow` stored for the project, else every wrapped server; narrowing it is the recommendation. |
416
428
  | `BIR_BUILTIN_TOOLS` | Built-in tools bir may execute itself during a replay — in the session's own project directory, and confined to it. Default `Read,Glob,Grep` (read-only). Add `Edit,Write,Bash` only deliberately: a runner-side step never reaches Claude Code's permission prompts. `none` turns it off, and a built-in step hands the turn back as before. |
417
- | `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. |
429
+ | `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. A plan's Bash steps share a working directory, as Claude Code's do (variables and functions do not persist, also as in Claude Code). |
418
430
  | `BIR_MIN_STEER_SIMILARITY` | Minimum match similarity to replay (default `0.92`, above the service's own `0.9` detection threshold). |
419
431
  | `BIR_DERIVE_API_KEY` | **Optional.** Working out what a new request is about — which fleet, which file, which date — is done for you by the service on its own key, as long as you are signed in. Set this (an Anthropic key) only to keep that reading on your machine, on your key. Signed out *and* unset, a scenario whose values change between requests is declined rather than replayed on stale ones. |
420
432
  | `ANTHROPIC_API_KEY` | Not used by the background recorder, which has Claude Code's environment and would bill your own key — unless `BIR_USE_ANTHROPIC_API_KEY=1`. A `bir-hooks` run by hand in a terminal uses that terminal's key. |
@@ -442,6 +442,31 @@ function note(args) {
442
442
  throw new BadArguments("`note` must be a string");
443
443
  return ["--note", value];
444
444
  }
445
+ /** `--revision <n>`, the chainRevision a change was made against. */
446
+ function revision(args) {
447
+ const value = args.revision;
448
+ if (value === undefined || value === null)
449
+ return [];
450
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
451
+ throw new BadArguments("`revision` must be a whole number from 1");
452
+ }
453
+ return ["--revision", String(value)];
454
+ }
455
+ const PARAM = {
456
+ type: "string",
457
+ description: "A parameter's name, as scenario_show lists it under `parameters` (for example `database`).",
458
+ };
459
+ /** A parameter's name: one argv element after `--param`, so it cannot read as a flag. */
460
+ function param(args) {
461
+ const value = args.param;
462
+ if (value === undefined || value === null)
463
+ return undefined;
464
+ if (typeof value !== "string" || value.length === 0 || value.length > 200 || /[\u0000-\u001f\u007f]/.test(value)) {
465
+ throw new BadArguments("`param` must be a parameter's name, as scenario_show lists it");
466
+ }
467
+ return value;
468
+ }
469
+ const given = (value) => value !== undefined && value !== null && value !== false;
445
470
  const SCENARIO_TOOLS = [
446
471
  {
447
472
  name: "scenario_show",
@@ -462,8 +487,8 @@ const SCENARIO_TOOLS = [
462
487
  name: "scenario_edits",
463
488
  changes: false,
464
489
  service: true,
465
- description: "List a calculated scenario's hand edits, newest first: step, note, revision, and whether each was forced, " +
466
- "undone or replaced by a recalculation.",
490
+ description: "List a calculated scenario's hand edits, newest first — step edits and parameter kind changes: step or " +
491
+ "parameter, note, revision, and whether each was forced, undone or replaced by a recalculation.",
467
492
  inputSchema: {
468
493
  type: "object",
469
494
  properties: { target: TARGET },
@@ -532,12 +557,24 @@ const SCENARIO_TOOLS = [
532
557
  "meanwhile is not overwritten. " +
533
558
  "`force` saves what the check refused and is only for a recording that was itself wrong — never a way past a " +
534
559
  "failing check. A save can be taken back with scenario_undo, which the service checks too: it refuses an undo " +
535
- "that would break a later step, and a recalculation with --discard-edits replaces edits for good.",
560
+ "that would break a later step, and a recalculation with --discard-edits replaces edits for good. " +
561
+ "Instead of `step`, `param` with `kind` changes what a prompt must name: a target must be named or the plan is " +
562
+ "declined (missing_target); a setting uses its recorded value when a prompt names none — for a value that is the " +
563
+ "same every time, such as a database, a host or a working folder. Making one a setting needs `note`.",
536
564
  inputSchema: {
537
565
  type: "object",
538
566
  properties: {
539
567
  target: TARGET,
540
568
  step: STEP,
569
+ param: {
570
+ ...PARAM,
571
+ description: `${PARAM.description} Changes that parameter's kind (with \`kind\`) instead of a step. Not with \`step\`.`,
572
+ },
573
+ kind: {
574
+ type: "string",
575
+ enum: ["target", "setting"],
576
+ description: "With `param`: target (a prompt must name it) or setting (its recorded value is used when a prompt names none; needs `note`).",
577
+ },
541
578
  inputLogic: INPUT_LOGIC,
542
579
  outputLogic: OUTPUT_LOGIC,
543
580
  freeze: {
@@ -549,17 +586,34 @@ const SCENARIO_TOOLS = [
549
586
  type: "boolean",
550
587
  description: "Save although the check refused it. Needs `note`. Only when the recording itself was wrong.",
551
588
  },
552
- note: { type: "string", description: "Why the step is changed; kept in the history. Required with `force`." },
589
+ note: {
590
+ type: "string",
591
+ description: "Why the step (or the parameter's kind) is changed; kept in the history. Required with `force`, and to make a parameter a setting.",
592
+ },
553
593
  revision: {
554
594
  type: "integer",
555
595
  minimum: 1,
556
596
  description: "The chainRevision the change was made against — at the top of a scenario_show answer with `step`; the save is refused if the plan moved on.",
557
597
  },
558
598
  },
559
- required: ["target", "step"],
599
+ // `step`, or `param` with `kind`: which one is checked below, in words.
600
+ required: ["target"],
560
601
  additionalProperties: false,
561
602
  },
562
603
  plan: (a) => {
604
+ const name = param(a);
605
+ if (name !== undefined || given(a.kind)) {
606
+ // A parameter's kind (findoffer.md): nothing runs, so nothing of a step's belongs here.
607
+ if (name === undefined)
608
+ throw new BadArguments("`kind` needs `param`: the parameter to change");
609
+ if (a.kind !== "target" && a.kind !== "setting")
610
+ throw new BadArguments("`kind` must be target or setting");
611
+ for (const key of ["step", "inputLogic", "outputLogic", "freeze", "unfreeze", "force"]) {
612
+ if (given(a[key]))
613
+ throw new BadArguments(`\`param\` changes only a parameter's kind; \`${key}\` belongs to a step`);
614
+ }
615
+ return { argv: ["scenario", "edit", target(a), "--param", name, "--kind", a.kind, ...note(a), ...revision(a)] };
616
+ }
563
617
  if (a.freeze === true && a.unfreeze === true)
564
618
  throw new BadArguments("`freeze` and `unfreeze` ask for opposite things");
565
619
  const argv = [
@@ -571,13 +625,7 @@ const SCENARIO_TOOLS = [
571
625
  ...flag(a, "unfreeze", "--unfreeze"),
572
626
  ...flag(a, "force", "--force"),
573
627
  ];
574
- argv.push(...note(a));
575
- if (a.revision !== undefined && a.revision !== null) {
576
- if (typeof a.revision !== "number" || !Number.isInteger(a.revision) || a.revision < 1) {
577
- throw new BadArguments("`revision` must be a whole number from 1");
578
- }
579
- argv.push("--revision", String(a.revision));
580
- }
628
+ argv.push(...note(a), ...revision(a));
581
629
  return { argv, inputLogic: logic(a, "inputLogic"), outputLogic: logic(a, "outputLogic") };
582
630
  },
583
631
  },
@@ -585,19 +633,27 @@ const SCENARIO_TOOLS = [
585
633
  name: "scenario_undo",
586
634
  changes: true,
587
635
  service: true,
588
- description: "Take back a hand edit of a calculated scenario: its step goes back as it was before that edit. Without `edit`, " +
589
- "the newest edit that can be undone (of `step`, when given); calling it again goes one further back. The service " +
590
- "checks the undo like a save and refuses it when a later step or the final answer would break — `force` with a " +
591
- "`note` undoes it anyway. To redo, undo the undo: pass its sedit_ id as `edit` (the answer names it).",
636
+ description: "Take back a hand edit of a calculated scenario: its step (or a parameter's kind) goes back as it was before that " +
637
+ "edit. Without `edit`, the newest edit that can be undone (of `step` or `param`, when given); calling it again goes " +
638
+ "one further back. The service checks a step's undo like a save and refuses it when a later step or the final " +
639
+ "answer would break — `force` with a `note` undoes it anyway. To redo, undo the undo: pass its id as `edit` (the " +
640
+ "answer names it).",
592
641
  inputSchema: {
593
642
  type: "object",
594
643
  properties: {
595
644
  target: TARGET,
596
- edit: { type: "string", description: "The sedit_ id to undo, as scenario_edits lists it. Not with `step`." },
645
+ edit: {
646
+ type: "string",
647
+ description: "The sedit_ or pedit_ id to undo, as scenario_edits lists it. Not with `step` or `param`.",
648
+ },
597
649
  step: {
598
650
  ...STEP,
599
651
  description: "Undo the newest edit of this step (its stepIndex, as scenario_show prints it). Not with `edit`.",
600
652
  },
653
+ param: {
654
+ ...PARAM,
655
+ description: `${PARAM.description} Undo the newest kind change of this parameter. Not with \`edit\` or \`step\`.`,
656
+ },
601
657
  force: {
602
658
  type: "boolean",
603
659
  description: "Undo although the check refused it (a later step or the answer would break). Needs `note`.",
@@ -609,15 +665,21 @@ const SCENARIO_TOOLS = [
609
665
  },
610
666
  plan: (a) => {
611
667
  const argv = ["scenario", "undo", target(a)];
668
+ const name = param(a);
669
+ if (name !== undefined && (given(a.edit) || given(a.step))) {
670
+ throw new BadArguments("`edit`, `step` and `param` each pick what to undo; give one of them");
671
+ }
612
672
  if (a.edit !== undefined && a.edit !== null) {
613
- if (typeof a.edit !== "string" || !/^sedit_[\w-]+$/.test(a.edit)) {
614
- throw new BadArguments("`edit` must be a sedit_… id");
673
+ if (typeof a.edit !== "string" || !/^(sedit|pedit)_[\w-]+$/.test(a.edit)) {
674
+ throw new BadArguments("`edit` must be a sedit_… or pedit_… id");
615
675
  }
616
676
  if (a.step !== undefined && a.step !== null) {
617
677
  throw new BadArguments("`edit` names one entry and `step` picks one; give one of them");
618
678
  }
619
679
  argv.push("--edit", a.edit);
620
680
  }
681
+ if (name !== undefined)
682
+ argv.push("--param", name);
621
683
  argv.push(...step(a, false), ...flag(a, "force", "--force"), ...note(a));
622
684
  return { argv };
623
685
  },
package/dist/bin/bir.d.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * bir scenario list | show <runId|scnId> [--step <n>] | calc <runId|scnId> [--force [--discard-edits]]
17
17
  * bir scenario check|edit <runId|scnId> --step <n> --input-logic <file|-> … ← fix one step
18
- * bir scenario edits | undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
18
+ * bir scenario edit <runId|scnId> --param <name> --kind target|setting … ← what a prompt must name
19
+ * bir scenario edits | undo <runId|scnId> [--edit <id> | --step <n> | --param <name>] [--force --note "why"]
19
20
  * bir scenario editing on|off|status ← may this project's `bir` MCP server change a scenario?
20
21
  * bir scenario replay <scnId> --prompt "…" [--dry] | bir replay status|on|off|allow …
21
22
  * bir investigate [<id>] | list | executions
package/dist/bin/bir.js CHANGED
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * bir scenario list | show <runId|scnId> [--step <n>] | calc <runId|scnId> [--force [--discard-edits]]
17
17
  * bir scenario check|edit <runId|scnId> --step <n> --input-logic <file|-> … ← fix one step
18
- * bir scenario edits | undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
18
+ * bir scenario edit <runId|scnId> --param <name> --kind target|setting … ← what a prompt must name
19
+ * bir scenario edits | undo <runId|scnId> [--edit <id> | --step <n> | --param <name>] [--force --note "why"]
19
20
  * bir scenario editing on|off|status ← may this project's `bir` MCP server change a scenario?
20
21
  * bir scenario replay <scnId> --prompt "…" [--dry] | bir replay status|on|off|allow …
21
22
  * bir investigate [<id>] | list | executions
@@ -109,10 +110,14 @@ Commands:
109
110
  scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
110
111
  [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
111
112
  the same check, and save the change if it passes
113
+ scenario edit <runId|scnId> --param <name> --kind target|setting [--note "why"]
114
+ a target must be named by the prompt or the plan is
115
+ declined; a setting uses its recorded value when the
116
+ prompt names none (needs --note)
112
117
  scenario edits <runId|scnId> a scenario's hand edits, newest first
113
- scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
114
- take back the newest edit (of step n); run it again to go
115
- one further back; undo an undo's sedit_ id to redo
118
+ scenario undo <runId|scnId> [--edit <id> | --step <n> | --param <name>] [--force --note "why"]
119
+ take back the newest edit (of step n, or parameter name);
120
+ run it again to go one further back; undo an undo's id to redo
116
121
  scenario editing on|off|status whether this project's bir MCP server offers the tools
117
122
  that change a scenario (restart Claude Code after)
118
123
  scenario replay <scnId> --prompt "…" [--dry]
@@ -156,7 +161,10 @@ Options:
156
161
  --unfreeze scenario check|edit: ask to remove that mark; the check decides
157
162
  --note <text> scenario edit|undo: why, kept in the history (required with --force)
158
163
  --revision <n> scenario edit|undo: only if the plan is still at this chainRevision
159
- --edit <id> scenario undo: the sedit_ id to undo (default: the newest edit that can be)
164
+ --edit <id> scenario undo: the sedit_ or pedit_ id to undo (default: the newest that can be)
165
+ --param <name> scenario edit: the parameter whose kind changes · scenario undo: its newest change
166
+ --kind <kind> scenario edit --param: target (a prompt must name it) or setting (its
167
+ recorded value is used when a prompt names none; needs --note)
160
168
  --discard-edits scenario calc --force: recalculate a plan with hand edits, replacing them
161
169
  --no-browser login: print the link and code, open nothing (SSH, headless)
162
170
  --token <value> login: redeem a one-time setup token from the console (no browser)
@@ -355,6 +363,18 @@ function parseArgs(argv) {
355
363
  case "--discard-edits":
356
364
  args.discardEdits = true;
357
365
  break;
366
+ case "--param":
367
+ // A value flag, like --note: the name is whatever follows, and
368
+ // scenario-edit.ts says what is wrong with it in words.
369
+ args.param = argv[++i];
370
+ if (!args.param)
371
+ usage(2);
372
+ break;
373
+ case "--kind":
374
+ args.kind = argv[++i];
375
+ if (!args.kind)
376
+ usage(2);
377
+ break;
358
378
  case "--here":
359
379
  args.here = true;
360
380
  break;
@@ -3374,6 +3394,8 @@ async function scenarioCommand(args) {
3374
3394
  edit: args.edit,
3375
3395
  revision: args.revision,
3376
3396
  discardEdits: args.discardEdits,
3397
+ param: args.param,
3398
+ kind: args.kind,
3377
3399
  }, {
3378
3400
  cwd: process.cwd(),
3379
3401
  out,
@@ -191,8 +191,8 @@ export const DECLINES = {
191
191
  fix: "enable or recalculate that sub-task, or recalculate this scenario.",
192
192
  },
193
193
  missing_target: {
194
- cause: "the prompt names a target the runner could not find where the recording had it.",
195
- fix: "run from the recording's directory, or name the target as the recording did.",
194
+ cause: "the plan needs a target — a value a prompt must name, such as a file, a table or a region — and the prompt did not name it. A replay never takes a target from the recording.",
195
+ fix: 'name it in the prompt. If the value is the same every time — a database, a host, a working folder — it is a setting, not a target: `bir scenario edit <scnId> --param <name> --kind setting --note "why"` lets the runner use its recorded value when a prompt names none (`bir scenario show <scnId>` lists the parameters and their kinds).',
196
196
  },
197
197
  no_derive_key: {
198
198
  cause: "the scenario has parameters and the runner had no way to derive them: no service session and no ANTHROPIC_API_KEY.",
@@ -203,6 +203,22 @@ export const DECLINES = {
203
203
  fix: "recalculate the scenario.",
204
204
  },
205
205
  };
206
+ /**
207
+ * The `missing_target` fix with what this turn knows put in: the scenario, and
208
+ * the target the gate named (`target database not found`). The code alone once
209
+ * sent a reader after a lookup that failed, when the prompt had simply not
210
+ * named the value.
211
+ */
212
+ function missingTargetFix(fix, turn) {
213
+ let text = fix;
214
+ const scenarioId = turn.matched?.scenarioId ?? turn.plan?.scenarioId;
215
+ if (scenarioId)
216
+ text = text.replaceAll("<scnId>", scenarioId);
217
+ const name = /\btarget (\S+) not found/.exec(turn.decision?.why ?? "")?.[1];
218
+ if (name)
219
+ text = text.replaceAll("<name>", name);
220
+ return text;
221
+ }
206
222
  export const LOW_SAVING_PCT = 0.3;
207
223
  const usd = (v) => `$${v.toFixed(4)}`;
208
224
  const pct = (v) => `${(v * 100).toFixed(1)}%`;
@@ -225,7 +241,7 @@ export function localFindings(turn) {
225
241
  severity: code === "not_ready" && !turn.matched.scenarioId ? "info" : "problem",
226
242
  title: `Matched ${turn.matched.runId} but declined to steer: ${turn.decision.why ?? code}.`,
227
243
  cause: known?.cause ?? "the runner's log line carries the reason text.",
228
- fix: known?.fix ??
244
+ fix: (known && code === "missing_target" ? missingTargetFix(known.fix, turn) : known?.fix) ??
229
245
  (turn.matched.scenarioId
230
246
  ? "read the `replay.decision` line above."
231
247
  : "the matched recording has no scenario yet — calculate it, or let the service pick it up after enough hits."),
@@ -6,8 +6,9 @@
6
6
  * bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
7
7
  * bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
8
8
  * [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
9
+ * bir scenario edit <runId|scnId> --param <name> --kind target|setting [--note "why"] [--revision <n>]
9
10
  * bir scenario edits <runId|scnId>
10
- * bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
11
+ * bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id> | --step <n> | --param <name>] [--force --note "why"]
11
12
  * bir scenario calc <runId|scnId> [--force [--discard-edits]]
12
13
  * bir scenario editing on|off|status
13
14
  *
@@ -23,6 +24,12 @@
23
24
  * of quotes, braces and `$`, and every shell mangles a different subset of
24
25
  * them; a file, or stdin with `-`, arrives byte for byte.
25
26
  *
27
+ * A parameter's kind (`--param <name> --kind target|setting`, findoffer.md in
28
+ * the BaseIn repository) is the one change here that runs no code: a target
29
+ * is a value a prompt must name, and the plan is declined without it; a
30
+ * setting takes its recorded value when a prompt names none. Making one a
31
+ * setting needs a note, because it is exactly what a target forbids.
32
+ *
26
33
  * Everything the commands touch is injected ({@link ScenarioEditDeps}), as in
27
34
  * `investigate.ts`, so all of it is tested in-process against a fake service.
28
35
  * The exception is `editing`, which writes the install sidecar — tests point
@@ -107,6 +114,30 @@ export interface EditEntry {
107
114
  /** A recalculation replaced the chain; the entry is history now. */
108
115
  replacedAt: string | null;
109
116
  }
117
+ /** A parameter's kind: a prompt must name a target; a setting takes its recorded value when none does. */
118
+ export type ParamKind = "target" | "setting";
119
+ /** A parameter's kind changed by hand, or such a change undone: the service's `paramEdits` (findoffer.md). */
120
+ export interface ParamEditEntry {
121
+ id: string;
122
+ scenarioId: string;
123
+ param: string;
124
+ kind: "edit" | "revert";
125
+ revertsEditId: string | null;
126
+ note: string | null;
127
+ chainRevisionBefore: number;
128
+ chainRevisionAfter: number;
129
+ /** `null` is no kind, which reads as a target. */
130
+ before: {
131
+ kind: ParamKind | null;
132
+ };
133
+ after: {
134
+ kind: ParamKind | null;
135
+ };
136
+ createdAt: string;
137
+ revertedAt: string | null;
138
+ /** A recalculation dropped the parameter; the entry is history now. */
139
+ replacedAt: string | null;
140
+ }
110
141
  export type ServiceReply = {
111
142
  status: number;
112
143
  body: unknown;
@@ -135,11 +166,15 @@ export interface ScenarioEditArgs {
135
166
  freeze: boolean;
136
167
  unfreeze: boolean;
137
168
  note?: string;
138
- /** `undo --edit`: the `sedit_` id to put back. `undo --step` is {@link step}. */
169
+ /** `undo --edit`: the `sedit_` (or `pedit_`) id to put back. `undo --step` is {@link step}. */
139
170
  edit?: string;
140
171
  /** `--revision`: save only if the plan is still at this `chainRevision`. */
141
172
  revision?: number;
142
173
  discardEdits: boolean;
174
+ /** `edit --param <name>`: the parameter whose kind changes; `undo --param`: its newest change. */
175
+ param?: string;
176
+ /** `edit --kind target|setting`, with `--param`. Checked here, so any word reaches the usage line. */
177
+ kind?: string;
143
178
  }
144
179
  /** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
145
180
  export declare const SCENARIO_EDIT_SUBCOMMANDS: ReadonlySet<string>;
@@ -202,5 +237,7 @@ export declare function around(text: string, at: number): string;
202
237
  * question, not this list's: it answers `not_latest_edit` when it is not.
203
238
  */
204
239
  export declare function newestUndoable(edits: EditEntry[], step?: number): EditEntry | undefined;
240
+ /** {@link newestUndoable} for kind changes: the newest that stands, of `param` when it is given. */
241
+ export declare function newestUndoableParam(paramEdits: ParamEditEntry[], param?: string): ParamEditEntry | undefined;
205
242
  export {};
206
243
  //# sourceMappingURL=scenario-edit.d.ts.map
@@ -6,8 +6,9 @@
6
6
  * bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
7
7
  * bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
8
8
  * [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
9
+ * bir scenario edit <runId|scnId> --param <name> --kind target|setting [--note "why"] [--revision <n>]
9
10
  * bir scenario edits <runId|scnId>
10
- * bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
11
+ * bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id> | --step <n> | --param <name>] [--force --note "why"]
11
12
  * bir scenario calc <runId|scnId> [--force [--discard-edits]]
12
13
  * bir scenario editing on|off|status
13
14
  *
@@ -23,6 +24,12 @@
23
24
  * of quotes, braces and `$`, and every shell mangles a different subset of
24
25
  * them; a file, or stdin with `-`, arrives byte for byte.
25
26
  *
27
+ * A parameter's kind (`--param <name> --kind target|setting`, findoffer.md in
28
+ * the BaseIn repository) is the one change here that runs no code: a target
29
+ * is a value a prompt must name, and the plan is declined without it; a
30
+ * setting takes its recorded value when a prompt names none. Making one a
31
+ * setting needs a note, because it is exactly what a target forbids.
32
+ *
26
33
  * Everything the commands touch is injected ({@link ScenarioEditDeps}), as in
27
34
  * `investigate.ts`, so all of it is tested in-process against a fake service.
28
35
  * The exception is `editing`, which writes the install sidecar — tests point
@@ -56,9 +63,10 @@ const USAGE = {
56
63
  show: "usage: bir scenario show <runId|scnId> [--step <n>]",
57
64
  check: "usage: bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze] [--json]",
58
65
  edit: "usage: bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]\n" +
59
- ' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]',
66
+ ' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]\n' +
67
+ ' bir scenario edit <runId|scnId> --param <name> --kind target|setting [--note "why"] [--revision <n>] [--json]',
60
68
  edits: "usage: bir scenario edits <runId|scnId> [--json]",
61
- undo: 'usage: bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"] [--json]',
69
+ undo: 'usage: bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id> | --step <n> | --param <name>] [--force --note "why"] [--json]',
62
70
  calc: "usage: bir scenario calc <runId|scnId> [--force [--discard-edits]]",
63
71
  editing: "usage: bir scenario editing on|off|status",
64
72
  };
@@ -141,8 +149,16 @@ async function checkOrEdit(args, deps, save) {
141
149
  const target = args.positionals[1];
142
150
  if (!target)
143
151
  return usageError(deps, sub);
152
+ if (args.param !== undefined || args.kind !== undefined) {
153
+ if (!save) {
154
+ return usageError(deps, sub, "A parameter's kind is not checked: no code runs. Save it with `bir scenario edit <id> --param <name> --kind target|setting`.");
155
+ }
156
+ return editParam(target, args, deps);
157
+ }
144
158
  if (args.step === undefined) {
145
- return usageError(deps, sub, "Which step? Give --step <n>, the stepIndex `bir scenario show` prints.");
159
+ return usageError(deps, sub, save
160
+ ? "Which step? Give --step <n>, the stepIndex `bir scenario show` prints — or --param <name> --kind target|setting to change a parameter."
161
+ : "Which step? Give --step <n>, the stepIndex `bir scenario show` prints.");
146
162
  }
147
163
  if (!save && (args.freeze || args.force)) {
148
164
  return usageError(deps, sub, "`check` never saves, so --freeze and --force belong to `bir scenario edit`.");
@@ -254,6 +270,90 @@ async function checkOrEdit(args, deps, save) {
254
270
  }
255
271
  return 0;
256
272
  }
273
+ /** A kind in the words the history uses; no kind reads as a target, as it does in the gate. */
274
+ const kindWord = (kind) => (kind ? kind : "no kind (a target)");
275
+ /** What the new kind means for the next prompt, in one line. */
276
+ function kindMeaning(name, kind, sample) {
277
+ if (kind === "setting") {
278
+ const text = JSON.stringify(sample);
279
+ const shown = text === undefined ? "" : ` (${text.length > 60 ? `${text.slice(0, 59)}…` : text})`;
280
+ return `When a prompt names no ${name}, the runner now uses its recorded value${shown}; a prompt that names one still wins.`;
281
+ }
282
+ return `A prompt must now name ${name}, or the runner declines the plan (missing_target).`;
283
+ }
284
+ /**
285
+ * `edit --param <name> --kind target|setting` (findoffer.md). Nothing runs, so
286
+ * there is no check to print: the service answers with the change, and this
287
+ * says what it means for the next prompt. Every flag that belongs to a step is
288
+ * refused before any request, and so is a setting without a note — the one
289
+ * direction that lets a replay use a recorded value.
290
+ */
291
+ async function editParam(target, args, deps) {
292
+ const { out } = deps;
293
+ const name = args.param;
294
+ if (name === undefined || name === "") {
295
+ return usageError(deps, "edit", "--kind needs --param <name>: the parameter to change, as `bir scenario show` lists it.");
296
+ }
297
+ if (args.step !== undefined) {
298
+ return usageError(deps, "edit", "--param changes a parameter and --step a step; give one of them.");
299
+ }
300
+ if (args.inputLogic || args.outputLogic || args.freeze || args.unfreeze || args.force) {
301
+ return usageError(deps, "edit", "--param changes only a parameter's kind; --input-logic, --output-logic, --freeze, --unfreeze and --force belong to a step (--step).");
302
+ }
303
+ if (args.kind !== "target" && args.kind !== "setting") {
304
+ return usageError(deps, "edit", args.kind === undefined ? "Which kind? Give --kind setting or --kind target." : `--kind is target or setting; got ${args.kind}.`);
305
+ }
306
+ const kind = args.kind;
307
+ const note = args.note?.trim() ? args.note : undefined;
308
+ if (kind === "setting" && !note) {
309
+ return usageError(deps, "edit", '--kind setting needs --note "why" (note_required): a setting lets the runner use the recorded value when a prompt names none, and the history keeps why.');
310
+ }
311
+ const resolved = await resolveTarget(target, args, deps);
312
+ if ("code" in resolved)
313
+ return resolved.code;
314
+ const body = { kind };
315
+ if (note)
316
+ body.note = note;
317
+ if (args.revision !== undefined)
318
+ body.expectedRevision = args.revision;
319
+ const reply = await deps.service("PATCH", `/scenarios/${seg(resolved.id)}/params/${seg(name)}`, body);
320
+ if ("error" in reply || reply.status !== 200)
321
+ return explainParam(reply, args, deps, target, "Not saved");
322
+ if (args.json) {
323
+ out(jsonText(reply.body));
324
+ return 0;
325
+ }
326
+ const answer = reply.body;
327
+ const edit = answer?.edit;
328
+ const revision = answer?.chainRevision ?? edit?.chainRevisionAfter;
329
+ out(`Parameter ${name}: ${kindWord(edit?.before.kind)} → ${kindWord(edit?.after.kind ?? kind)} (revision ${revision ?? "?"}).` +
330
+ (edit?.id ? ` Undo: bir scenario undo ${target} --edit ${edit.id}` : ""));
331
+ out(` ${kindMeaning(name, edit?.after.kind ?? kind, answer?.parameter?.sampleValue)}`);
332
+ return 0;
333
+ }
334
+ /**
335
+ * The answers a kind change has words of its own for; everything else as
336
+ * {@link explain} says it. A 404 here is most often a name that is not the
337
+ * scenario's, so it says where the names are.
338
+ */
339
+ function explainParam(reply, args, deps, target, verb) {
340
+ if (!("error" in reply) && !args.json) {
341
+ const code = reply.body?.error;
342
+ if (reply.status === 404) {
343
+ deps.out(`${verb} (not_found): ${target} has no parameter ${args.param}, or it is not yours. \`bir scenario show ${target}\` lists its parameters.`);
344
+ return 1;
345
+ }
346
+ if (code === "nothing_to_change") {
347
+ deps.out(`Nothing to change: ${args.param} is already a ${args.kind}.`);
348
+ return 1;
349
+ }
350
+ if (code === "note_required") {
351
+ deps.out(`${verb} (note_required): making a parameter a setting needs --note "why".`);
352
+ return 1;
353
+ }
354
+ }
355
+ return explain(reply, args, deps, { verb, target, param: args.param });
356
+ }
257
357
  /** What `check` printed, turned into the `edit` that would save it. */
258
358
  function editCommand(target, args) {
259
359
  const parts = ["bir scenario edit", target, "--step", String(args.step)];
@@ -495,12 +595,19 @@ function refusal(out, report, opening, hint) {
495
595
  out(hint);
496
596
  }
497
597
  // ── edits / undo ──────────────────────────────────────────────────────────────
598
+ /** The service's history: step edits, and — from a service that has them — kind changes. */
599
+ function historyOf(body) {
600
+ const b = body;
601
+ return {
602
+ edits: Array.isArray(b?.edits) ? b.edits : [],
603
+ paramEdits: Array.isArray(b?.paramEdits) ? b.paramEdits : [],
604
+ };
605
+ }
498
606
  async function fetchEdits(scenarioId, deps) {
499
607
  const reply = await readWithin(deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`));
500
608
  if ("error" in reply || reply.status !== 200)
501
609
  return { reply };
502
- const edits = reply.body?.edits;
503
- return { edits: Array.isArray(edits) ? edits : [] };
610
+ return historyOf(reply.body);
504
611
  }
505
612
  async function listEdits(args, deps) {
506
613
  const { out } = deps;
@@ -521,21 +628,46 @@ async function listEdits(args, deps) {
521
628
  out(jsonText(reply.body));
522
629
  return 0;
523
630
  }
524
- const edits = reply.body?.edits ?? [];
525
- if (edits.length === 0) {
631
+ const { edits, paramEdits } = historyOf(reply.body);
632
+ if (edits.length === 0 && paramEdits.length === 0) {
526
633
  out(`No hand edits of ${target}.`);
527
634
  return 0;
528
635
  }
529
636
  // Ids are printed whole: they are what `undo --edit` takes. The detail line
530
637
  // is indented rather than aligned under the date, which a 42-character id
531
- // would push past most terminals' width.
532
- for (const e of edits) {
533
- const note = e.note ? ` "${e.note.replace(/\s+/g, " ")}"` : "";
534
- out(`${e.id} ${when(e.createdAt)} step ${e.stepIndex} ${String(e.kind).padEnd(7)} revision ${e.chainRevisionBefore} → ${e.chainRevisionAfter}${note}`);
535
- out(` ${editSummary(e)}`);
638
+ // would push past most terminals' width. One list, newest first: a step's
639
+ // edits and a parameter's kind changes are walked back by the same `undo`.
640
+ const noteOf = (note) => (note ? ` "${note.replace(/\s+/g, " ")}"` : "");
641
+ const rows = [
642
+ ...edits.map((e) => ({
643
+ at: e.createdAt,
644
+ line: `${e.id} ${when(e.createdAt)} step ${e.stepIndex} ${String(e.kind).padEnd(7)} revision ${e.chainRevisionBefore} → ${e.chainRevisionAfter}${noteOf(e.note)}`,
645
+ detail: editSummary(e),
646
+ })),
647
+ ...paramEdits.map((p) => ({
648
+ at: p.createdAt,
649
+ line: `${p.id} ${when(p.createdAt)} param ${p.param} ${String(p.kind).padEnd(7)} revision ${p.chainRevisionBefore} → ${p.chainRevisionAfter}${noteOf(p.note)}`,
650
+ detail: paramSummary(p),
651
+ })),
652
+ ].sort((x, y) => (x.at < y.at ? 1 : x.at > y.at ? -1 : 0));
653
+ for (const r of rows) {
654
+ out(r.line);
655
+ out(` ${r.detail}`);
536
656
  }
537
657
  return 0;
538
658
  }
659
+ /** The second line of a kind change in `edits`: the kinds, and what became of it. */
660
+ function paramSummary(p) {
661
+ const parts = [];
662
+ if (p.kind === "revert" && p.revertsEditId)
663
+ parts.push(`undoes ${p.revertsEditId}`);
664
+ parts.push(`${kindWord(p.before?.kind)} → ${kindWord(p.after?.kind)}`);
665
+ if (p.revertedAt)
666
+ parts.push(`undone ${when(p.revertedAt)}`);
667
+ if (p.replacedAt)
668
+ parts.push(`the parameter was dropped by a recalculation ${when(p.replacedAt)}`);
669
+ return parts.join("; ");
670
+ }
539
671
  /** The second line of an `edits` entry: what the edit changed, and what became of it. */
540
672
  function editSummary(e) {
541
673
  const parts = [];
@@ -584,16 +716,29 @@ export function newestUndoable(edits, step) {
584
716
  !e.replacedAt &&
585
717
  (step === undefined || e.stepIndex === step));
586
718
  }
719
+ /** {@link newestUndoable} for kind changes: the newest that stands, of `param` when it is given. */
720
+ export function newestUndoableParam(paramEdits, param) {
721
+ return [...paramEdits]
722
+ .sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
723
+ .find((e) => e.kind !== "revert" && !e.revertedAt && !e.replacedAt && (param === undefined || e.param === param));
724
+ }
725
+ const isParamEditId = (id) => id.startsWith("pedit_");
587
726
  async function undo(args, deps) {
588
727
  const { out } = deps;
589
728
  const target = args.positionals[1];
590
729
  if (!target)
591
730
  return usageError(deps, "undo");
592
- if (args.edit !== undefined && !/^sedit_[\w-]+$/.test(args.edit)) {
593
- return usageError(deps, "undo", `--edit takes a sedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
731
+ if (args.edit !== undefined && !/^(sedit|pedit)_[\w-]+$/.test(args.edit)) {
732
+ return usageError(deps, "undo", `--edit takes a sedit_… or pedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
733
+ }
734
+ const picks = [args.edit, args.step, args.param].filter((v) => v !== undefined).length;
735
+ if (picks > 1) {
736
+ return usageError(deps, "undo", args.param === undefined
737
+ ? "--edit names one entry and --step picks one; give one of them."
738
+ : "--edit names one entry, --step picks a step's newest edit and --param a parameter's; give one of them.");
594
739
  }
595
- if (args.edit !== undefined && args.step !== undefined) {
596
- return usageError(deps, "undo", "--edit names one entry and --step picks one; give one of them.");
740
+ if (args.kind !== undefined) {
741
+ return usageError(deps, "undo", "An undo puts back the kind that was there; --kind belongs to `bir scenario edit --param`.");
597
742
  }
598
743
  const note = args.note?.trim() ? args.note : undefined;
599
744
  if (args.force && !note) {
@@ -609,11 +754,17 @@ async function undo(args, deps) {
609
754
  const listed = await fetchEdits(resolved.id, deps);
610
755
  if ("reply" in listed)
611
756
  return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
612
- const pick = newestUndoable(listed.edits, args.step);
757
+ // Undo walks back across both lists (D9): the newest change of either
758
+ // kind goes first, unless --step or --param narrows it to one.
759
+ const stepPick = args.param === undefined ? newestUndoable(listed.edits, args.step) : undefined;
760
+ const paramPick = args.step === undefined ? newestUndoableParam(listed.paramEdits, args.param) : undefined;
761
+ const pick = !stepPick ? paramPick : !paramPick ? stepPick : paramPick.createdAt > stepPick.createdAt ? paramPick : stepPick;
613
762
  if (!pick) {
614
- return localFailure(args, deps, "nothing_to_undo", args.step === undefined
615
- ? `Nothing to undo: ${target} has no hand edit that can be undone.`
616
- : `Nothing to undo: step ${args.step} of ${target} has no hand edit that can be undone.`);
763
+ return localFailure(args, deps, "nothing_to_undo", args.step !== undefined
764
+ ? `Nothing to undo: step ${args.step} of ${target} has no hand edit that can be undone.`
765
+ : args.param !== undefined
766
+ ? `Nothing to undo: parameter ${args.param} of ${target} has no kind change that can be undone.`
767
+ : `Nothing to undo: ${target} has no hand edit that can be undone.`);
617
768
  }
618
769
  editId = pick.id;
619
770
  }
@@ -633,12 +784,15 @@ async function undo(args, deps) {
633
784
  target,
634
785
  editId,
635
786
  notFound: `Not undone (not_found): ${target} has no edit ${editId}, or it is not yours.`,
787
+ param: args.param,
636
788
  });
637
789
  }
638
790
  if (args.json) {
639
791
  out(jsonText(reply.body));
640
792
  return refused ? 1 : 0;
641
793
  }
794
+ if (isParamEditId(editId))
795
+ return paramUndone(reply.body, editId, target, args, deps);
642
796
  if (answer?.report)
643
797
  renderReport(out, answer.report, { brief: true, undo: true });
644
798
  if (refused && answer?.report) {
@@ -666,6 +820,22 @@ async function undo(args, deps) {
666
820
  }
667
821
  return 0;
668
822
  }
823
+ /**
824
+ * A kind change undone. Nothing ran, so there is no report: the parameter's
825
+ * kind as it is now, what that means for the next prompt, and the way on —
826
+ * redo, or one further back (of that parameter, when one was named).
827
+ */
828
+ function paramUndone(answer, editId, target, args, deps) {
829
+ const entry = answer?.edit;
830
+ const revision = answer?.chainRevision ?? entry?.chainRevisionAfter;
831
+ const name = entry?.param ?? answer?.parameter?.name ?? args.param ?? "?";
832
+ deps.out(`Parameter ${name} is back to ${kindWord(entry?.after.kind)}, as it was before ${entry?.revertsEditId ?? editId} (revision ${revision ?? "?"}).` +
833
+ (entry?.id ? ` Redo: bir scenario undo ${target} --edit ${entry.id}` : ""));
834
+ deps.out(` ${kindMeaning(name, entry?.after.kind, answer?.parameter?.sampleValue)}`);
835
+ const further = args.param !== undefined || args.edit !== undefined ? ` --param ${name}` : "";
836
+ deps.out(` One further back: bir scenario undo ${target}${further}`);
837
+ return 0;
838
+ }
669
839
  // ── calc ──────────────────────────────────────────────────────────────────────
670
840
  /**
671
841
  * `calc` lives here because of one answer: a recalculation rebuilds every
@@ -885,6 +1055,8 @@ function explain(reply, args, deps, e) {
885
1055
  const code = typeof body.error === "string" ? body.error : "";
886
1056
  const target = e.target ?? "<id>";
887
1057
  const stepFlag = e.step !== undefined ? ` --step ${e.step}` : "";
1058
+ // A kind change (findoffer.md) is about a parameter, and nothing in it is checked.
1059
+ const aboutParam = e.param !== undefined || (e.editId?.startsWith("pedit_") ?? false);
888
1060
  if (reply.status === 404) {
889
1061
  out(e.notFound ??
890
1062
  `${e.verb} (not_found): no such scenario, step or edit — or it is not yours. Only a scenario's owner can see or change it.`);
@@ -928,7 +1100,9 @@ function explain(reply, args, deps, e) {
928
1100
  return 1;
929
1101
  case "revision_changed": {
930
1102
  const now = typeof body.chainRevision === "number" ? ` — it is at revision ${body.chainRevision} now` : "";
931
- out(`${e.verb} (revision_changed): the plan changed since it was read${now}. Show it again (\`bir scenario show ${target}${stepFlag}\`), check again, then save again.`);
1103
+ out(aboutParam
1104
+ ? `${e.verb} (revision_changed): the plan changed since it was read${now}. Show it again (\`bir scenario show ${target}\`), then try again.`
1105
+ : `${e.verb} (revision_changed): the plan changed since it was read${now}. Show it again (\`bir scenario show ${target}${stepFlag}\`), check again, then save again.`);
932
1106
  return 1;
933
1107
  }
934
1108
  case "not_latest_edit": {
@@ -936,7 +1110,7 @@ function explain(reply, args, deps, e) {
936
1110
  // newer entry puts the step back as this one left it, and then this one
937
1111
  // can be undone. It used to loop, when undo only toggled (C23).
938
1112
  const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
939
- out(`${e.verb} (not_latest_edit): the step has changed since that edit — ${latest} changed it after. Undo the newer one first, then this one:`);
1113
+ out(`${e.verb} (not_latest_edit): the ${aboutParam ? "parameter" : "step"} has changed since that edit — ${latest} changed it after. Undo the newer one first, then this one:`);
940
1114
  out(` bir scenario undo ${target} --edit ${latest}`);
941
1115
  out(` bir scenario undo ${target} --edit ${e.editId ?? "<this edit>"}`);
942
1116
  return 1;
@@ -948,7 +1122,9 @@ function explain(reply, args, deps, e) {
948
1122
  return 1;
949
1123
  }
950
1124
  case "edit_replaced":
951
- out(`${e.verb} (edit_replaced): a recalculation replaced the plan after that edit. It stays in \`bir scenario edits\`, but there is nothing left to undo.`);
1125
+ out(aboutParam
1126
+ ? `${e.verb} (edit_replaced): a recalculation dropped that parameter after the change. It stays in \`bir scenario edits\`, but there is nothing left to undo.`
1127
+ : `${e.verb} (edit_replaced): a recalculation replaced the plan after that edit. It stays in \`bir scenario edits\`, but there is nothing left to undo.`);
952
1128
  return 1;
953
1129
  case "rate_limited": {
954
1130
  const ms = typeof body.retryAfterMs === "number" ? body.retryAfterMs : undefined;
@@ -94,6 +94,21 @@ export interface BuiltinContext {
94
94
  timeoutMs?: number;
95
95
  /** Parsed `BIR_BASH_ALLOW` ({@link parseBashAllow}). */
96
96
  bashAllow?: readonly string[];
97
+ /**
98
+ * Where this plan's *previous* `Bash` step left its shell — the host's own
99
+ * semantics, which a recorded chain was written against: the working
100
+ * directory persists between Bash calls, while variables and functions do
101
+ * not (each call is a fresh shell off the profile). Absent, or pointing at
102
+ * a directory that no longer exists, the step starts in {@link cwd}.
103
+ */
104
+ bashCwd?: string;
105
+ /**
106
+ * Told where a `Bash` step's shell ended up (`$PWD`, as a path the next
107
+ * spawn can use), so the caller can carry it into the plan's next Bash
108
+ * step. Best effort: a command that `exit`s explicitly, or is cut off by
109
+ * its clock, reports nothing and the previous directory stands.
110
+ */
111
+ onBashCwd?: (pwd: string) => void;
97
112
  }
98
113
  /**
99
114
  * Execute one built-in step and return its hook-shaped result (an object; the
@@ -37,6 +37,7 @@
37
37
  * hook-view threading fallback already carries ("shapes may differ").
38
38
  */
39
39
  import { execFile } from "node:child_process";
40
+ import { randomBytes } from "node:crypto";
40
41
  import { existsSync } from "node:fs";
41
42
  import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
42
43
  import { dirname, join, resolve, sep } from "node:path";
@@ -491,13 +492,28 @@ async function runBash(input, ctx) {
491
492
  throw new BuiltinNotRunnable("the command does not match BIR_BASH_ALLOW, so bir does not run it unattended");
492
493
  }
493
494
  const timeoutMs = ctx.timeoutMs ?? 120_000;
495
+ // The host's Bash semantics, which the recorded chain was written against:
496
+ // the working directory carries over from the plan's previous Bash step,
497
+ // variables and functions do not (a fresh `-l` shell each call).
498
+ const startIn = ctx.bashCwd && existsSync(ctx.bashCwd) ? ctx.bashCwd : resolve(ctx.cwd);
499
+ // Where the shell *ends up* is asked of the shell itself, behind a marker
500
+ // no real output contains (random per call), printed to stderr and
501
+ // stripped before the output is threaded — the recording never carried it.
502
+ // `pwd -W` is Git Bash's Windows-path form; elsewhere it fails quietly and
503
+ // plain `pwd` answers. An explicit `exit` skips the marker, and the
504
+ // previous directory simply stands.
505
+ const mark = `__BIR_PWD_${randomBytes(8).toString("hex")}__`;
506
+ const script = `${command}\n__bir_rc=$?\nprintf '\\n%s%s%s' '${mark}' "$(pwd -W 2>/dev/null || pwd)" '${mark}' 1>&2\nexit "$__bir_rc"`;
494
507
  return new Promise((resolvePromise, rejectPromise) => {
495
- execFile(bashExecutable(), ["-lc", command], {
496
- cwd: resolve(ctx.cwd),
508
+ execFile(bashExecutable(), ["-lc", script], {
509
+ cwd: startIn,
497
510
  timeout: timeoutMs,
498
511
  maxBuffer: MAX_BASH_BUFFER,
499
512
  windowsHide: true,
500
513
  }, (err, stdout, stderr) => {
514
+ const cleaned = takePwdMarker(String(stderr ?? ""), mark);
515
+ if (cleaned.pwd)
516
+ ctx.onBashCwd?.(cleaned.pwd);
501
517
  if (err) {
502
518
  const e = err;
503
519
  if (e.code === "ENOENT") {
@@ -511,19 +527,36 @@ async function runBash(input, ctx) {
511
527
  return;
512
528
  }
513
529
  const code = typeof e.code === "number" ? e.code : 1;
514
- const detail = String(stderr || stdout || "").trim().slice(0, 500);
530
+ const detail = String(cleaned.text || stdout || "").trim().slice(0, 500);
515
531
  rejectPromise(new Error(`the command exited with code ${code}${detail ? `: ${detail}` : ""}`));
516
532
  return;
517
533
  }
518
534
  resolvePromise({
519
535
  stdout: String(stdout ?? ""),
520
- stderr: String(stderr ?? ""),
536
+ stderr: cleaned.text,
521
537
  interrupted: false,
522
538
  isImage: false,
523
539
  });
524
540
  });
525
541
  });
526
542
  }
543
+ /**
544
+ * Split the shell's final `$PWD` out of stderr again: the marked chunk (and
545
+ * the newline the wrapper added before it) is removed, whatever else the
546
+ * command wrote stays byte-for-byte.
547
+ */
548
+ function takePwdMarker(stderr, mark) {
549
+ const i = stderr.indexOf(mark);
550
+ if (i < 0)
551
+ return { text: stderr };
552
+ const j = stderr.indexOf(mark, i + mark.length);
553
+ if (j < 0)
554
+ return { text: stderr };
555
+ const pwd = stderr.slice(i + mark.length, j).trim();
556
+ const before = stderr.slice(0, i).replace(/\n$/, "");
557
+ const after = stderr.slice(j + mark.length);
558
+ return { text: before + after, pwd: pwd || undefined };
559
+ }
527
560
  // ── shared walking and matching ─────────────────────────────────────────────
528
561
  /** Directories no scenario's step means to scan. */
529
562
  const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -356,6 +356,15 @@ export interface ReplayState {
356
356
  * no built-in executes: the step is `not_run`, exactly as before.
357
357
  */
358
358
  sessionCwd?: string;
359
+ /**
360
+ * Where this plan's last runner-side `Bash` step left its shell. The
361
+ * host's own semantics, which the recorded chain was written against: the
362
+ * working directory persists between Bash calls (variables and functions
363
+ * do not), so a chain of `cd sub` then `cat file` replays as it ran.
364
+ * Starts at {@link sessionCwd}; never read for `Read`/`Edit`/`Write`
365
+ * paths, which resolve against the project directory as the host's do.
366
+ */
367
+ bashCwd?: string;
359
368
  /**
360
369
  * Which kind of row was handed out (segmented.md R-OUT-7). A `segment` is a
361
370
  * named sub-task of a recording rather than a whole task, and it is judged
@@ -2084,7 +2084,7 @@ export class ReplayController {
2084
2084
  // the session can do the step itself or its recorded output can
2085
2085
  // stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
2086
2086
  if (this.builtinTools.has(name) && state.sessionCwd) {
2087
- return this.executeBuiltin(name, input, state.sessionCwd, opts.deadline);
2087
+ return this.executeBuiltin(name, input, state, opts.deadline);
2088
2088
  }
2089
2089
  throw new StepRunError(`${name || "this step"} is a built-in tool, which only runs in the session`, "not_run");
2090
2090
  }
@@ -2130,14 +2130,24 @@ export class ReplayController {
2130
2130
  * Failures keep {@link ExecuteStep}'s contract: policy (`not_run`), a step
2131
2131
  * cut off by its clock (`uncertain`), a step that ran and broke (`failed`).
2132
2132
  */
2133
- async executeBuiltin(name, input, cwd, deadline) {
2133
+ async executeBuiltin(name, input, state, deadline) {
2134
+ const cwd = state.sessionCwd; // the caller executes only with one
2134
2135
  const left = deadline !== undefined ? deadline - Date.now() : Number.POSITIVE_INFINITY;
2135
2136
  const timeoutMs = Math.max(MIN_STEP_MS, Math.min(this.budgets.stepMs, left));
2136
2137
  // The audit line precedes the action, always (§13.2, mitigation 4).
2137
2138
  logDetail("replay.builtin", { tool: name, cwd });
2138
2139
  let result;
2139
2140
  try {
2140
- result = await runBuiltin(name, input, { cwd, timeoutMs, bashAllow: this.bashAllow });
2141
+ result = await runBuiltin(name, input, {
2142
+ cwd,
2143
+ timeoutMs,
2144
+ bashAllow: this.bashAllow,
2145
+ // The plan's Bash steps share a working directory, as the host's do.
2146
+ bashCwd: state.bashCwd,
2147
+ onBashCwd: (pwd) => {
2148
+ state.bashCwd = pwd;
2149
+ },
2150
+ });
2141
2151
  }
2142
2152
  catch (err) {
2143
2153
  if (err instanceof BuiltinNotRunnable)
@@ -259,7 +259,10 @@ step is `not_run`, the clean hand-over. `Bash` needs a second opt-in on top (`BI
259
259
  command prefixes), because a runner-side step never reaches the host's permission prompts. The
260
260
  results mimic the hook's view of each tool, which is what the recorder stored for built-in steps
261
261
  and what their `toolOutputLogic` was authored against; they pass through the same
262
- `serializeCapped(redact(…))` as every recorded payload.
262
+ `serializeCapped(redact(…))` as every recorded payload. Bash keeps the host's own semantics a
263
+ recorded chain was written against: each call is a fresh `-l` shell (variables and functions do
264
+ not persist), while the **working directory carries over** from the plan's previous Bash step —
265
+ `cd sub` then `cat file` replays as it ran.
263
266
 
264
267
  A **mixed** scenario in `steer` mode still gets direct execution for its wrapped steps once it
265
268
  diverges (§8), so coverage is not all-or-nothing at the divergence boundary — only at the arm-time
@@ -354,7 +354,7 @@ so there is exactly one switch and it cannot get out of step with itself.
354
354
  | `BIR_LOGIC_TIMEOUT_MS` | `1000` | How long one scenario logic body may run before it is stopped. At most 10 000 |
355
355
  | `BIR_REPLAY_RECORDED_OUTPUTS` | *(unset)* | `1` lets a step that could not be sent (a built-in off `BIR_BUILTIN_TOOLS`, no proxy, a server off the allowlist) stand on its recorded output instead of stopping the plan. Never for a timeout or an upstream error |
356
356
  | `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's own project directory and confined to it. A chain whose only non-MCP steps are on this list arms `direct`. Add `Edit,Write,Bash` only deliberately — a runner-side step never reaches the host's permission prompts. `none` restores the old behaviour: every built-in hands the turn back |
357
- | `BIR_BASH_ALLOW` | *(unset)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it |
357
+ | `BIR_BASH_ALLOW` | *(unset)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. A plan's Bash steps share a working directory, exactly as the host's own Bash tool does — variables and functions do not persist, also as the host's |
358
358
  | `BIR_INTENT_ARM` | *(unset)* | `1` lets a match found mid-turn arm a plan in the calling tool's place, which means waiting for it (`BIR_INTENT_ARM_BUDGET_MS`, 500); `0` never waits; unset follows `BIR_SEGMENT_ARM` |
359
359
  | `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
360
360
 
@@ -630,8 +630,9 @@ bir scenario show <runId|scnId> [--step <n>]
630
630
  bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
631
631
  bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
632
632
  [--freeze | --unfreeze] [--note "<why>"] [--force --note "<why>"] [--revision <n>]
633
+ bir scenario edit <runId|scnId> --param <name> --kind target|setting [--note "<why>"] [--revision <n>]
633
634
  bir scenario edits <runId|scnId>
634
- bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "<why>"]
635
+ bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id> | --step <n> | --param <name>] [--force --note "<why>"]
635
636
  bir scenario calc <runId|scnId> [--force [--discard-edits]]
636
637
  bir scenario editing on|off|status
637
638
  ```
@@ -666,8 +667,9 @@ never take, is never reached in the check. A `fetch` inside a `try` fails in the
666
667
  code in its own process, with the global scope and only a time limit, where `fetch` and `process`
667
668
  do exist. So read every change before it is saved — yours and Claude's.
668
669
 
669
- **Only a step's code and its mark can be changed.** Not the parameters, not their sample values, not
670
- the logic that builds the final answer. Only tool steps: a sub-task call row belongs to the service.
670
+ **Only a step's code and its mark can be changed — and a parameter's kind (below, *A value no prompt names*).**
671
+ Not the parameters' sample values, not the logic that builds the final answer. Only tool steps: a
672
+ sub-task call row belongs to the service.
671
673
 
672
674
  #### The fix, step by step
673
675
 
@@ -1047,8 +1049,8 @@ The tools, as the `bir` server defines them. **Bold** arguments are required.
1047
1049
  | `scenario_edits` | after `bir scenario editing on`, or with `BIR_SCENARIO_TOOLS=read` | **`target`** | `bir scenario edits` |
1048
1050
  | `investigate` | after `bir scenario editing on`, or with `BIR_SCENARIO_TOOLS=read` | `id` — a `run_`, `scn_` or `sexec_` id; without it, the newest turn here | `bir investigate` |
1049
1051
  | `scenario_check` | after `bir scenario editing on` | **`target`**, **`step`**, `inputLogic`, `outputLogic`, `unfreeze` | `bir scenario check` |
1050
- | `scenario_edit` | after `bir scenario editing on` | **`target`**, **`step`**, `inputLogic`, `outputLogic`, `freeze`, `unfreeze`, `force`, `note`, `revision` | `bir scenario edit` |
1051
- | `scenario_undo` | after `bir scenario editing on` | **`target`**, `edit` or `step` (not both), `force`, `note` | `bir scenario undo` |
1052
+ | `scenario_edit` | after `bir scenario editing on` | **`target`**, and **`step`** with `inputLogic`, `outputLogic`, `freeze`, `unfreeze`, `force` — or **`param`** with **`kind`**; `note`, `revision` | `bir scenario edit` |
1053
+ | `scenario_undo` | after `bir scenario editing on` | **`target`**, one of `edit`, `step` or `param`, `force`, `note` | `bir scenario undo` |
1052
1054
 
1053
1055
  - `target` is a `scn_` id, or the `run_` id of a whole-run scenario. `step` is the `stepIndex`.
1054
1056
  - `inputLogic` and `outputLogic` are the code itself, as text. The server writes them to temporary
@@ -1095,6 +1097,59 @@ For the hiring-year case, Claude's report would be: *step 4's code is a copy of
1095
1097
  so it was marked; the new code builds the query from `summary_table`; the check says it reproduces
1096
1098
  the recording, and the step will run by itself.*
1097
1099
 
1100
+ #### A value no prompt names — `--param <name> --kind setting`
1101
+
1102
+ > Built 2026-09-28, **not yet released**: it needs the next service deploy, then the next
1103
+ > `@basein/runner` version. The design is `findoffer.md` and `editSteps.md` ("Parameter kinds") in
1104
+ > the BaseIn repository.
1105
+
1106
+ Every parameter of a plan is one of two kinds:
1107
+
1108
+ | Kind | Means | When the prompt does not name it |
1109
+ |---|---|---|
1110
+ | **target** | what the task acts on — a file, a table, a region | the plan is declined: `missing_target`. A target is never guessed |
1111
+ | **setting** | a value nobody states, the same every time — a database, a host, a working folder | the recorded value is used |
1112
+
1113
+ A prompt that names a value always wins, whatever its kind. The calculation decides the kinds, and
1114
+ it can get one wrong. The hiring-year plan `scn_09dbd4e6` never ran: its `database` was a target,
1115
+ and the MCPMark prompt never names the database — it is simply the one the agent is connected to.
1116
+ `bir investigate` names such a target: `declined … missing_target (database not named in the
1117
+ prompt)`.
1118
+
1119
+ **Do this.** Make it a setting, and say why:
1120
+
1121
+ ```bash
1122
+ bir scenario edit scn_09dbd4e6-… --param database --kind setting --note "The database is the connection; the prompt never names it."
1123
+ ```
1124
+
1125
+ **You should see:**
1126
+
1127
+ ```
1128
+ Parameter database: target → setting (revision 4). Undo: bir scenario undo scn_09dbd4e6-… --edit pedit_…
1129
+ When a prompt names no database, the runner now uses its recorded value ("hys_trial"); a prompt that names one still wins.
1130
+ ```
1131
+
1132
+ - `--kind setting` needs `--note`: it is the one change that lets a replay use a recorded value.
1133
+ `--kind target` does not.
1134
+ - No code runs, so nothing is checked. The revision goes up by one, as for a step edit.
1135
+ - **A recalculation keeps the kind** while the parameter keeps its name, so unlike a step edit it
1136
+ does not hold a recalculation back. When a recalculation drops the parameter, the change is
1137
+ history.
1138
+ - `bir scenario edits` lists it as `param database`, and `bir investigate` reports it on every run
1139
+ (`param_kind_edited`).
1140
+ - `bir scenario undo … --param database` takes it back. A plain `bir scenario undo` takes back the
1141
+ newest change, a step's or a parameter's.
1142
+ - The `scenario_edit` tool takes `param` and `kind` instead of `step`; `scenario_undo` takes `param`.
1143
+
1144
+ **If it goes wrong:**
1145
+
1146
+ | Symptom | Fix |
1147
+ |---|---|
1148
+ | `--kind setting needs --note "why" (note_required)` | Add `--note "why"` |
1149
+ | `Not saved (not_found): … has no parameter …` | Use a name that `bir scenario show <id>` lists under `parameters` |
1150
+ | `Nothing to change: database is already a setting.` | Nothing to do |
1151
+ | `A parameter's kind is not checked` | `check` runs code, and a kind has none. Use `bir scenario edit` |
1152
+
1098
1153
  #### When it does not work
1099
1154
 
1100
1155
  | Symptom | Cause | Fix |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/production.md CHANGED
@@ -993,7 +993,7 @@ Defaults were checked in `src/`.
993
993
  | `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
994
994
  | `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
995
995
  | `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's project directory and confined to it. Add `Edit,Write,Bash` deliberately — runner-side steps bypass the host's permission prompts. `none` turns it off. |
996
- | `BIR_BASH_ALLOW` | recorder | unset | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner even when listed. |
996
+ | `BIR_BASH_ALLOW` | recorder | unset | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner even when listed. A plan's Bash steps share a working directory, as the host's do; variables do not persist, also as the host's. |
997
997
  | `BIR_DERIVE_RECENT_RESULTS` | recorder | 5 | How many of this prompt's tool results derivation may read (`0`: none). Never for plans with a command sink. |
998
998
  | `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
999
999
  | `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |