@basein/runner 0.3.3 → 0.3.5

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.
@@ -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;
@@ -10,7 +10,11 @@
10
10
  * 2. the sign-in — a setup token from the console, a cached session, or the
11
11
  * browser flow — stored the way `bir login` stores it;
12
12
  * 3. the project: MCP servers wrapped, hooks wired, the scenario server added
13
- * and pre-approved (`bir install --global --replay`);
13
+ * and pre-approved (`bir install --global --replay`) — and every one of
14
+ * its tools with it: investigate and the scenario tools are offered as
15
+ * well as run_scenario, and Claude Code runs them without asking (Eran,
16
+ * 2026-09-28: after setup, every `bir` tool works). A person's
17
+ * `bir scenario editing off` is kept;
14
18
  * 4. the recorder, started in the background and from now on started by the
15
19
  * SessionStart hook whenever it is missing;
16
20
  * 5. a summary that names the account, the servers, the recorder's log, and
package/dist/bin/setup.js CHANGED
@@ -10,7 +10,11 @@
10
10
  * 2. the sign-in — a setup token from the console, a cached session, or the
11
11
  * browser flow — stored the way `bir login` stores it;
12
12
  * 3. the project: MCP servers wrapped, hooks wired, the scenario server added
13
- * and pre-approved (`bir install --global --replay`);
13
+ * and pre-approved (`bir install --global --replay`) — and every one of
14
+ * its tools with it: investigate and the scenario tools are offered as
15
+ * well as run_scenario, and Claude Code runs them without asking (Eran,
16
+ * 2026-09-28: after setup, every `bir` tool works). A person's
17
+ * `bir scenario editing off` is kept;
14
18
  * 4. the recorder, started in the background and from now on started by the
15
19
  * SessionStart hook whenever it is missing;
16
20
  * 5. a summary that names the account, the servers, the recorder's log, and
@@ -35,8 +39,10 @@ import { pidAlive, readDiscovery } from "../control/discovery.js";
35
39
  import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, describeAuthService, deviceLogin, loadStoredConfig, normalizeAuthUrl, rememberAuthUrl, tokenLogin, } from "../auth/client.js";
36
40
  import { activeSessions, compareVersions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
37
41
  import { normalizePath } from "../control/paths.js";
38
- import { allocateReplacementPort, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
42
+ import { allowRules, BIR_TOOLS_RULE, claudeCodePaths } from "../config/adapters/claude-code.js";
43
+ import { allocateReplacementPort, editingEnabled, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
39
44
  import { resolveServers } from "../config/resolve.js";
45
+ import { EDIT_TOOLS, READ_TOOLS } from "./scenario-edit.js";
40
46
  import { errText } from "../util/log.js";
41
47
  import { offSource } from "../util/off-switch.js";
42
48
  import { packageVersion } from "../util/version.js";
@@ -368,6 +374,14 @@ export async function runSetup(args, deps) {
368
374
  else {
369
375
  out(` replay on — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
370
376
  }
377
+ // What the `bir` server gives a session here, and whether Claude Code asks
378
+ // before each call — both set by install for setup (bir.ts, installWriting).
379
+ if (resolveServers(project).some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config))) {
380
+ const asks = !allowRules(claudeCodePaths(project).settingsLocal).includes(BIR_TOOLS_RULE);
381
+ out(editingEnabled(readSidecar(), project)
382
+ ? ` tools ${["run_scenario", ...READ_TOOLS, ...EDIT_TOOLS].join(", ")} — ${asks ? "Claude Code asks before each" : "Claude Code runs them without asking, except a forced change"}`
383
+ : " tools run_scenario only — scenario editing is off here (`bir scenario editing on` adds investigate and the scenario tools)");
384
+ }
371
385
  if (ensured) {
372
386
  const { info, health: h } = ensured.status;
373
387
  const theirs = String(h.hooksVersion ?? info.version ?? "");
@@ -77,6 +77,33 @@ export declare function removeGitExclude(dir: string, line?: string): string | u
77
77
  export declare function enableMcpjsonServers(settingsPath: string, names: string[]): string;
78
78
  /** Undo {@link enableMcpjsonServers} for `names`; drops the key when empty. */
79
79
  export declare function disableMcpjsonServers(settingsPath: string, names: string[]): string | undefined;
80
+ /**
81
+ * The rule that lets Claude Code run the runner's own `bir` server's tools
82
+ * without asking. In Claude Code's permission syntax `mcp__<server>` matches
83
+ * every tool that server offers, so it also covers the tools `bir scenario
84
+ * editing on` brings, and any a later runner adds.
85
+ *
86
+ * `bir setup` writes it (bir.ts, installWriting), into the person's own
87
+ * `.claude/settings.local.json` only, and only where `bir` IS the runner's
88
+ * scenario server: the rule matches a server by name, and a server of the
89
+ * person's own that is called `bir` must keep its prompts.
90
+ */
91
+ export declare const BIR_TOOLS_RULE = "mcp__bir";
92
+ /** The `permissions.allow` rules in a settings file; none when it is missing or unreadable. */
93
+ export declare function allowRules(settingsPath: string): string[];
94
+ /**
95
+ * Add `rules` to `permissions.allow`, leaving every other rule and key alone.
96
+ * Returns the file's new text and the rules that were not there before — the
97
+ * only ones {@link removeAllowRules} may take out again. A `permissions` or
98
+ * `allow` of the wrong shape is left as it is (nothing added): it is the
99
+ * person's to fix, and Claude Code reads neither.
100
+ */
101
+ export declare function addAllowRules(settingsPath: string, rules: string[]): {
102
+ text: string;
103
+ added: string[];
104
+ };
105
+ /** Undo {@link addAllowRules} for `rules`; drops `allow`, then `permissions`, when left empty. */
106
+ export declare function removeAllowRules(settingsPath: string, rules: string[]): string | undefined;
80
107
  /** True when `settingsPath` holds any hook of ours. */
81
108
  export declare function hasBirHooks(settingsPath: string): boolean;
82
109
  /** Which file holds a given scope's `mcpServers` for this cwd. */
@@ -24,7 +24,7 @@ import { createHash } from "node:crypto";
24
24
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
25
25
  import { dirname, join, parse as parsePath, resolve as resolvePath } from "node:path";
26
26
  import { writeFileAtomic } from "../../util/atomic-write.js";
27
- import { FIRST_PROJECT_PORT, PACKAGE_NAME } from "../generate.js";
27
+ import { FIRST_PROJECT_PORT, PACKAGE_NAME, SCENARIO_SERVER_KEY } from "../generate.js";
28
28
  import { claudeJsonPath, parseJsonFile, parseJsonText, projectKeyFor, } from "../resolve.js";
29
29
  export function claudeCodePaths(cwd, claudeJsonOverride) {
30
30
  return {
@@ -176,6 +176,74 @@ export function disableMcpjsonServers(settingsPath, names) {
176
176
  fresh.enabledMcpjsonServers = kept;
177
177
  });
178
178
  }
179
+ // ── permission rules ───────────────────────────────────────────────────────
180
+ /**
181
+ * The rule that lets Claude Code run the runner's own `bir` server's tools
182
+ * without asking. In Claude Code's permission syntax `mcp__<server>` matches
183
+ * every tool that server offers, so it also covers the tools `bir scenario
184
+ * editing on` brings, and any a later runner adds.
185
+ *
186
+ * `bir setup` writes it (bir.ts, installWriting), into the person's own
187
+ * `.claude/settings.local.json` only, and only where `bir` IS the runner's
188
+ * scenario server: the rule matches a server by name, and a server of the
189
+ * person's own that is called `bir` must keep its prompts.
190
+ */
191
+ export const BIR_TOOLS_RULE = `mcp__${SCENARIO_SERVER_KEY}`;
192
+ /** The `permissions.allow` rules in a settings file; none when it is missing or unreadable. */
193
+ export function allowRules(settingsPath) {
194
+ let parsed;
195
+ try {
196
+ parsed = parseJsonFile(settingsPath);
197
+ }
198
+ catch {
199
+ return [];
200
+ }
201
+ const allow = parsed?.permissions?.allow;
202
+ return Array.isArray(allow) ? allow.filter((r) => typeof r === "string") : [];
203
+ }
204
+ const plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
205
+ /**
206
+ * Add `rules` to `permissions.allow`, leaving every other rule and key alone.
207
+ * Returns the file's new text and the rules that were not there before — the
208
+ * only ones {@link removeAllowRules} may take out again. A `permissions` or
209
+ * `allow` of the wrong shape is left as it is (nothing added): it is the
210
+ * person's to fix, and Claude Code reads neither.
211
+ */
212
+ export function addAllowRules(settingsPath, rules) {
213
+ let added = [];
214
+ const text = editJson(settingsPath, (parsed) => {
215
+ added = [];
216
+ if (parsed.permissions !== undefined && !plainObject(parsed.permissions))
217
+ return;
218
+ const permissions = { ...(parsed.permissions ?? {}) };
219
+ if (permissions.allow !== undefined && !Array.isArray(permissions.allow))
220
+ return;
221
+ const allow = [...(permissions.allow ?? [])];
222
+ added = rules.filter((rule) => !allow.includes(rule));
223
+ if (added.length === 0)
224
+ return;
225
+ permissions.allow = [...allow, ...added];
226
+ parsed.permissions = permissions;
227
+ });
228
+ return { text, added };
229
+ }
230
+ /** Undo {@link addAllowRules} for `rules`; drops `allow`, then `permissions`, when left empty. */
231
+ export function removeAllowRules(settingsPath, rules) {
232
+ if (!allowRules(settingsPath).some((rule) => rules.includes(rule)))
233
+ return undefined;
234
+ return editJson(settingsPath, (parsed) => {
235
+ const permissions = parsed.permissions;
236
+ if (!plainObject(permissions) || !Array.isArray(permissions.allow))
237
+ return;
238
+ const kept = permissions.allow.filter((rule) => !(typeof rule === "string" && rules.includes(rule)));
239
+ if (kept.length > 0)
240
+ permissions.allow = kept;
241
+ else
242
+ delete permissions.allow;
243
+ if (Object.keys(permissions).length === 0)
244
+ delete parsed.permissions;
245
+ });
246
+ }
179
247
  /** True when `settingsPath` holds any hook of ours. */
180
248
  export function hasBirHooks(settingsPath) {
181
249
  let parsed;
@@ -455,12 +455,23 @@ export interface ProjectRecord {
455
455
  * environment: their definitions ride along with every request of every
456
456
  * session, and only somebody working on a scenario calls them.
457
457
  *
458
- * Per project, and off by default, because the `bir` server runs in every
459
- * session of every installed project — a fleet included — and a plan must
460
- * not change because some agent there decided it should. The `bir scenario`
461
- * commands ignore it: a person in a terminal is asked before each one.
458
+ * Per project. `bir setup` turns it on (Eran, 2026-09-28: setup leaves every
459
+ * `bir` tool offered and working), except where a person turned it off —
460
+ * that `false` is kept by every later setup (see {@link settleEditing}). A
461
+ * bare `bir install` leaves it as it is, so a fleet wired by hand keeps the
462
+ * old default: the `bir` server runs in every session of every installed
463
+ * project, and there a plan must not change because some agent decided it
464
+ * should. The `bir scenario` commands ignore it: a person in a terminal is
465
+ * asked before each one.
462
466
  */
463
467
  editing?: boolean;
468
+ /**
469
+ * The `permissions.allow` rules `bir setup` added to this project's
470
+ * `.claude/settings.local.json` so Claude Code runs the `bir` server's tools
471
+ * without asking (adapters/claude-code.ts, BIR_TOOLS_RULE). Uninstall takes
472
+ * out only these: a rule that was there before is the person's own.
473
+ */
474
+ allowRules?: string[];
464
475
  /**
465
476
  * Every `.claude.json` an install for this project wrote local-scope entries
466
477
  * to. Claude Code reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set,
@@ -588,6 +599,12 @@ export declare function editingEnabled(sidecar: InstalledSidecar, cwd: string):
588
599
  * inventing a record would invent a port the hooks do not use.
589
600
  */
590
601
  export declare function setEditing(sidecar: InstalledSidecar, cwd: string, on: boolean): ProjectRecord | undefined;
602
+ /**
603
+ * `bir setup`: scenario editing on for `cwd`, unless it was ever set — an
604
+ * `off` a person stored is theirs, and no setup turns it back on. Returns true
605
+ * when it was switched on now.
606
+ */
607
+ export declare function settleEditing(sidecar: InstalledSidecar, cwd: string): boolean;
591
608
  /**
592
609
  * What an install or uninstall said about calculated replay:
593
610
  *