@basein/runner 0.2.5 → 0.2.7

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.
@@ -146,6 +146,7 @@ export class ReplayController {
146
146
  state.declined = code;
147
147
  logLine("replay.decision", {
148
148
  verdict: "no-steer",
149
+ code,
149
150
  run: match.runId,
150
151
  scenario: match.scenarioId ?? undefined,
151
152
  similarity: match.similarity.toFixed(3),
@@ -165,7 +166,7 @@ export class ReplayController {
165
166
  }
166
167
  // Gate 2 — replay enabled.
167
168
  if (!this.enabled)
168
- return decline("BIR_REPLAY is not set", "replay_disabled");
169
+ return decline("BIR_REPLAY=0", "replay_disabled");
169
170
  // Gate 3 — a ready scenario with steps.
170
171
  const scenario = match.scenario;
171
172
  if (!match.scenarioId || !isReadyScenario(scenario)) {
@@ -726,6 +727,7 @@ export class ReplayController {
726
727
  const blame = this.blameStep(steps);
727
728
  return {
728
729
  scenarioId: state.scenarioId,
730
+ runId: d.runId,
729
731
  ticket: state.ticket,
730
732
  outcome: state.outcome,
731
733
  deriveCostUsd: state.deriveCostUsd,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * journal — the audit lines that explain a turn, kept on disk for
3
+ * `bir investigate` (docs/calculatedReplayGuide.md §9.1).
4
+ *
5
+ * `logLine` prints to stderr and is gone when the terminal scrolls. The
6
+ * questions people ask afterwards — why did this prompt not run its scenario,
7
+ * which gate declined, what mode did the plan arm in, what did the execution
8
+ * report — are answered by exactly those lines. So `bir-hooks` opens a journal
9
+ * for its directory and every audit line whose event is in {@link isJournaled}
10
+ * lands in it too, as one JSON object per line with the same fields.
11
+ *
12
+ * One file per directory, keyed like the discovery file, so two projects on
13
+ * one machine never mix. Rotated at {@link JOURNAL_MAX_BYTES} by keeping the
14
+ * newest half. Written best-effort and synchronously: a full disk must never be
15
+ * the reason a hook fails (design §1, rule 4), and a hook's answer must not
16
+ * race the line that explains it.
17
+ */
18
+ export interface JournalEntry {
19
+ /** ISO timestamp, written by the journal, not by the caller. */
20
+ at: string;
21
+ event: string;
22
+ [field: string]: unknown;
23
+ }
24
+ export declare function isJournaled(event: string): boolean;
25
+ export declare const JOURNAL_MAX_BYTES: number;
26
+ export declare function journalPath(cwd: string): string;
27
+ /** Start journaling this directory's audit lines. Returns the file, for the startup log. */
28
+ export declare function openJournal(cwd: string): string;
29
+ export declare function closeJournal(): void;
30
+ /**
31
+ * Append one entry, if a journal is open and the event is one we keep.
32
+ * Called by `logLine` for every audit line, and directly for the few facts
33
+ * that belong in the journal but not on stderr (a prompt preview).
34
+ */
35
+ export declare function journal(event: string, fields?: Record<string, unknown>): void;
36
+ /** Every entry in a journal file, oldest first. A malformed line is skipped, not fatal. */
37
+ export declare function readJournal(path: string): JournalEntry[];
38
+ //# sourceMappingURL=journal.d.ts.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * journal — the audit lines that explain a turn, kept on disk for
3
+ * `bir investigate` (docs/calculatedReplayGuide.md §9.1).
4
+ *
5
+ * `logLine` prints to stderr and is gone when the terminal scrolls. The
6
+ * questions people ask afterwards — why did this prompt not run its scenario,
7
+ * which gate declined, what mode did the plan arm in, what did the execution
8
+ * report — are answered by exactly those lines. So `bir-hooks` opens a journal
9
+ * for its directory and every audit line whose event is in {@link isJournaled}
10
+ * lands in it too, as one JSON object per line with the same fields.
11
+ *
12
+ * One file per directory, keyed like the discovery file, so two projects on
13
+ * one machine never mix. Rotated at {@link JOURNAL_MAX_BYTES} by keeping the
14
+ * newest half. Written best-effort and synchronously: a full disk must never be
15
+ * the reason a hook fails (design §1, rule 4), and a hook's answer must not
16
+ * race the line that explains it.
17
+ */
18
+ import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { controlDir, controlKey, ensureDir } from "../control/paths.js";
21
+ /** Events worth keeping: the run boundaries, every replay decision, the money. */
22
+ const JOURNALED_PREFIXES = ["run.", "replay.", "plan.", "execution."];
23
+ const JOURNALED_EVENTS = new Set(["session.start", "control.listening", "proxy.registered"]);
24
+ export function isJournaled(event) {
25
+ return JOURNALED_EVENTS.has(event) || JOURNALED_PREFIXES.some((p) => event.startsWith(p));
26
+ }
27
+ export const JOURNAL_MAX_BYTES = 4 * 1024 * 1024;
28
+ export function journalPath(cwd) {
29
+ return join(controlDir(), "journal", `${controlKey(cwd)}.jsonl`);
30
+ }
31
+ let current;
32
+ /** Start journaling this directory's audit lines. Returns the file, for the startup log. */
33
+ export function openJournal(cwd) {
34
+ const path = journalPath(cwd);
35
+ ensureDir(dirname(path));
36
+ current = path;
37
+ return path;
38
+ }
39
+ export function closeJournal() {
40
+ current = undefined;
41
+ }
42
+ /**
43
+ * Append one entry, if a journal is open and the event is one we keep.
44
+ * Called by `logLine` for every audit line, and directly for the few facts
45
+ * that belong in the journal but not on stderr (a prompt preview).
46
+ */
47
+ export function journal(event, fields = {}) {
48
+ if (!current || !isJournaled(event))
49
+ return;
50
+ const entry = { at: new Date().toISOString(), event };
51
+ for (const [key, value] of Object.entries(fields)) {
52
+ if (value === undefined || value === null)
53
+ continue;
54
+ entry[key] = value;
55
+ }
56
+ try {
57
+ appendFileSync(current, `${JSON.stringify(entry)}\n`);
58
+ rotate(current);
59
+ }
60
+ catch {
61
+ /* best effort — never the reason a hook fails */
62
+ }
63
+ }
64
+ function rotate(path) {
65
+ let size = 0;
66
+ try {
67
+ size = statSync(path).size;
68
+ }
69
+ catch {
70
+ return;
71
+ }
72
+ if (size <= JOURNAL_MAX_BYTES)
73
+ return;
74
+ const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
75
+ const keep = lines.slice(Math.floor(lines.length / 2));
76
+ writeFileSync(path, keep.length ? `${keep.join("\n")}\n` : "");
77
+ }
78
+ /** Every entry in a journal file, oldest first. A malformed line is skipped, not fatal. */
79
+ export function readJournal(path) {
80
+ if (!existsSync(path))
81
+ return [];
82
+ const out = [];
83
+ for (const line of readFileSync(path, "utf8").split("\n")) {
84
+ if (!line.trim())
85
+ continue;
86
+ try {
87
+ const parsed = JSON.parse(line);
88
+ if (typeof parsed.event === "string" && typeof parsed.at === "string")
89
+ out.push(parsed);
90
+ }
91
+ catch {
92
+ /* a torn last line from a crash, or a hand edit — ignore it */
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ //# sourceMappingURL=journal.js.map
package/dist/util/log.js CHANGED
@@ -18,8 +18,12 @@ export const VERBOSE = process.env.BIR_VERBOSE === "1";
18
18
  * session: the audit log is the only record of what the recorder decided.
19
19
  */
20
20
  export const QUIET = process.env.BIR_QUIET === "1";
21
+ import { journal } from "./journal.js";
21
22
  /** Print one audit line: `[bir] <iso ts> <event> key=value …`. */
22
23
  export function logLine(event, fields = {}) {
24
+ // The journal keeps the decision lines for `bir investigate` (journal.ts).
25
+ // Before the QUIET check on purpose: silence is for the terminal, not the record.
26
+ journal(event, fields);
23
27
  if (QUIET)
24
28
  return;
25
29
  const pairs = [];
@@ -59,7 +59,7 @@ Continuing v1's D1–D8.
59
59
  | **D11** | Execution ownership for MCP steps | **The proxy that owns the upstream** | No second connection, no double-spawned browser, no Agent SDK dependency |
60
60
  | **D12** | Control → proxy channel | **Long-poll**, proxy dials out | No new listeners, no new ports, no new tokens (§13.3, §19.2) |
61
61
  | **D13** | Result delivery in direct mode | A **first-party MCP server** (`bir`), one tool | The model reads a genuine `tool_result`, never a `deny` reason it distrusts (§19.1) |
62
- | **D14** | Default state | **Off.** `BIR_REPLAY=1` plus `bir install --replay` | Replay bypasses permission prompts (§13.2); opt-in is the only defensible default |
62
+ | **D14** | Default state | **On** once `bir install --replay` has run; `BIR_REPLAY=0` turns it off | Replay bypasses permission prompts (§13.2); the install flag is the opt-in, the env var is the kill switch |
63
63
  | **D15** | Parameter derivation | One Haiku call over raw `fetch`; **no API key ⇒ recorded sample values** | Preserves v1's zero-runtime-dependency property, and degrades to a free replay |
64
64
  | **D16** | Savings reporting | Every armed match reports to `POST /scenarios/:id/executions`, including declines | A decline is a *baseline sample*, not silence — it is what keeps the ledger honest |
65
65
 
@@ -189,7 +189,7 @@ Every gate that declines logs **one** `replay.decision` line carrying the reason
189
189
  | # | Gate | Declines when | Outcome floor |
190
190
  |---|---|---|---|
191
191
  | 1 | **A match arrived** | `getMatch()` resolved `null`, or the budget expired (§10) | (no report — nothing matched) |
192
- | 2 | **Replay enabled** | `BIR_REPLAY !== "1"` | `not_steered` |
192
+ | 2 | **Replay enabled** | `BIR_REPLAY === "0"` | `not_steered` |
193
193
  | 3 | **Scenario ready** | `scenario == null`, `state !== "ready"`, or `steps.length === 0` | `not_steered` |
194
194
  | 4 | **Similarity** | `similarity < BIR_MIN_STEER_SIMILARITY` (default **0.92**) | `not_steered` |
195
195
  | 5 | **Tool coverage** | No step is executable anywhere (§5 → `mode: "none"`) | `not_steered` |
@@ -828,7 +828,7 @@ So a scenario is, in effect, a **pre-approved list of tool calls with computed a
828
828
  precisely what makes it valuable, and precisely what makes it dangerous. Four mitigations, all
829
829
  required:
830
830
 
831
- 1. `BIR_REPLAY` off by default; `bir install --replay` a distinct, non-default flag.
831
+ 1. `bir install --replay` a distinct, non-default flag; `BIR_REPLAY=0` a one-line kill switch once installed.
832
832
  2. `BIR_REPLAY_ALLOW_SERVERS` — a comma-separated allowlist of server keys eligible for **direct**
833
833
  execution. Unset means *all wrapped servers*; the guide (§4 of `calculatedReplayGuide.md`)
834
834
  recommends setting it.
@@ -206,7 +206,7 @@ That is what makes replay fast, and it is the whole risk. Two things follow:
206
206
 
207
207
  ```bash
208
208
  bir install --replay # adds the `bir` MCP server; raises the prompt-hook timeout
209
- export BIR_REPLAY=1 # in the terminal that runs bir-hooks
209
+ unset BIR_REPLAY # on by default; only BIR_REPLAY=0 turns it off
210
210
  bir-hooks # restart it
211
211
  ```
212
212
 
@@ -243,7 +243,7 @@ there is exactly one switch and it cannot get out of step with itself.
243
243
 
244
244
  | Variable | Default | What it does |
245
245
  |---|---|---|
246
- | `BIR_REPLAY` | *(unset)* | `1` enables replay. Nothing below matters until it is set |
246
+ | `BIR_REPLAY` | *(on)* | `0` disables replay. Unset or any other value keeps it on; nothing below matters while it is `0` |
247
247
  | `BIR_REPLAY_ALLOW_SERVERS` | *(all wrapped)* | Comma-separated server keys eligible for **direct** execution. **Set this** |
248
248
  | `BIR_MIN_STEER_SIMILARITY` | `0.92` | Below this a match is detected but not replayed (§8) |
249
249
  | `ANTHROPIC_API_KEY` | *(unset)* | **Not required.** Derivation — reading what this request acts on — is done by the service on its key for a signed-in runner. Set this to keep the reading on this machine instead: the prompt then never leaves it, and it is one round trip faster. Signed out *and* unset, only a scenario with nothing to work out replays |
@@ -381,7 +381,7 @@ first prompt that states the task plainly.
381
381
  | Symptom | Cause | Fix |
382
382
  |---|---|---|
383
383
  | No `run.matched`, ever | Prompt below the server's `SIMILARITY_THRESHOLD` | Rephrase closer, or lower it server-side. Confirm the original run is in the list — a sub-`RECORDING_MIN_ACTIONS` run is never embedded |
384
- | `run.matched` but no `plan.armed` | A gate declined | Read the `replay.decision` line's `why=`. Ranked by frequency: `BIR_REPLAY` unset · scenario not `ready` · similarity below threshold · no step is executable |
384
+ | `run.matched` but no `plan.armed` | A gate declined | Read the `replay.decision` line's `why=`. Ranked by frequency: `BIR_REPLAY=0` · scenario not `ready` · similarity below threshold · no step is executable |
385
385
  | `why="no step is executable"` | None of the scenario's tools is a wrapped MCP server and none is a built-in reachable in this session | Wrap the servers the scenario uses (`bir install --server …`) and recalculate |
386
386
  | `plan.armed mode=steer` where you expected `direct` | At least one step is a built-in, an unwrapped MCP server, or `claude-in-chrome` | `bir status` shows what is wrapped. `claude-in-chrome` is `scope: "dynamic"` and can never be wrapped |
387
387
  | Model ignores the directive and diverges every turn | Steering is advisory — the model chooses; `bir` only pins arguments | Expected occasionally. Persistent divergence usually means the scenario's tools do not fit the live task; check `intent` |
@@ -395,6 +395,38 @@ first prompt that states the task plainly.
395
395
 
396
396
  ---
397
397
 
398
+ ### 9.1 `bir investigate` — the table above, applied for you
399
+
400
+ Every audit line that explains a turn is also kept in a **journal**, one JSON
401
+ object per line, under `~/.baseinstrunner/control/journal/` (one file per
402
+ directory, rotated at 4 MB). `bir-hooks` prints its path at start. Ask the
403
+ journal and the service together:
404
+
405
+ ```
406
+ bir investigate # the newest turn in this directory
407
+ bir investigate <run_|scn_|sexec_ id> # that run, scenario or execution
408
+ bir investigate list [--limit n] # recent turns and their verdicts
409
+ bir investigate executions [--limit n] [--user <email|id>] # the ledger
410
+ ```
411
+
412
+ The output is the turn as the journal tells it (matched what, which gate
413
+ declined and why, what mode armed with which coverage, what the execution
414
+ reported), then **runner findings**, then the service's view (the recording,
415
+ the scenario, the baseline and its sample count, the executions with their
416
+ saved $ and %) and **service findings**. Every finding is *problem → cause →
417
+ fix*. The three questions it answers:
418
+
419
+ | Question | Where the answer comes from |
420
+ |---|---|
421
+ | Why did this turn not run its calculated scenario? | `replay.decision code=…` in the journal, mapped to the cause and the fix; and the service: is the run a recording, is it embedded, how many hits, is the scenario ready / failed / switched off, which step is parked |
422
+ | Why did it save so little, or cost more? | the execution's cost breakdown (derive, session, fallback) against the baseline; steer mode with built-in steps is the usual answer, a single-sample baseline the second |
423
+ | What happened, step by step? | the turn's lines in order, the per-step verdicts on the execution, and `replay.*_failed`, `replay.diverge`, `replay.handover` as incidents |
424
+
425
+ You see your own data. An admin of the service sees everyone's and may pass
426
+ `--user` to `executions`. `--json` on any form prints the raw merge.
427
+
428
+ ---
429
+
398
430
  ## 10. What is built
399
431
 
400
432
  All of it. Phases R0–R7 of [calculatedReplay.md](calculatedReplay.md) §17 are implemented and
@@ -439,10 +471,10 @@ Three things the implementation settled that the design left open:
439
471
  Three levels, least to most.
440
472
 
441
473
  ```bash
442
- unset BIR_REPLAY # stop arming. Matches still detected; recording still stops on a match
474
+ export BIR_REPLAY=0 # stop arming. Matches still detected; recording still stops on a match
443
475
  bir uninstall --replay # remove the `bir` MCP server, restore the prompt-hook timeout
444
476
  bir uninstall # remove everything: proxies, hooks, files restored byte-for-byte
445
477
  ```
446
478
 
447
- With `BIR_REPLAY` unset the system is exactly v1 again: it recognises a repeated prompt, declines to
479
+ With `BIR_REPLAY=0` the system is exactly v1 again: it recognises a repeated prompt, declines to
448
480
  record it a second time, and lets the model do the work.
@@ -357,7 +357,7 @@ Leave this running in its own terminal, **in the run folder**:
357
357
 
358
358
  ```bash
359
359
  export BIR_AUTH_URL=https://api.your-domain.com
360
- export BIR_REPLAY=1 # the replay switch — off by default
360
+ unset BIR_REPLAY # replay is on by default; BIR_REPLAY=0 turns it off
361
361
  export BIR_REPLAY_ALLOW_SERVERS=postgres # servers allowed to run unattended
362
362
  bir-hooks 2>&1 | tee -a bir-hooks.log
363
363
  ```
@@ -539,7 +539,7 @@ real work for zero tokens, and the saving is real and positive.**
539
539
  - **[quickstart.md](quickstart.md)** — setting up a fresh machine to record from
540
540
  your *own* work, not just this benchmark.
541
541
 
542
- > **Turning replay off** is one line: `unset BIR_REPLAY`. The system is then
542
+ > **Turning replay off** is one line: `export BIR_REPLAY=0`. The system is then
543
543
  > exactly a recorder again — it recognises a repeated prompt and declines to
544
544
  > record it twice, and the model does the work. Nothing is skipped, nothing is
545
545
  > risked.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",