@basein/runner 0.2.6 → 0.2.8

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.
@@ -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 = [];
@@ -390,11 +390,45 @@ first prompt that states the task plainly.
390
390
  | `run_scenario` returns "no scenario is armed" | The model called it on a turn with no plan | Harmless. It happens when the model remembers the tool from an earlier turn |
391
391
  | `execution.reported … 409 invalid_ticket` | The ticket was already redeemed, or belongs to another scenario | Harmless if `duplicate: true`. Otherwise a rollover raced `SessionEnd`; the server books once |
392
392
  | `savedUsd` looks impossible | Pricing drift between the runner's table and the server's | Compare `PRICING_VERSION` on both sides. Design §11.3 documents a known live drift between two existing copies |
393
+ | The hand-over note says a step "did not run: its input needs a judgement" | The service marked the step non-deterministic when it calculated the plan: the generated logic could not compute its input from the prompt or from earlier outputs, so the recorded value was a copy (plan-services.md D8, kind 3) | Nothing on the runner. The agent makes that choice and finishes the task; a model step for such judgements is planned (W3.1). The journal's `plan.armed` line lists it under `stops=` |
394
+ | A parked step ran again after an hour, or a plan was recalculated by itself | The service's grace period (D8): a parked step is served with `stop: null` once an hour for a day and a success clears its count; if it keeps failing, or its tool is gone, the next repeat of the prompt is recorded in full and the plan is calculated again from it | Nothing: that is the repair. `bir investigate` names the kind (`step_retrying`, `step_parked … repair due`, `scenario_repairing`). To repair now by hand: `bir scenario calc <runId> --force` |
393
395
  | `sessionCostUsd` grows every prompt in a session | The per-run usage watermark is missing or not taken | Design §11.4. Reports without a mark must carry `measured: false` |
394
396
  | Everything works, nothing is saved | A replayed turn is deliberately not recorded (design §12) | Correct. The matched run stays canonical and its `iterations` is bumped |
395
397
 
396
398
  ---
397
399
 
400
+ ### 9.1 `bir investigate` — the table above, applied for you
401
+
402
+ Every audit line that explains a turn is also kept in a **journal**, one JSON
403
+ object per line, under `~/.baseinstrunner/control/journal/` (one file per
404
+ directory, rotated at 4 MB). `bir-hooks` prints its path at start. Ask the
405
+ journal and the service together:
406
+
407
+ ```
408
+ bir investigate # the newest turn in this directory
409
+ bir investigate <run_|scn_|sexec_ id> # that run, scenario or execution
410
+ bir investigate list [--limit n] # recent turns and their verdicts
411
+ bir investigate executions [--limit n] [--user <email|id>] # the ledger
412
+ ```
413
+
414
+ The output is the turn as the journal tells it (matched what, which gate
415
+ declined and why, what mode armed with which coverage, what the execution
416
+ reported), then **runner findings**, then the service's view (the recording,
417
+ the scenario, the baseline and its sample count, the executions with their
418
+ saved $ and %) and **service findings**. Every finding is *problem → cause →
419
+ fix*. The three questions it answers:
420
+
421
+ | Question | Where the answer comes from |
422
+ |---|---|
423
+ | 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 |
424
+ | 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 |
425
+ | 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 |
426
+
427
+ You see your own data. An admin of the service sees everyone's and may pass
428
+ `--user` to `executions`. `--json` on any form prints the raw merge.
429
+
430
+ ---
431
+
398
432
  ## 10. What is built
399
433
 
400
434
  All of it. Phases R0–R7 of [calculatedReplay.md](calculatedReplay.md) §17 are implemented and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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",