@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.
- package/README.md +31 -11
- package/dist/bin/bir-scenario.d.ts +9 -8
- package/dist/bin/bir-scenario.js +90 -27
- package/dist/bin/bir.d.ts +2 -1
- package/dist/bin/bir.js +98 -10
- package/dist/bin/investigate.js +19 -3
- package/dist/bin/scenario-edit.d.ts +39 -2
- package/dist/bin/scenario-edit.js +200 -24
- package/dist/bin/setup.d.ts +5 -1
- package/dist/bin/setup.js +16 -2
- package/dist/config/adapters/claude-code.d.ts +27 -0
- package/dist/config/adapters/claude-code.js +69 -1
- package/dist/config/generate.d.ts +21 -4
- package/dist/config/generate.js +12 -0
- package/dist/control/server.js +20 -0
- package/docs/calculatedReplay.md +11 -9
- package/docs/calculatedReplayGuide.md +83 -21
- package/docs/quickstart.md +20 -10
- package/package.json +1 -1
- package/production.md +2 -2
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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 && !/^
|
|
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.
|
|
596
|
-
return usageError(deps, "undo", "
|
|
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
|
-
|
|
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
|
|
615
|
-
? `Nothing to undo: ${target} has no hand edit that can be undone.`
|
|
616
|
-
:
|
|
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(
|
|
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(
|
|
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;
|
package/dist/bin/setup.d.ts
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
|
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 {
|
|
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
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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
|
*
|