@basein/runner 0.2.10 → 0.2.12
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 +41 -1
- package/dist/bin/bir-scenario.d.ts +29 -2
- package/dist/bin/bir-scenario.js +484 -16
- package/dist/bin/bir.d.ts +10 -0
- package/dist/bin/bir.js +143 -43
- package/dist/bin/investigate.js +2 -2
- package/dist/bin/scenario-edit.d.ts +200 -0
- package/dist/bin/scenario-edit.js +950 -0
- package/dist/config/generate.d.ts +21 -0
- package/dist/config/generate.js +16 -0
- package/dist/control/server.js +18 -5
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/replay/controller.d.ts +3 -1
- package/dist/replay/controller.js +17 -12
- package/docs/calculatedReplay.md +115 -4
- package/docs/calculatedReplayGuide.md +528 -10
- package/docs/quickstart.md +44 -2
- package/package.json +1 -1
|
@@ -157,6 +157,18 @@ export interface ProjectRecord {
|
|
|
157
157
|
* a fleet's absolute paths into pinned-npx entries.
|
|
158
158
|
*/
|
|
159
159
|
invocation?: Invocation;
|
|
160
|
+
/**
|
|
161
|
+
* `bir scenario editing on|off` (editSteps.md D2, in the BaseIn repository):
|
|
162
|
+
* whether this project's `bir` MCP server also offers the tools that CHANGE
|
|
163
|
+
* a calculated scenario — `scenario_check`, `scenario_edit`, `scenario_undo`.
|
|
164
|
+
* Absent means off. The read tools are offered either way.
|
|
165
|
+
*
|
|
166
|
+
* Per project, and off by default, because the `bir` server runs in every
|
|
167
|
+
* session of every installed project — a fleet included — and a plan must
|
|
168
|
+
* not change because some agent there decided it should. The `bir scenario`
|
|
169
|
+
* commands ignore it: a person in a terminal is asked before each one.
|
|
170
|
+
*/
|
|
171
|
+
editing?: boolean;
|
|
160
172
|
}
|
|
161
173
|
export interface InstalledSidecar {
|
|
162
174
|
version: 1;
|
|
@@ -216,4 +228,13 @@ export declare function allocateFreeProjectPort(sidecar: InstalledSidecar, cwd:
|
|
|
216
228
|
export declare function setProjectRecord(sidecar: InstalledSidecar, cwd: string, patch: Partial<ProjectRecord> & Pick<ProjectRecord, "port">): ProjectRecord;
|
|
217
229
|
/** The bearer token this project's hooks and control server share. */
|
|
218
230
|
export declare function projectToken(sidecar: InstalledSidecar, cwd: string): string | undefined;
|
|
231
|
+
/** Whether `bir scenario editing on` was run for `cwd`; see {@link ProjectRecord.editing}. */
|
|
232
|
+
export declare function editingEnabled(sidecar: InstalledSidecar, cwd: string): boolean;
|
|
233
|
+
/**
|
|
234
|
+
* Store `bir scenario editing on|off` for `cwd`, under the same normalised key
|
|
235
|
+
* as the replay switches. `undefined` when the directory has no record: it was
|
|
236
|
+
* never installed, so there is no `bir` server here to offer anything, and
|
|
237
|
+
* inventing a record would invent a port the hooks do not use.
|
|
238
|
+
*/
|
|
239
|
+
export declare function setEditing(sidecar: InstalledSidecar, cwd: string, on: boolean): ProjectRecord | undefined;
|
|
219
240
|
//# sourceMappingURL=generate.d.ts.map
|
package/dist/config/generate.js
CHANGED
|
@@ -198,4 +198,20 @@ export function setProjectRecord(sidecar, cwd, patch) {
|
|
|
198
198
|
export function projectToken(sidecar, cwd) {
|
|
199
199
|
return projectRecord(sidecar, cwd)?.token ?? sidecar.token;
|
|
200
200
|
}
|
|
201
|
+
/** Whether `bir scenario editing on` was run for `cwd`; see {@link ProjectRecord.editing}. */
|
|
202
|
+
export function editingEnabled(sidecar, cwd) {
|
|
203
|
+
return projectRecord(sidecar, cwd)?.editing === true;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Store `bir scenario editing on|off` for `cwd`, under the same normalised key
|
|
207
|
+
* as the replay switches. `undefined` when the directory has no record: it was
|
|
208
|
+
* never installed, so there is no `bir` server here to offer anything, and
|
|
209
|
+
* inventing a record would invent a port the hooks do not use.
|
|
210
|
+
*/
|
|
211
|
+
export function setEditing(sidecar, cwd, on) {
|
|
212
|
+
const record = projectRecord(sidecar, cwd);
|
|
213
|
+
if (!record)
|
|
214
|
+
return undefined;
|
|
215
|
+
return setProjectRecord(sidecar, cwd, { port: record.port, editing: on });
|
|
216
|
+
}
|
|
201
217
|
//# sourceMappingURL=generate.js.map
|
package/dist/control/server.js
CHANGED
|
@@ -28,7 +28,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
28
28
|
import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
|
|
29
29
|
import { StepIndexAllocator } from "./ordering.js";
|
|
30
30
|
import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
|
|
31
|
-
import {
|
|
31
|
+
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
32
32
|
import { redact } from "../record/redact.js";
|
|
33
33
|
import { serializeCapped } from "../record/truncate.js";
|
|
34
34
|
import { StepQueue } from "../record/queue.js";
|
|
@@ -732,7 +732,13 @@ export class ControlServer {
|
|
|
732
732
|
// calculated scenario: its generated reasoning is injected into every
|
|
733
733
|
// matched turn's steering directive, and because it is not an MCP tool it
|
|
734
734
|
// drags the whole scenario out of `direct` mode. Observed in production.
|
|
735
|
-
|
|
735
|
+
// The runner's own `bir` — its MCP tools and a Bash command that only runs
|
|
736
|
+
// it — is housekeeping too (D13): a session that fixes a plan must never
|
|
737
|
+
// become a scenario that edits plans when it is replayed. Returning here
|
|
738
|
+
// also keeps it out of intent matching, the fragment and the plan.
|
|
739
|
+
if (isHousekeepingCall(toolName, modelArgs)) {
|
|
740
|
+
if (toolUseId)
|
|
741
|
+
(run.housekeepingUses ??= new Set()).add(toolUseId);
|
|
736
742
|
logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
|
|
737
743
|
return {};
|
|
738
744
|
}
|
|
@@ -761,7 +767,7 @@ export class ControlServer {
|
|
|
761
767
|
// Runs before anything else, because two of its four answers end the call.
|
|
762
768
|
let pinned;
|
|
763
769
|
if (run.replay?.plan && !run.replay.retired) {
|
|
764
|
-
const action = await this.replay.preTool(run.replay, toolName, toolUseId);
|
|
770
|
+
const action = await this.replay.preTool(run.replay, toolName, toolUseId, modelArgs);
|
|
765
771
|
// A hand-over inside `preTool` (an input logic that threw) must still
|
|
766
772
|
// schedule the fragment, even though this call ends here.
|
|
767
773
|
this.noteHandover(run);
|
|
@@ -1266,8 +1272,15 @@ export class ControlServer {
|
|
|
1266
1272
|
const toolUseId = payload.tool_use_id ?? "";
|
|
1267
1273
|
const toolName = payload.tool_name ?? "unknown";
|
|
1268
1274
|
// `onToolPre` opened no step for these, so closing one here would record a
|
|
1269
|
-
// `tool_response` with no `tool_selected` before it.
|
|
1270
|
-
|
|
1275
|
+
// `tool_response` with no `tool_selected` before it. The id it remembered
|
|
1276
|
+
// decides first; the call itself is judged again only when no step was
|
|
1277
|
+
// opened for it — a post whose pre never reached this server. A step the
|
|
1278
|
+
// pre did open (a plan pinned the model's call to a command that happens
|
|
1279
|
+
// to be `bir`) is closed as usual: its output is what the plan threads.
|
|
1280
|
+
const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
|
|
1281
|
+
if (skippedAtPre)
|
|
1282
|
+
return {};
|
|
1283
|
+
if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
|
|
1271
1284
|
return {};
|
|
1272
1285
|
// Nor for an unarmed `run_scenario`. The plan may have retired since the
|
|
1273
1286
|
// pre hook, so the test is whether that hook opened a step, not the plan.
|
|
@@ -27,8 +27,79 @@
|
|
|
27
27
|
* possibly be the user's task. `Read`, `Bash` and `Grep` are NOT here: they do
|
|
28
28
|
* real work, they belong in a recording, and reaching for one mid-replay
|
|
29
29
|
* usually *is* the model doing the task another way.
|
|
30
|
+
*
|
|
31
|
+
* THE RUNNER'S OWN `bir` IS HOUSEKEEPING TOO (editSteps.md D13), and that is
|
|
32
|
+
* the one reason this file now looks at a call's input and not only its name.
|
|
33
|
+
* A session that fixes a plan — `bir investigate`, then `bir scenario edit`, or
|
|
34
|
+
* the same through the `bir` MCP server's `scenario_*` tools — is exactly the
|
|
35
|
+
* kind of session that gets recorded and calculated. Recorded, it becomes a
|
|
36
|
+
* scenario whose steps *edit plans*, and a steered or direct replay of it would
|
|
37
|
+
* then change a plan unattended, on a prompt nobody meant as "change the plan":
|
|
38
|
+
* the very thing D2 keeps the editing tools switched off in a fleet to prevent.
|
|
39
|
+
* And mid-replay, a model that stops to read `bir investigate` has not left the
|
|
40
|
+
* plan; it is reading about it.
|
|
41
|
+
*
|
|
42
|
+
* So two more kinds of call are housekeeping, and both are still narrow:
|
|
43
|
+
*
|
|
44
|
+
* - every tool of the `bir` MCP server **except `run_scenario`**, which is
|
|
45
|
+
* the direct plan's delivery vehicle and keeps its own handling (it is
|
|
46
|
+
* recorded, tagged `pinnedBy`, while its plan is live);
|
|
47
|
+
* - a `Bash` call whose command is **nothing but** `bir` invocations
|
|
48
|
+
* ({@link isBirOnlyCommand}), and the same for a `PowerShell` call
|
|
49
|
+
* ({@link isBirOnlyPowerShell}) — Claude Code on Windows reaches for its
|
|
50
|
+
* PowerShell tool as readily as for Bash, and a `bir scenario edit` run
|
|
51
|
+
* there is the same act. `npm test && bir investigate` is not: the
|
|
52
|
+
* `npm test` half is real work, and hiding it from the recording would be
|
|
53
|
+
* hiding the task. When in doubt the answer is "not housekeeping", because
|
|
54
|
+
* a `bir` call recorded by mistake costs a noisy step, while real work
|
|
55
|
+
* skipped by mistake is a plan with a hole in it.
|
|
30
56
|
*/
|
|
31
57
|
/** Host tools that are never recorded as steps and never count as divergence. */
|
|
32
58
|
export declare const HOUSEKEEPING_TOOLS: ReadonlySet<string>;
|
|
59
|
+
/** By name alone — the host's own bookkeeping tools. See {@link isHousekeepingCall} for the full rule. */
|
|
33
60
|
export declare function isHousekeeping(toolName: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The direct plan's delivery vehicle. Must equal `DIRECT_TOOL_NAME` in the
|
|
63
|
+
* replay controller (a test holds them together); spelled out here because
|
|
64
|
+
* the controller imports this file.
|
|
65
|
+
*/
|
|
66
|
+
export declare const RUN_SCENARIO_TOOL = "mcp__bir__run_scenario";
|
|
67
|
+
/**
|
|
68
|
+
* Whether one call is host housekeeping: never recorded as a step, never a
|
|
69
|
+
* divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
|
|
70
|
+
* a `Bash` call's `command` is read from it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isHousekeepingCall(toolName: string, toolInput?: unknown): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
|
|
75
|
+
* Bash reader would get it wrong: a backslash is a path separator, not an
|
|
76
|
+
* escape; the escape is the backtick; `$(…)` runs code even inside double
|
|
77
|
+
* quotes; `&` at the start of a part is the call operator. Deliberately
|
|
78
|
+
* narrower than the Bash reader: parts split on `;`, `&&`, `||` and newlines
|
|
79
|
+
* (outside quotes); each is `bir …` (as {@link isBirInvocation} reads it, also
|
|
80
|
+
* after a leading `&`) or a plain `cd`/`Set-Location`/`Push-Location <dir>`.
|
|
81
|
+
* A pipe, a backtick, `$(` or `@(` anywhere, a brace or parenthesis outside
|
|
82
|
+
* quotes, an `@` starting a word, or a quote left open makes the answer false — for the same
|
|
83
|
+
* reason as there: when unsure, record it.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isBirOnlyPowerShell(command: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* True when a shell command runs `bir` and nothing else: split on `&&`, `||`,
|
|
88
|
+
* `;`, `|`, `&` and newlines (outside quotes), every part is a `bir`
|
|
89
|
+
* invocation or a plain `cd <dir>`, and at least one is `bir`.
|
|
90
|
+
*
|
|
91
|
+
* A `bir` invocation is `bir` / `bir.cmd` / a path ending in `bir`, `bir.cmd`,
|
|
92
|
+
* `bir.js` or `bir.ps1`; `npx [-y] [-p @basein/runner…] [@basein/runner…] bir …`;
|
|
93
|
+
* or `node <path>/bir.js …` — each optionally after `NAME=value` assignments,
|
|
94
|
+
* which only set `bir`'s own environment. A heredoc is read as the data it is,
|
|
95
|
+
* because `bir scenario check … --input-logic - <<'EOF'` is the natural way to
|
|
96
|
+
* hand `bir` a logic body, and its lines are JavaScript, not commands.
|
|
97
|
+
*
|
|
98
|
+
* Anything that could run other code while looking like `bir` makes the answer
|
|
99
|
+
* false: a command substitution (`$(…)`, backticks) outside single quotes and
|
|
100
|
+
* quoted heredocs, and a subshell, group or process substitution (`(`, `{`).
|
|
101
|
+
* So does a quote or heredoc left open — a parse this side is not sure of is
|
|
102
|
+
* not one to hide a step on.
|
|
103
|
+
*/
|
|
104
|
+
export declare function isBirOnlyCommand(command: string): boolean;
|
|
34
105
|
//# sourceMappingURL=housekeeping.d.ts.map
|
|
@@ -27,13 +27,430 @@
|
|
|
27
27
|
* possibly be the user's task. `Read`, `Bash` and `Grep` are NOT here: they do
|
|
28
28
|
* real work, they belong in a recording, and reaching for one mid-replay
|
|
29
29
|
* usually *is* the model doing the task another way.
|
|
30
|
+
*
|
|
31
|
+
* THE RUNNER'S OWN `bir` IS HOUSEKEEPING TOO (editSteps.md D13), and that is
|
|
32
|
+
* the one reason this file now looks at a call's input and not only its name.
|
|
33
|
+
* A session that fixes a plan — `bir investigate`, then `bir scenario edit`, or
|
|
34
|
+
* the same through the `bir` MCP server's `scenario_*` tools — is exactly the
|
|
35
|
+
* kind of session that gets recorded and calculated. Recorded, it becomes a
|
|
36
|
+
* scenario whose steps *edit plans*, and a steered or direct replay of it would
|
|
37
|
+
* then change a plan unattended, on a prompt nobody meant as "change the plan":
|
|
38
|
+
* the very thing D2 keeps the editing tools switched off in a fleet to prevent.
|
|
39
|
+
* And mid-replay, a model that stops to read `bir investigate` has not left the
|
|
40
|
+
* plan; it is reading about it.
|
|
41
|
+
*
|
|
42
|
+
* So two more kinds of call are housekeeping, and both are still narrow:
|
|
43
|
+
*
|
|
44
|
+
* - every tool of the `bir` MCP server **except `run_scenario`**, which is
|
|
45
|
+
* the direct plan's delivery vehicle and keeps its own handling (it is
|
|
46
|
+
* recorded, tagged `pinnedBy`, while its plan is live);
|
|
47
|
+
* - a `Bash` call whose command is **nothing but** `bir` invocations
|
|
48
|
+
* ({@link isBirOnlyCommand}), and the same for a `PowerShell` call
|
|
49
|
+
* ({@link isBirOnlyPowerShell}) — Claude Code on Windows reaches for its
|
|
50
|
+
* PowerShell tool as readily as for Bash, and a `bir scenario edit` run
|
|
51
|
+
* there is the same act. `npm test && bir investigate` is not: the
|
|
52
|
+
* `npm test` half is real work, and hiding it from the recording would be
|
|
53
|
+
* hiding the task. When in doubt the answer is "not housekeeping", because
|
|
54
|
+
* a `bir` call recorded by mistake costs a noisy step, while real work
|
|
55
|
+
* skipped by mistake is a plan with a hole in it.
|
|
30
56
|
*/
|
|
57
|
+
import { SCENARIO_SERVER_KEY } from "../config/generate.js";
|
|
31
58
|
/** Host tools that are never recorded as steps and never count as divergence. */
|
|
32
59
|
export const HOUSEKEEPING_TOOLS = new Set([
|
|
33
60
|
"ToolSearch",
|
|
34
61
|
"TodoWrite",
|
|
35
62
|
]);
|
|
63
|
+
/** By name alone — the host's own bookkeeping tools. See {@link isHousekeepingCall} for the full rule. */
|
|
36
64
|
export function isHousekeeping(toolName) {
|
|
37
65
|
return HOUSEKEEPING_TOOLS.has(toolName);
|
|
38
66
|
}
|
|
67
|
+
/** `mcp__bir__`: every tool the first-party `bir` MCP server offers is named under it. */
|
|
68
|
+
const SCENARIO_SERVER_PREFIX = `mcp__${SCENARIO_SERVER_KEY}__`;
|
|
69
|
+
/**
|
|
70
|
+
* The direct plan's delivery vehicle. Must equal `DIRECT_TOOL_NAME` in the
|
|
71
|
+
* replay controller (a test holds them together); spelled out here because
|
|
72
|
+
* the controller imports this file.
|
|
73
|
+
*/
|
|
74
|
+
export const RUN_SCENARIO_TOOL = `${SCENARIO_SERVER_PREFIX}run_scenario`;
|
|
75
|
+
/**
|
|
76
|
+
* Whether one call is host housekeeping: never recorded as a step, never a
|
|
77
|
+
* divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
|
|
78
|
+
* a `Bash` call's `command` is read from it.
|
|
79
|
+
*/
|
|
80
|
+
export function isHousekeepingCall(toolName, toolInput) {
|
|
81
|
+
if (HOUSEKEEPING_TOOLS.has(toolName))
|
|
82
|
+
return true;
|
|
83
|
+
if (toolName.startsWith(SCENARIO_SERVER_PREFIX))
|
|
84
|
+
return toolName !== RUN_SCENARIO_TOOL;
|
|
85
|
+
if (toolName === "Bash" || toolName === "PowerShell") {
|
|
86
|
+
const command = toolInput?.command;
|
|
87
|
+
if (typeof command !== "string")
|
|
88
|
+
return false;
|
|
89
|
+
return toolName === "Bash" ? isBirOnlyCommand(command) : isBirOnlyPowerShell(command);
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
|
|
95
|
+
* Bash reader would get it wrong: a backslash is a path separator, not an
|
|
96
|
+
* escape; the escape is the backtick; `$(…)` runs code even inside double
|
|
97
|
+
* quotes; `&` at the start of a part is the call operator. Deliberately
|
|
98
|
+
* narrower than the Bash reader: parts split on `;`, `&&`, `||` and newlines
|
|
99
|
+
* (outside quotes); each is `bir …` (as {@link isBirInvocation} reads it, also
|
|
100
|
+
* after a leading `&`) or a plain `cd`/`Set-Location`/`Push-Location <dir>`.
|
|
101
|
+
* A pipe, a backtick, `$(` or `@(` anywhere, a brace or parenthesis outside
|
|
102
|
+
* quotes, an `@` starting a word, or a quote left open makes the answer false — for the same
|
|
103
|
+
* reason as there: when unsure, record it.
|
|
104
|
+
*/
|
|
105
|
+
export function isBirOnlyPowerShell(command) {
|
|
106
|
+
if (command.includes("`") || command.includes("$(") || command.includes("@("))
|
|
107
|
+
return false;
|
|
108
|
+
const segments = [];
|
|
109
|
+
let words = [];
|
|
110
|
+
let word = "";
|
|
111
|
+
let inWord = false;
|
|
112
|
+
const endWord = () => {
|
|
113
|
+
if (inWord)
|
|
114
|
+
words.push(word);
|
|
115
|
+
word = "";
|
|
116
|
+
inWord = false;
|
|
117
|
+
};
|
|
118
|
+
const endSegment = () => {
|
|
119
|
+
endWord();
|
|
120
|
+
segments.push(words);
|
|
121
|
+
words = [];
|
|
122
|
+
};
|
|
123
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
124
|
+
const c = command[i];
|
|
125
|
+
if (c === "'" || c === '"') {
|
|
126
|
+
const close = command.indexOf(c, i + 1);
|
|
127
|
+
if (close < 0)
|
|
128
|
+
return false;
|
|
129
|
+
word += command.slice(i + 1, close);
|
|
130
|
+
inWord = true;
|
|
131
|
+
i = close;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === "\n" || c === "\r" || c === ";") {
|
|
135
|
+
endSegment();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if ((c === "&" || c === "|") && command[i + 1] === c) {
|
|
139
|
+
endSegment();
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (c === "|" || c === "{" || c === "}" || c === "(" || c === ")")
|
|
144
|
+
return false;
|
|
145
|
+
// `@` opens an array, a hash or a splat only at the start of a word; inside
|
|
146
|
+
// one (`…\node_modules\@basein\runner\…`) it is a plain character.
|
|
147
|
+
if (c === "@" && !inWord)
|
|
148
|
+
return false;
|
|
149
|
+
if (c === "&") {
|
|
150
|
+
// The call operator, as a word of its own at the start of a part; a
|
|
151
|
+
// redirection's `2>&1` keeps its `&` inside the word.
|
|
152
|
+
if (inWord && /\d?>$/.test(word)) {
|
|
153
|
+
word += c;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (inWord || words.length > 0)
|
|
157
|
+
return false;
|
|
158
|
+
words.push("&");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (c === " " || c === "\t") {
|
|
162
|
+
endWord();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
word += c;
|
|
166
|
+
inWord = true;
|
|
167
|
+
}
|
|
168
|
+
endSegment();
|
|
169
|
+
let bir = false;
|
|
170
|
+
for (const raw of segments) {
|
|
171
|
+
const parts = raw[0] === "&" ? raw.slice(1) : raw;
|
|
172
|
+
if (parts.length === 0) {
|
|
173
|
+
if (raw.length > 0)
|
|
174
|
+
return false;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (raw[0] !== "&" && /^(cd|chdir|sl|set-location|pushd|push-location)$/i.test(parts[0])) {
|
|
178
|
+
if (parts.length > 2 || parts.slice(1).some(isRedirect))
|
|
179
|
+
return false;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!isBirInvocation(parts.filter((w) => !isRedirect(w))))
|
|
183
|
+
return false;
|
|
184
|
+
bir = true;
|
|
185
|
+
}
|
|
186
|
+
return bir;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* True when a shell command runs `bir` and nothing else: split on `&&`, `||`,
|
|
190
|
+
* `;`, `|`, `&` and newlines (outside quotes), every part is a `bir`
|
|
191
|
+
* invocation or a plain `cd <dir>`, and at least one is `bir`.
|
|
192
|
+
*
|
|
193
|
+
* A `bir` invocation is `bir` / `bir.cmd` / a path ending in `bir`, `bir.cmd`,
|
|
194
|
+
* `bir.js` or `bir.ps1`; `npx [-y] [-p @basein/runner…] [@basein/runner…] bir …`;
|
|
195
|
+
* or `node <path>/bir.js …` — each optionally after `NAME=value` assignments,
|
|
196
|
+
* which only set `bir`'s own environment. A heredoc is read as the data it is,
|
|
197
|
+
* because `bir scenario check … --input-logic - <<'EOF'` is the natural way to
|
|
198
|
+
* hand `bir` a logic body, and its lines are JavaScript, not commands.
|
|
199
|
+
*
|
|
200
|
+
* Anything that could run other code while looking like `bir` makes the answer
|
|
201
|
+
* false: a command substitution (`$(…)`, backticks) outside single quotes and
|
|
202
|
+
* quoted heredocs, and a subshell, group or process substitution (`(`, `{`).
|
|
203
|
+
* So does a quote or heredoc left open — a parse this side is not sure of is
|
|
204
|
+
* not one to hide a step on.
|
|
205
|
+
*/
|
|
206
|
+
export function isBirOnlyCommand(command) {
|
|
207
|
+
const segments = splitCommand(command);
|
|
208
|
+
if (!segments)
|
|
209
|
+
return false;
|
|
210
|
+
let bir = false;
|
|
211
|
+
for (const words of segments) {
|
|
212
|
+
if (words.length === 0)
|
|
213
|
+
continue;
|
|
214
|
+
if (isPlainCd(words))
|
|
215
|
+
continue;
|
|
216
|
+
if (!isBirInvocation(words))
|
|
217
|
+
return false;
|
|
218
|
+
bir = true;
|
|
219
|
+
}
|
|
220
|
+
return bir;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The command's parts, each as its words (quotes removed, as the shell would),
|
|
224
|
+
* or null when it cannot be read with confidence. Redirections stay words:
|
|
225
|
+
* `bir … < step.js` and `bir … > out.json 2>&1` are still only `bir`.
|
|
226
|
+
*/
|
|
227
|
+
function splitCommand(command) {
|
|
228
|
+
const n = command.length;
|
|
229
|
+
const segments = [];
|
|
230
|
+
const heredocs = [];
|
|
231
|
+
let words = [];
|
|
232
|
+
let word = "";
|
|
233
|
+
let inWord = false;
|
|
234
|
+
const endWord = () => {
|
|
235
|
+
if (inWord)
|
|
236
|
+
words.push(word);
|
|
237
|
+
word = "";
|
|
238
|
+
inWord = false;
|
|
239
|
+
};
|
|
240
|
+
const endSegment = () => {
|
|
241
|
+
endWord();
|
|
242
|
+
segments.push(words);
|
|
243
|
+
words = [];
|
|
244
|
+
};
|
|
245
|
+
for (let i = 0; i < n; i += 1) {
|
|
246
|
+
const c = command[i];
|
|
247
|
+
if (c === "'") {
|
|
248
|
+
const close = command.indexOf("'", i + 1);
|
|
249
|
+
if (close === -1)
|
|
250
|
+
return null;
|
|
251
|
+
word += command.slice(i + 1, close);
|
|
252
|
+
inWord = true;
|
|
253
|
+
i = close;
|
|
254
|
+
}
|
|
255
|
+
else if (c === '"') {
|
|
256
|
+
let j = i + 1;
|
|
257
|
+
for (; j < n && command[j] !== '"'; j += 1) {
|
|
258
|
+
const d = command[j];
|
|
259
|
+
if (d === "`" || (d === "$" && command[j + 1] === "("))
|
|
260
|
+
return null;
|
|
261
|
+
if (d === "\\" && j + 1 < n) {
|
|
262
|
+
j += 1;
|
|
263
|
+
// Inside double quotes a backslash escapes only these; before anything else it stays.
|
|
264
|
+
if (!'"\$`\n'.includes(command[j]))
|
|
265
|
+
word += "\\";
|
|
266
|
+
}
|
|
267
|
+
word += command[j];
|
|
268
|
+
}
|
|
269
|
+
if (j >= n)
|
|
270
|
+
return null;
|
|
271
|
+
inWord = true;
|
|
272
|
+
i = j;
|
|
273
|
+
}
|
|
274
|
+
else if (c === "`" || (c === "$" && command[i + 1] === "(")) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
else if (c === "$" && command[i + 1] === "{") {
|
|
278
|
+
// `${NAME}` only reads a variable; a substitution inside it was refused above.
|
|
279
|
+
const close = command.indexOf("}", i + 2);
|
|
280
|
+
if (close === -1)
|
|
281
|
+
return null;
|
|
282
|
+
const inner = command.slice(i + 2, close);
|
|
283
|
+
if (/`|\$\(/.test(inner))
|
|
284
|
+
return null;
|
|
285
|
+
word += command.slice(i, close + 1);
|
|
286
|
+
inWord = true;
|
|
287
|
+
i = close;
|
|
288
|
+
}
|
|
289
|
+
else if (c === "\\") {
|
|
290
|
+
// A backslash-newline joins lines; any other escaped character is itself.
|
|
291
|
+
if (command[i + 1] === "\n") {
|
|
292
|
+
i += 1;
|
|
293
|
+
}
|
|
294
|
+
else if (command[i + 1] === "\r" && command[i + 2] === "\n") {
|
|
295
|
+
i += 2;
|
|
296
|
+
}
|
|
297
|
+
else if (i + 1 < n) {
|
|
298
|
+
word += command[i + 1];
|
|
299
|
+
inWord = true;
|
|
300
|
+
i += 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else if (c === "<" && command[i + 1] === "<" && command[i + 2] === "<") {
|
|
304
|
+
// A here-string: one word of data, read as the words that follow.
|
|
305
|
+
word += "<<<";
|
|
306
|
+
inWord = true;
|
|
307
|
+
i += 2;
|
|
308
|
+
}
|
|
309
|
+
else if (c === "<" && command[i + 1] === "<") {
|
|
310
|
+
endWord();
|
|
311
|
+
let j = i + 2;
|
|
312
|
+
const stripTabs = command[j] === "-";
|
|
313
|
+
if (stripTabs)
|
|
314
|
+
j += 1;
|
|
315
|
+
while (command[j] === " " || command[j] === "\t")
|
|
316
|
+
j += 1;
|
|
317
|
+
let delimiter = "";
|
|
318
|
+
let literal = false;
|
|
319
|
+
for (; j < n; j += 1) {
|
|
320
|
+
const d = command[j];
|
|
321
|
+
if (d === "'" || d === '"') {
|
|
322
|
+
const close = command.indexOf(d, j + 1);
|
|
323
|
+
if (close === -1)
|
|
324
|
+
return null;
|
|
325
|
+
delimiter += command.slice(j + 1, close);
|
|
326
|
+
literal = true;
|
|
327
|
+
j = close;
|
|
328
|
+
}
|
|
329
|
+
else if (d === "\\" && j + 1 < n) {
|
|
330
|
+
delimiter += command[j + 1];
|
|
331
|
+
literal = true;
|
|
332
|
+
j += 1;
|
|
333
|
+
}
|
|
334
|
+
else if (/[\s;&|<>(){}]/.test(d)) {
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
delimiter += d;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!delimiter)
|
|
342
|
+
return null;
|
|
343
|
+
heredocs.push({ delimiter, literal, stripTabs });
|
|
344
|
+
words.push("<<", delimiter);
|
|
345
|
+
i = j - 1;
|
|
346
|
+
}
|
|
347
|
+
else if (c === "\n") {
|
|
348
|
+
endSegment();
|
|
349
|
+
// The bodies of the heredocs this line opened come next, in order.
|
|
350
|
+
while (heredocs.length > 0) {
|
|
351
|
+
const h = heredocs.shift();
|
|
352
|
+
let closed = false;
|
|
353
|
+
for (let pos = i + 1; pos <= n;) {
|
|
354
|
+
let eol = command.indexOf("\n", pos);
|
|
355
|
+
if (eol === -1)
|
|
356
|
+
eol = n;
|
|
357
|
+
let line = command.slice(pos, eol);
|
|
358
|
+
if (line.endsWith("\r"))
|
|
359
|
+
line = line.slice(0, -1);
|
|
360
|
+
if ((h.stripTabs ? line.replace(/^\t+/, "") : line) === h.delimiter) {
|
|
361
|
+
i = eol;
|
|
362
|
+
closed = true;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (!h.literal && /`|\$\(/.test(line))
|
|
366
|
+
return null;
|
|
367
|
+
pos = eol + 1;
|
|
368
|
+
}
|
|
369
|
+
if (!closed)
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else if (c === ";" || c === "|") {
|
|
374
|
+
endSegment();
|
|
375
|
+
}
|
|
376
|
+
else if (c === "&") {
|
|
377
|
+
// `2>&1` and `&>file` redirect; every other `&` ends a command.
|
|
378
|
+
if (command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">") {
|
|
379
|
+
word += c;
|
|
380
|
+
inWord = true;
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
endSegment();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (c === "(" || c === ")" || c === "{" || c === "}") {
|
|
387
|
+
// A subshell, a group or a process substitution: not something this reads.
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
else if (c === " " || c === "\t" || c === "\r") {
|
|
391
|
+
endWord();
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
word += c;
|
|
395
|
+
inWord = true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// A heredoc whose body never came.
|
|
399
|
+
if (heredocs.length > 0)
|
|
400
|
+
return null;
|
|
401
|
+
endSegment();
|
|
402
|
+
return segments;
|
|
403
|
+
}
|
|
404
|
+
/** `cd` or `cd <dir>`, and nothing more. */
|
|
405
|
+
function isPlainCd(words) {
|
|
406
|
+
return words[0] === "cd" && words.length <= 2 && !words.slice(1).some(isRedirect);
|
|
407
|
+
}
|
|
408
|
+
const isRedirect = (w) => /^\d*[<>]|^&>/.test(w);
|
|
409
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
410
|
+
/** The last path component, on either separator. */
|
|
411
|
+
const basename = (w) => w.slice(Math.max(w.lastIndexOf("/"), w.lastIndexOf("\\")) + 1);
|
|
412
|
+
/** `bir`, `bir.cmd`, `bir.ps1`, or any path to one of those or to `bir.js`. */
|
|
413
|
+
function isBirProgram(w) {
|
|
414
|
+
const base = basename(w);
|
|
415
|
+
if (/^bir(\.cmd|\.ps1)?$/i.test(base))
|
|
416
|
+
return true;
|
|
417
|
+
return /^bir\.js$/i.test(base) && base !== w;
|
|
418
|
+
}
|
|
419
|
+
const isNode = (w) => /^node(\.exe)?$/i.test(basename(w));
|
|
420
|
+
/** `@basein/runner`, `@basein/runner@0.2.11`, `@basein/runner@latest`. */
|
|
421
|
+
const isRunnerPackage = (w) => /^@basein\/runner(@[\w.+-]+)?$/.test(w);
|
|
422
|
+
function isBirInvocation(input) {
|
|
423
|
+
let words = input;
|
|
424
|
+
while (words.length > 0 && ASSIGNMENT.test(words[0]))
|
|
425
|
+
words = words.slice(1);
|
|
426
|
+
const first = words[0];
|
|
427
|
+
if (first === undefined)
|
|
428
|
+
return false;
|
|
429
|
+
if (isBirProgram(first))
|
|
430
|
+
return true;
|
|
431
|
+
if (isNode(first)) {
|
|
432
|
+
// Exactly `node <path>/bir.js …`: a node flag could load other code first.
|
|
433
|
+
const script = words[1];
|
|
434
|
+
return script !== undefined && /^bir\.js$/i.test(basename(script));
|
|
435
|
+
}
|
|
436
|
+
if (first === "npx") {
|
|
437
|
+
let i = 1;
|
|
438
|
+
for (; i < words.length; i += 1) {
|
|
439
|
+
const w = words[i];
|
|
440
|
+
if (w === "-y" || w === "--yes")
|
|
441
|
+
continue;
|
|
442
|
+
if ((w === "-p" || w === "--package") && isRunnerPackage(words[i + 1] ?? "")) {
|
|
443
|
+
i += 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (w.startsWith("--package=") && isRunnerPackage(w.slice("--package=".length)))
|
|
447
|
+
continue;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
if (isRunnerPackage(words[i] ?? ""))
|
|
451
|
+
i += 1;
|
|
452
|
+
return words[i] === "bir";
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
39
456
|
//# sourceMappingURL=housekeeping.js.map
|
|
@@ -299,7 +299,9 @@ export declare class ReplayController {
|
|
|
299
299
|
* and hand it back through the best channel available; if even that fails,
|
|
300
300
|
* abort to an ordinary turn.
|
|
301
301
|
*/
|
|
302
|
-
preTool(state: ReplayState, toolName: string, toolUseId: string
|
|
302
|
+
preTool(state: ReplayState, toolName: string, toolUseId: string,
|
|
303
|
+
/** The call's `tool_input`: whether a `Bash` call is only `bir` is read from it (D13). */
|
|
304
|
+
toolInput?: unknown): Promise<PreToolAction>;
|
|
303
305
|
/**
|
|
304
306
|
* `PostToolUse` for a call this plan pinned.
|
|
305
307
|
*
|