@basein/runner 0.3.26 → 0.3.28
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 +13 -0
- package/dist/bin/bir-scenario.js +7 -5
- package/dist/bin/bir.js +36 -5
- package/dist/bin/investigate.d.ts +13 -0
- package/dist/bin/investigate.js +67 -2
- package/dist/bin/scenario-edit.d.ts +102 -1
- package/dist/bin/scenario-edit.js +318 -23
- package/dist/control/server.d.ts +31 -2
- package/dist/control/server.js +170 -17
- package/dist/control/transcript.d.ts +9 -0
- package/dist/control/transcript.js +56 -9
- package/dist/record/recorder.d.ts +50 -0
- package/dist/record/remote-recorder.d.ts +9 -1
- package/dist/record/remote-recorder.js +38 -0
- package/dist/replay/controller.d.ts +71 -2
- package/dist/replay/controller.js +137 -19
- package/dist/replay/plan.d.ts +41 -0
- package/dist/replay/plan.js +171 -3
- package/dist/replay/reads-only.d.ts +48 -0
- package/dist/replay/reads-only.js +161 -0
- package/docs/calculatedReplayGuide.md +74 -1
- package/package.json +1 -1
package/dist/replay/plan.js
CHANGED
|
@@ -132,6 +132,47 @@ function clipReasoning(reasoning) {
|
|
|
132
132
|
const first = (reasoning ?? "").trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
133
133
|
return first.length > MAX_REASONING_CHARS ? `${first.slice(0, MAX_REASONING_CHARS)}…` : first;
|
|
134
134
|
}
|
|
135
|
+
// ── the recording's values in a directive's text (B2 is11.md) ────────────────
|
|
136
|
+
/**
|
|
137
|
+
* A recorded value as a text may name it: a string or a number at least this
|
|
138
|
+
* long — a one-character sample ("5", "a") is found in too many words — and
|
|
139
|
+
* no longer than a name or a title is.
|
|
140
|
+
*/
|
|
141
|
+
const RECORDED_VALUE_MIN = 2;
|
|
142
|
+
const RECORDED_VALUE_MAX = 200;
|
|
143
|
+
/** One of this request's values, as a directive shows it: this long at most. */
|
|
144
|
+
const SHOWN_VALUE_MAX = 60;
|
|
145
|
+
/** This many of this request's values at most on a direct directive's values line… */
|
|
146
|
+
const SHOWN_VALUES = 4;
|
|
147
|
+
/** …and this long at most, all of them. */
|
|
148
|
+
const SHOWN_VALUES_CHARS = 200;
|
|
149
|
+
/** The recorded value `value` as a text may name it, or undefined. */
|
|
150
|
+
function recordedText(value) {
|
|
151
|
+
const text = typeof value === "number" && Number.isFinite(value)
|
|
152
|
+
? String(value)
|
|
153
|
+
: typeof value === "string"
|
|
154
|
+
? value.trim()
|
|
155
|
+
: undefined;
|
|
156
|
+
return text !== undefined && text.length >= RECORDED_VALUE_MIN && text.length <= RECORDED_VALUE_MAX ? text : undefined;
|
|
157
|
+
}
|
|
158
|
+
/** A value of this request's as plain text — never a mark — or undefined. */
|
|
159
|
+
function valueText(value) {
|
|
160
|
+
let text;
|
|
161
|
+
if (typeof value === "boolean")
|
|
162
|
+
text = String(value);
|
|
163
|
+
else if (typeof value === "number" && Number.isFinite(value))
|
|
164
|
+
text = String(value);
|
|
165
|
+
else if (typeof value === "string")
|
|
166
|
+
text = value.replace(/\s+/g, " ").trim();
|
|
167
|
+
if (!text || markedKey(text) !== undefined)
|
|
168
|
+
return undefined;
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
171
|
+
const clipValue = (text) => text.length > SHOWN_VALUE_MAX ? `${text.slice(0, SHOWN_VALUE_MAX)}…` : text;
|
|
172
|
+
const sameText = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
173
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
174
|
+
/** A parameter's role, for a value a text must not name: `assignee_name` → `<assignee name>`. */
|
|
175
|
+
const roleOf = (key) => `<${key.replace(/[_-]+/g, " ").trim() || key}>`;
|
|
135
176
|
/** A step's computed arguments, as a steer directive shows them: this long at most. */
|
|
136
177
|
const MAX_DIRECTIVE_ARGS_CHARS = 240;
|
|
137
178
|
/**
|
|
@@ -854,6 +895,120 @@ export class ScenarioReplayPlan {
|
|
|
854
895
|
}
|
|
855
896
|
return out;
|
|
856
897
|
}
|
|
898
|
+
/**
|
|
899
|
+
* This request's value of `key`, as a directive may show it, or undefined
|
|
900
|
+
* (B2 is11.md): once the reading has settled, a plain value the reading
|
|
901
|
+
* took from the request — never a mark, and never a value a step works out
|
|
902
|
+
* (`from: "step"`): before the run nobody has that one, and the recorded one
|
|
903
|
+
* is another request's (issue 1212).
|
|
904
|
+
*/
|
|
905
|
+
shownValueOf(key, p) {
|
|
906
|
+
if (!this.paramsSettled || p?.from === "step")
|
|
907
|
+
return undefined;
|
|
908
|
+
return valueText(this.derivedParams[key]);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* A text written from the recording — the intent, a step's reasoning — put
|
|
912
|
+
* right for this request, for a directive to show (B2 is11.md).
|
|
913
|
+
*
|
|
914
|
+
* Both name the recording's values: "set Olga up", "moves each lead to
|
|
915
|
+
* Olga". Shown beside a request for Carlos, the name tells the model the
|
|
916
|
+
* plan is for someone else. A careful model then checks before it runs
|
|
917
|
+
* anything, and a direct plan's first call that was not `run_scenario`
|
|
918
|
+
* retired it — so the replay was lost on exactly the turn replay is for: the
|
|
919
|
+
* same task with a new value. The steps never read the text: they take
|
|
920
|
+
* Carlos from the request.
|
|
921
|
+
*
|
|
922
|
+
* Each recorded value of a parameter found in the text, as a whole word, is
|
|
923
|
+
* replaced by this request's own value where the plan has one it may show
|
|
924
|
+
* ({@link shownValueOf}), and else by the parameter's role (`<assignee
|
|
925
|
+
* name>`): a target the request did not name, a value a step works out, or
|
|
926
|
+
* any value while the reading has not settled. A value the same as the
|
|
927
|
+
* recorded one stays as it is. One pass, longest value first, so nothing put
|
|
928
|
+
* in is replaced again.
|
|
929
|
+
*/
|
|
930
|
+
forThisRequest(text) {
|
|
931
|
+
return this.rewriter()(text);
|
|
932
|
+
}
|
|
933
|
+
/** {@link forThisRequest} as one function, built once for a directive's many texts. */
|
|
934
|
+
rewriter() {
|
|
935
|
+
const schema = this.scenario.paramsObject;
|
|
936
|
+
if (!schema || typeof schema !== "object")
|
|
937
|
+
return (text) => text;
|
|
938
|
+
/** Recorded value (lower case) → what goes in its place; null where it stays. */
|
|
939
|
+
const swaps = new Map();
|
|
940
|
+
for (const [key, p] of Object.entries(schema)) {
|
|
941
|
+
const recorded = recordedText(p?.sampleValue);
|
|
942
|
+
if (!recorded)
|
|
943
|
+
continue;
|
|
944
|
+
const now = this.shownValueOf(key, p);
|
|
945
|
+
const swap = now !== undefined && sameText(now, recorded) ? null : now !== undefined ? clipValue(now) : roleOf(key);
|
|
946
|
+
const k = recorded.toLowerCase();
|
|
947
|
+
// Two parameters recorded with one value: a change wins over "stays".
|
|
948
|
+
if (!swaps.has(k) || swaps.get(k) === null)
|
|
949
|
+
swaps.set(k, swap);
|
|
950
|
+
}
|
|
951
|
+
const found = [...swaps.entries()].filter(([, swap]) => swap !== null).map(([k]) => k);
|
|
952
|
+
if (found.length === 0)
|
|
953
|
+
return (text) => text;
|
|
954
|
+
found.sort((a, b) => b.length - a.length);
|
|
955
|
+
let pattern;
|
|
956
|
+
try {
|
|
957
|
+
pattern = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${found.map(escapeRegExp).join("|")})(?![\\p{L}\\p{N}_])`, "giu");
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
return (text) => text;
|
|
961
|
+
}
|
|
962
|
+
return (text) => (text ? text.replace(pattern, (m) => swaps.get(m.toLowerCase()) ?? m) : text);
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* This request's own values, for the direct directive's line that says the
|
|
966
|
+
* steps take them (B2 is11.md): what the reading took from the request.
|
|
967
|
+
* The values the request names in words come first — a name, a title: what
|
|
968
|
+
* a reader holds the note against — those unlike the recording's first;
|
|
969
|
+
* then the values worked out from the day ("tomorrow" as a date). Targets
|
|
970
|
+
* only: never a setting, never a value a step works out, never a mark.
|
|
971
|
+
* Undefined until the reading has settled, or when it named none.
|
|
972
|
+
*/
|
|
973
|
+
requestValues() {
|
|
974
|
+
if (!this.paramsSettled)
|
|
975
|
+
return undefined;
|
|
976
|
+
const schema = this.scenario.paramsObject;
|
|
977
|
+
if (!schema || typeof schema !== "object")
|
|
978
|
+
return undefined;
|
|
979
|
+
const items = [];
|
|
980
|
+
let order = 0;
|
|
981
|
+
for (const [key, p] of Object.entries(schema)) {
|
|
982
|
+
order += 1;
|
|
983
|
+
if (p?.kind === "setting")
|
|
984
|
+
continue;
|
|
985
|
+
const now = this.shownValueOf(key, p);
|
|
986
|
+
if (now === undefined)
|
|
987
|
+
continue;
|
|
988
|
+
const recorded = recordedText(p?.sampleValue);
|
|
989
|
+
items.push({
|
|
990
|
+
key,
|
|
991
|
+
text: clipValue(now),
|
|
992
|
+
// Named in the request's own words; an older service sends no `from`.
|
|
993
|
+
said: p?.from === undefined || p.from === "prompt",
|
|
994
|
+
differs: recorded !== undefined && !sameText(now, recorded),
|
|
995
|
+
order,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
items.sort((a, b) => Number(b.said) - Number(a.said) || Number(b.differs) - Number(a.differs) || a.order - b.order);
|
|
999
|
+
const parts = [];
|
|
1000
|
+
let chars = 0;
|
|
1001
|
+
for (const item of items) {
|
|
1002
|
+
if (parts.length >= SHOWN_VALUES)
|
|
1003
|
+
break;
|
|
1004
|
+
const part = `${item.key} ${JSON.stringify(item.text)}`;
|
|
1005
|
+
if (parts.length > 0 && chars + part.length > SHOWN_VALUES_CHARS)
|
|
1006
|
+
break;
|
|
1007
|
+
parts.push(part);
|
|
1008
|
+
chars += part.length + 2;
|
|
1009
|
+
}
|
|
1010
|
+
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
1011
|
+
}
|
|
857
1012
|
/**
|
|
858
1013
|
* The directive injected via `UserPromptSubmit`'s `additionalContext`.
|
|
859
1014
|
*
|
|
@@ -880,9 +1035,14 @@ export class ScenarioReplayPlan {
|
|
|
880
1035
|
// A sub-task plan (armed mid-task) never tells the agent to stop calling
|
|
881
1036
|
// tools: the steps below are a part of the task it is already doing.
|
|
882
1037
|
const subTask = opts?.subTask ?? this.subTask;
|
|
1038
|
+
// The intent and each step's reasoning were written from the recording,
|
|
1039
|
+
// so they name its values ("set Olga up"); shown here, they say this
|
|
1040
|
+
// request's — or the value's role — never another request's (B2 is11.md).
|
|
1041
|
+
const fix = this.rewriter();
|
|
883
1042
|
// The intent is a paragraph the analyser wrote; it is context, not an
|
|
884
1043
|
// instruction, so it is clipped rather than dumped whole into the prompt.
|
|
885
|
-
const
|
|
1044
|
+
const written = fix(this.intent);
|
|
1045
|
+
const intent = written.length > 240 ? `${written.slice(0, 240)}…` : written;
|
|
886
1046
|
if (this.mode === "direct") {
|
|
887
1047
|
// The steps are not listed. The model does not call them, and what the
|
|
888
1048
|
// tool returns names every call and says whether the run was whole or
|
|
@@ -890,9 +1050,17 @@ export class ScenarioReplayPlan {
|
|
|
890
1050
|
// cost the agent up to 2,000 characters in the first call of every turn
|
|
891
1051
|
// a plan was armed for (docs/calculatedReplay.md, "How a bundle reads"),
|
|
892
1052
|
// and the tool's result shows an operator watching the session what ran.
|
|
1053
|
+
//
|
|
1054
|
+
// One line says where the steps' values come from, with this request's
|
|
1055
|
+
// own: a model that met another request's value in the note used to
|
|
1056
|
+
// stop to check, and its check retired the plan (B2 is11.md).
|
|
1057
|
+
const values = this.requestValues();
|
|
893
1058
|
return [
|
|
894
1059
|
"[BaseInstRunner calculated replay] A known-good tool sequence was recorded",
|
|
895
|
-
`for this
|
|
1060
|
+
`for a request like this one${intent ? ` (intent: ${intent})` : ""}.`,
|
|
1061
|
+
values
|
|
1062
|
+
? `Its steps take this request's values, not the recording's: ${values}.`
|
|
1063
|
+
: "Its steps take this request's values, not the recording's.",
|
|
896
1064
|
"",
|
|
897
1065
|
`Call ${directToolName} once, with no arguments, before any other tool. It runs the`,
|
|
898
1066
|
"sequence and returns what each call did.",
|
|
@@ -924,7 +1092,7 @@ export class ScenarioReplayPlan {
|
|
|
924
1092
|
return ` (only when ${t.text ?? "this request needs it"})`;
|
|
925
1093
|
};
|
|
926
1094
|
const listing = (withWhy, argsBefore) => planned.map((s, i) => {
|
|
927
|
-
const why = withWhy ? clipReasoning(s.reasoning) : "";
|
|
1095
|
+
const why = withWhy ? clipReasoning(fix(s.reasoning ?? "")) : "";
|
|
928
1096
|
const head = ` ${i + 1}. ${s.toolName}${onlyWhen(i)}${why ? ` — ${why}` : ""}`;
|
|
929
1097
|
const shown = i < argsBefore ? args[i] : undefined;
|
|
930
1098
|
return shown ? `${head}\n ${shown}` : head;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reads-only — whether one call of the agent's only looks, and changes nothing.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE RUNNER ASKS (B2 is11.md, 2026-11-11). A direct plan is announced
|
|
5
|
+
* with "call run_scenario before any other tool", and a first call that is
|
|
6
|
+
* not `run_scenario` used to retire it on the spot (control/server.ts). The
|
|
7
|
+
* rule is there for one danger: a model that has no `run_scenario` makes its
|
|
8
|
+
* own calls, and a divergence would then run every step, writes included,
|
|
9
|
+
* inside a hook nobody approves. But the call that retired the plan in is11
|
|
10
|
+
* was the agent reading its own notes file: it had seen another person's name
|
|
11
|
+
* in the note (Olga, the recording's; the request said Carlos) and checked
|
|
12
|
+
* first. The plan would have done the task right — its steps take the person
|
|
13
|
+
* from the request — and the turn cost a full agent run.
|
|
14
|
+
*
|
|
15
|
+
* So a call that only looks no longer retires a direct plan: it runs as the
|
|
16
|
+
* agent wrote it, and the plan waits, still announced, for `run_scenario`.
|
|
17
|
+
* The first call that may change something still retires it, running nothing
|
|
18
|
+
* (and nothing ever runs in a hook in its place): a plan run after the agent
|
|
19
|
+
* changed something itself could do the same work twice.
|
|
20
|
+
*
|
|
21
|
+
* WHEN IN DOUBT, IT IS A CHANGE. A call judged "only looks" that does change
|
|
22
|
+
* something leaves a plan armed that could then repeat that change; a change
|
|
23
|
+
* judged a change only ends a replay early, as every call did before. So:
|
|
24
|
+
*
|
|
25
|
+
* - Claude Code's built-ins by name: the reading ones only look, every other
|
|
26
|
+
* built-in (a sub-agent, an edit, an unknown new tool) is a change;
|
|
27
|
+
* - `Bash` by its command ({@link commandReadsOnly}), and `PowerShell` never;
|
|
28
|
+
* - an MCP tool by what its own server says of it (`annotations`), else by
|
|
29
|
+
* its name: a reading word and no writing word.
|
|
30
|
+
*
|
|
31
|
+
* The same rule as the service's `readsOnly` (correctionCheck.ts, AC13), kept
|
|
32
|
+
* in step by hand, stricter where a wrong "only looks" costs more here: a
|
|
33
|
+
* command substitution, a process substitution or an `awk` `system(` makes a
|
|
34
|
+
* command a change.
|
|
35
|
+
*/
|
|
36
|
+
import type { ToolAnnotations } from "../record/recorder.js";
|
|
37
|
+
/**
|
|
38
|
+
* Whether this call only looks. `toolInput` is the hook's `tool_input` (only a
|
|
39
|
+
* `Bash` call's `command` is read from it); `hints` is what the tool's own
|
|
40
|
+
* server listed for it, when it listed anything.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readsOnlyCall(toolName: string, toolInput?: unknown, hints?: ToolAnnotations): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a shell command only looks: every part of it is a looking command,
|
|
45
|
+
* nothing is written to a file, and nothing runs code it does not show.
|
|
46
|
+
*/
|
|
47
|
+
export declare function commandReadsOnly(command: string): boolean;
|
|
48
|
+
//# sourceMappingURL=reads-only.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reads-only — whether one call of the agent's only looks, and changes nothing.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE RUNNER ASKS (B2 is11.md, 2026-11-11). A direct plan is announced
|
|
5
|
+
* with "call run_scenario before any other tool", and a first call that is
|
|
6
|
+
* not `run_scenario` used to retire it on the spot (control/server.ts). The
|
|
7
|
+
* rule is there for one danger: a model that has no `run_scenario` makes its
|
|
8
|
+
* own calls, and a divergence would then run every step, writes included,
|
|
9
|
+
* inside a hook nobody approves. But the call that retired the plan in is11
|
|
10
|
+
* was the agent reading its own notes file: it had seen another person's name
|
|
11
|
+
* in the note (Olga, the recording's; the request said Carlos) and checked
|
|
12
|
+
* first. The plan would have done the task right — its steps take the person
|
|
13
|
+
* from the request — and the turn cost a full agent run.
|
|
14
|
+
*
|
|
15
|
+
* So a call that only looks no longer retires a direct plan: it runs as the
|
|
16
|
+
* agent wrote it, and the plan waits, still announced, for `run_scenario`.
|
|
17
|
+
* The first call that may change something still retires it, running nothing
|
|
18
|
+
* (and nothing ever runs in a hook in its place): a plan run after the agent
|
|
19
|
+
* changed something itself could do the same work twice.
|
|
20
|
+
*
|
|
21
|
+
* WHEN IN DOUBT, IT IS A CHANGE. A call judged "only looks" that does change
|
|
22
|
+
* something leaves a plan armed that could then repeat that change; a change
|
|
23
|
+
* judged a change only ends a replay early, as every call did before. So:
|
|
24
|
+
*
|
|
25
|
+
* - Claude Code's built-ins by name: the reading ones only look, every other
|
|
26
|
+
* built-in (a sub-agent, an edit, an unknown new tool) is a change;
|
|
27
|
+
* - `Bash` by its command ({@link commandReadsOnly}), and `PowerShell` never;
|
|
28
|
+
* - an MCP tool by what its own server says of it (`annotations`), else by
|
|
29
|
+
* its name: a reading word and no writing word.
|
|
30
|
+
*
|
|
31
|
+
* The same rule as the service's `readsOnly` (correctionCheck.ts, AC13), kept
|
|
32
|
+
* in step by hand, stricter where a wrong "only looks" costs more here: a
|
|
33
|
+
* command substitution, a process substitution or an `awk` `system(` makes a
|
|
34
|
+
* command a change.
|
|
35
|
+
*/
|
|
36
|
+
/** Claude Code's built-ins that only read. `ToolSearch` and `TodoWrite` are housekeeping (record/housekeeping.ts). */
|
|
37
|
+
const READ_BUILTINS = new Set([
|
|
38
|
+
"Read",
|
|
39
|
+
"Grep",
|
|
40
|
+
"Glob",
|
|
41
|
+
"LS",
|
|
42
|
+
"WebFetch",
|
|
43
|
+
"WebSearch",
|
|
44
|
+
"NotebookRead",
|
|
45
|
+
"TaskOutput",
|
|
46
|
+
"BashOutput",
|
|
47
|
+
// The conversation's own: a question to the person, plan mode in or out.
|
|
48
|
+
// Nothing outside the conversation changes, and the plan may still be
|
|
49
|
+
// what the person says yes to.
|
|
50
|
+
"AskUserQuestion",
|
|
51
|
+
"EnterPlanMode",
|
|
52
|
+
"ExitPlanMode",
|
|
53
|
+
]);
|
|
54
|
+
// Not `query`, `select` or `check`: a database query, a selected option, a
|
|
55
|
+
// check-in can change something.
|
|
56
|
+
const READ_WORDS = new Set([
|
|
57
|
+
"list", "get", "search", "find", "read", "fetch", "lookup", "count", "view", "show",
|
|
58
|
+
"describe", "retrieve", "browse", "peek", "inspect", "snapshot",
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* A word that may change something: a name holding one writes, whatever else
|
|
62
|
+
* it says (`get_or_create_contact`, `find_and_replace`). Some are nouns too
|
|
63
|
+
* (`get_schedule`): such a look is then taken for a change, which only ends a
|
|
64
|
+
* replay early, never lets a change through.
|
|
65
|
+
*/
|
|
66
|
+
const WRITE_WORDS = new Set([
|
|
67
|
+
"create", "add", "set", "update", "upsert", "insert", "put", "post", "send", "delete",
|
|
68
|
+
"remove", "mark", "replace", "archive", "move", "assign", "book", "cancel", "write",
|
|
69
|
+
"edit", "modify", "save", "submit", "approve", "reject", "close", "open", "merge",
|
|
70
|
+
"push", "publish", "upload", "invite", "schedule", "reply", "forward", "transfer",
|
|
71
|
+
"pay", "refund", "notify", "run", "execute", "exec", "apply", "reset", "start", "stop",
|
|
72
|
+
"kill", "enable", "disable", "grant", "revoke", "rename", "tag", "label", "comment",
|
|
73
|
+
]);
|
|
74
|
+
/** A tool name's words: `getJiraIssue` → get, jira, issue; `calendar_search_events` → calendar, search, events. */
|
|
75
|
+
function nameWords(name) {
|
|
76
|
+
return name
|
|
77
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
78
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.split(/[^a-z\d]+/)
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether this call only looks. `toolInput` is the hook's `tool_input` (only a
|
|
85
|
+
* `Bash` call's `command` is read from it); `hints` is what the tool's own
|
|
86
|
+
* server listed for it, when it listed anything.
|
|
87
|
+
*/
|
|
88
|
+
export function readsOnlyCall(toolName, toolInput, hints) {
|
|
89
|
+
if (toolName === "Bash") {
|
|
90
|
+
const command = toolInput?.command;
|
|
91
|
+
return typeof command === "string" && commandReadsOnly(command);
|
|
92
|
+
}
|
|
93
|
+
if (READ_BUILTINS.has(toolName))
|
|
94
|
+
return true;
|
|
95
|
+
// Every other built-in — Write, Edit, PowerShell, a sub-agent, one added
|
|
96
|
+
// after this list was written — may change something.
|
|
97
|
+
if (!toolName.startsWith("mcp__"))
|
|
98
|
+
return false;
|
|
99
|
+
// A server's word beats any guess from the name.
|
|
100
|
+
if (hints?.readOnlyHint === false || hints?.destructiveHint === true)
|
|
101
|
+
return false;
|
|
102
|
+
if (hints?.readOnlyHint === true)
|
|
103
|
+
return true;
|
|
104
|
+
const words = nameWords(toolName.replace(/^mcp__.+?__/, ""));
|
|
105
|
+
if (words.some((w) => WRITE_WORDS.has(w)))
|
|
106
|
+
return false;
|
|
107
|
+
return words.some((w) => READ_WORDS.has(w));
|
|
108
|
+
}
|
|
109
|
+
/** Commands that only look (`ls`, `cat`, `grep …`). */
|
|
110
|
+
const READ_COMMANDS = new Set([
|
|
111
|
+
"ls", "dir", "cat", "type", "head", "tail", "less", "more", "grep", "rg", "egrep", "fgrep",
|
|
112
|
+
"find", "wc", "pwd", "echo", "printf", "date", "whoami", "which", "where", "stat", "file",
|
|
113
|
+
"du", "df", "tree", "jq", "sort", "uniq", "cut", "awk", "diff", "cmp", "basename", "dirname",
|
|
114
|
+
"realpath", "readlink", "true", "false", "test", "ps", "uname", "hostname", "id", "nproc",
|
|
115
|
+
"free", "uptime", "node --version", "npm --version",
|
|
116
|
+
]);
|
|
117
|
+
const READ_GIT = new Set([
|
|
118
|
+
"status", "log", "diff", "show", "branch", "rev-parse", "ls-files", "blame", "remote",
|
|
119
|
+
"describe", "config --get", "grep", "shortlog", "tag --list",
|
|
120
|
+
]);
|
|
121
|
+
/**
|
|
122
|
+
* Whether a shell command only looks: every part of it is a looking command,
|
|
123
|
+
* nothing is written to a file, and nothing runs code it does not show.
|
|
124
|
+
*/
|
|
125
|
+
export function commandReadsOnly(command) {
|
|
126
|
+
const text = command.trim();
|
|
127
|
+
if (!text)
|
|
128
|
+
return false;
|
|
129
|
+
// Code that runs out of sight: a substitution (`$(…)`, backticks), a
|
|
130
|
+
// process substitution, awk's `system(`.
|
|
131
|
+
if (/`|\$\(|<\(|>\(|\bsystem\s*\(/.test(text))
|
|
132
|
+
return false;
|
|
133
|
+
// A redirect into a file (not into /dev/null, not 2>&1), tee, or an in-place edit changes something.
|
|
134
|
+
if (/(^|[^0-9&])>{1,2}\s*(?!&|\/dev\/null)\S/.test(text) ||
|
|
135
|
+
/&>{1,2}\s*(?!\/dev\/null)\S/.test(text) ||
|
|
136
|
+
/\btee\b/.test(text) ||
|
|
137
|
+
/\bsed\b[^|;&]*\s(-i|--in-place)/.test(text)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (/\bfind\b[^|;&]*\s-(delete|exec|execdir|ok|okdir|fprint|fprintf|fls)\b/.test(text))
|
|
141
|
+
return false;
|
|
142
|
+
// A job sent to the background (`cmd &`): not something this reads.
|
|
143
|
+
if (/(?<![>&])&(?![>&])/.test(text.replace(/&&/g, "")))
|
|
144
|
+
return false;
|
|
145
|
+
const parts = text
|
|
146
|
+
.split(/\|\||&&|[|;\n]/)
|
|
147
|
+
.map((p) => p.trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
if (parts.length === 0)
|
|
150
|
+
return false;
|
|
151
|
+
return parts.every((part) => {
|
|
152
|
+
const words = part.replace(/^(\w+=\S+\s+)+/, "").split(/\s+/);
|
|
153
|
+
const cmd = (words[0] ?? "").replace(/^.*[\\/]/, "");
|
|
154
|
+
if (cmd === "git")
|
|
155
|
+
return READ_GIT.has(words[1] ?? "") || READ_GIT.has(`${words[1] ?? ""} ${words[2] ?? ""}`);
|
|
156
|
+
if (cmd === "sed")
|
|
157
|
+
return !/\s(-i|--in-place)/.test(part);
|
|
158
|
+
return READ_COMMANDS.has(cmd) || READ_COMMANDS.has(`${cmd} ${words[1] ?? ""}`);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=reads-only.js.map
|
|
@@ -784,8 +784,9 @@ bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-l
|
|
|
784
784
|
[--freeze | --unfreeze] [--note "<why>"] [--force --note "<why>"] [--revision <n>]
|
|
785
785
|
bir scenario edit <runId|scnId> --param <name> --kind target|setting [--note "<why>"] [--revision <n>]
|
|
786
786
|
bir scenario edits <runId|scnId>
|
|
787
|
-
bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id|scor_id> | --step <n> | --param <name>] [--force --note "<why>"]
|
|
787
|
+
bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id|scor_id|simp_id> | --step <n> | --param <name>] [--force --note "<why>"]
|
|
788
788
|
bir scenario calc <runId|scnId> [--force [--discard-edits]]
|
|
789
|
+
bir scenario learning <runId|scnId> [--runs <n>] [--stop] [--again] (§9.3)
|
|
789
790
|
bir scenario editing on|off|status
|
|
790
791
|
```
|
|
791
792
|
|
|
@@ -1128,6 +1129,10 @@ How undo works:
|
|
|
1128
1129
|
`bir scenario undo` takes back a correction in use when it is the newest change. `--step` never
|
|
1129
1130
|
picks one: a correction changes the whole plan. A recalculation is refused on a corrected plan
|
|
1130
1131
|
as on an edited one (`Not recalculated: the plan carries correction scor_…`).
|
|
1132
|
+
- **A change of the plan's learning phase** (built 2026-11-13, not yet released; §9.3).
|
|
1133
|
+
`bir scenario edits` lists it under its `simp_` id, and `bir scenario undo <id> --edit simp_…`
|
|
1134
|
+
puts back the whole plan from before it. Only by its id: a plain `bir scenario undo` never takes
|
|
1135
|
+
one back, and when nothing else can be undone it names the change in use and the command.
|
|
1131
1136
|
|
|
1132
1137
|
#### What a save changes
|
|
1133
1138
|
|
|
@@ -1346,6 +1351,73 @@ Parameter database: target → setting (revision 4). Undo: bir scenario undo scn
|
|
|
1346
1351
|
| Claude Code asks before each `bir` tool | No `mcp__bir` in `permissions.allow`: the project was wired by `bir install`, or the rule was taken out | `bir setup` here adds it (`bir doctor` says when it is missing) |
|
|
1347
1352
|
| The `bir` server lists no tools at all | The runner is off here (`bir off`, `bir off --here`, or `BIR_OFF=1` where Claude Code started) | `bir on` (or `bir on --here`), and the server tells the open session its list changed. For `BIR_OFF=1`, start Claude Code without it |
|
|
1348
1353
|
|
|
1354
|
+
### 9.3 The learning phase — `bir scenario learning`
|
|
1355
|
+
|
|
1356
|
+
> Built 2026-11-13, **not yet released**: it needs the next `@basein/runner` version and a service
|
|
1357
|
+
> deploy. The design is `learningPhase.md` in the BaseIn repository (LP9, LP11).
|
|
1358
|
+
|
|
1359
|
+
A new plan is in its **learning phase** from the moment it is calculated until 10 of its runs were
|
|
1360
|
+
evaluated. You can change the number for each plan, from 1 to 50. In that time the service scores
|
|
1361
|
+
every run, looks for what to improve, and tries one change at a time. It keeps a change only when
|
|
1362
|
+
the plan got better by the three measures — successful runs first, then cost, then speed — and
|
|
1363
|
+
otherwise takes it back exactly.
|
|
1364
|
+
|
|
1365
|
+
**Do this**
|
|
1366
|
+
|
|
1367
|
+
```
|
|
1368
|
+
bir scenario learning <runId|scnId> # where the learning phase stands
|
|
1369
|
+
bir scenario learning <runId|scnId> --runs <n> # it lasts n evaluated runs (1–50)
|
|
1370
|
+
bir scenario learning <runId|scnId> --stop # end it now
|
|
1371
|
+
bir scenario learning <runId|scnId> --again [--runs <n>] # start a new one
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
**You should see**
|
|
1375
|
+
|
|
1376
|
+
```
|
|
1377
|
+
Learning: 4 of 10 runs evaluated · change 2 on trial (1 of 2 runs)
|
|
1378
|
+
Revision 7: Successful 4 of 6 · cost 40% under the original · time 35% under the original
|
|
1379
|
+
Found in the last run:
|
|
1380
|
+
P3 The summary reads 2 values no step makes (task_id, due_date). (step 4)
|
|
1381
|
+
Changes:
|
|
1382
|
+
simp_0b6f… kept cost The summary emits only the values the answer reads. — Kept: cost 0.62 → 0.41 of the original over 2 runs, every run successful.
|
|
1383
|
+
simp_7d2e… trial speed The owner's lookup is left out: nothing reads it.
|
|
1384
|
+
```
|
|
1385
|
+
|
|
1386
|
+
- The first line is the phase: `Learning: 4 of 10 runs evaluated`, `Learning done after 10 runs`,
|
|
1387
|
+
`Learning stopped after 4 runs`, or `No learning phase`. A change on trial says how many of its
|
|
1388
|
+
runs are in.
|
|
1389
|
+
- The second line is the three measures of the revision the plan is at now. A measure that is not
|
|
1390
|
+
known yet is left out.
|
|
1391
|
+
- `Found in the last run` is what the service's rules found in the newest evaluated run, one
|
|
1392
|
+
problem per line (P1 to P10).
|
|
1393
|
+
- `Changes` is the phase's changes, oldest first: the id, the state (`proposed`, `trial`, `kept`,
|
|
1394
|
+
`taken_back`, `failed_check`, `dismissed`, `replaced`), what it aims at, what it changed, and the
|
|
1395
|
+
decision with its numbers. A `proposed` change waits for you: **Use** on the recording page tries
|
|
1396
|
+
it.
|
|
1397
|
+
- `bir scenario show <id>` ends with the first line (not with `--json`: scripts read that as JSON,
|
|
1398
|
+
and it carries the same facts as `learning`). `bir scenario edits <id>` lists the changes beside
|
|
1399
|
+
your hand edits, and `bir scenario undo <id> --edit simp_…` takes one back (§9.2).
|
|
1400
|
+
- `--json` prints the service's answer unchanged. The exit codes are those of §9.2.
|
|
1401
|
+
|
|
1402
|
+
The runner also tells the service more about each run, for the learning phase's rules about cost
|
|
1403
|
+
and time: the execution report's `turn` says whether the plan ran direct or steered, the turn's
|
|
1404
|
+
model calls and tokens, how many characters the plan handed the agent to read, whether that was
|
|
1405
|
+
the brief bundle, how long the agent's answer was, and, on a hand-over, where the step stood and
|
|
1406
|
+
why it stopped. An older service ignores it.
|
|
1407
|
+
|
|
1408
|
+
**If it goes wrong**
|
|
1409
|
+
|
|
1410
|
+
| What `bir` says | Why | What to do |
|
|
1411
|
+
|---|---|---|
|
|
1412
|
+
| `Not changed (builtin)` | Update bir and Remove bir have no learning phase | Nothing |
|
|
1413
|
+
| `Not changed (learning_off)` | The service runs with `SCENARIO_LEARNING=off` | Ask whoever runs the service |
|
|
1414
|
+
| `Not changed (not_ready)` | The plan is not ready, or a calculation of it is running | Wait for it, then try again |
|
|
1415
|
+
| `Not changed (invalid_input)` | The service did not take the request; `bir` prints its details | `--runs` takes 1 to 50 |
|
|
1416
|
+
| `the service does not know the learning phase yet` | The service is older than this runner | Wait for the service deploy |
|
|
1417
|
+
| `No learning phase` | The plan was in use before the learning phase existed — or it has none at first: a fragment, or another host's plan | For a plan in use from before: `bir scenario learning <id> --again` starts one |
|
|
1418
|
+
| `Not undone (not_latest_change)` | A later change of the plan stands on that one | Take back the newer one first: `bir` prints both commands when the service names it, and `bir scenario edits <id>` lists the changes, newest first |
|
|
1419
|
+
| `Not undone (not_used)` | The change was never used: it is proposed, dismissed, or failed its check | Nothing to take back |
|
|
1420
|
+
|
|
1349
1421
|
---
|
|
1350
1422
|
|
|
1351
1423
|
## 10. What is built
|
|
@@ -1368,6 +1440,7 @@ part of it is not deployed yet.
|
|
|
1368
1440
|
| **§6.5** *(the package is not on PyPI; the service's part is not deployed)* | A plan stepped through by the adapter of a host that is not Claude Code: the declines `host_records_only` and `host_tool_missing`, the stops `host_rejected` and `host_edited`, the cost from the adapter's `usage` | [control/server.ts](../src/control/server.ts), [controller.ts](../src/replay/controller.ts), [handover.ts](../src/replay/handover.ts), [safety.ts](../src/replay/safety.ts), [bin/investigate.ts](../src/bin/investigate.ts); the proof: `examples/langchain-proof/` in the repository (not in the npm package) |
|
|
1369
1441
|
| **Redo and corrections** *(not yet released; the service's autocorrect.md)* | The report's `redoneBy`; a redone repeat opens with the plan's own calls, whole and marked `byPlan`; steps that run only when (contract 2); `scor_` corrections in `bir scenario edits` and `undo`; `bir investigate`'s repeat, verdict, hold and correction lines | [control/server.ts](../src/control/server.ts), [controller.ts](../src/replay/controller.ts), [plan.ts](../src/replay/plan.ts), [scenario-edit.ts](../src/bin/scenario-edit.ts), [investigate.ts](../src/bin/investigate.ts); tests: [step-tests.test.ts](../test/step-tests.test.ts), [newest-run.test.ts](../test/newest-run.test.ts) |
|
|
1370
1442
|
| **§9.2** *(not yet released)* | `bir scenario show/check/edit/edits/undo/calc/editing`, the `bir` server's scenario tools, `bir` calls never recorded (`editSteps.md` D13) | [bin/scenario-edit.ts](../src/bin/scenario-edit.ts), [bin/bir-scenario.ts](../src/bin/bir-scenario.ts), [record/housekeeping.ts](../src/record/housekeeping.ts); tests: [scenario-edit.test.ts](../test/scenario-edit.test.ts), [bir-scenario.test.ts](../test/bir-scenario.test.ts), [housekeeping.test.ts](../test/housekeeping.test.ts), [replay-server.test.ts](../test/replay-server.test.ts) |
|
|
1443
|
+
| **§9.3** *(not yet released; the service's learningPhase.md)* | `bir scenario learning`, the learning line in `show`, `simp_` changes in `edits` and `undo`; the report's `turn` (LP11) | [bin/scenario-edit.ts](../src/bin/scenario-edit.ts), [controller.ts](../src/replay/controller.ts), [control/server.ts](../src/control/server.ts), [control/transcript.ts](../src/control/transcript.ts), [remote-recorder.ts](../src/record/remote-recorder.ts); tests: [scenario-learning.test.ts](../test/scenario-learning.test.ts), [learning-turn.test.ts](../test/learning-turn.test.ts) |
|
|
1371
1444
|
|
|
1372
1445
|
```bash
|
|
1373
1446
|
npm test # ~1,000 tests, incl. test/replay.test.ts and test/replay-server.test.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.28",
|
|
4
4
|
"description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|