jules-orchestrator-kit 0.22.5 → 0.22.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.
package/README.md CHANGED
@@ -68,8 +68,11 @@ Whether you are dispatching your first automated coding task or managing high-th
68
68
 
69
69
  ### Engine System Highlights
70
70
 
71
- - **Linearizable VFS Mutex (`src/state.mjs`)**: Kernel-level directory mutex (`withVfsMutex`) guaranteeing serial linearizability for SHA-256 hash-chained session ledgers under high concurrency.
72
- - **PID Recycling & Stale Lock Protection (`src/state.mjs`)**: Linux `/proc/<pid>/stat` launch-time validation prevents false-positive lock reaps from recycled OS process IDs.
71
+ - **Native Task DAG Executor (`src/dag-engine.mjs`)**: Zero-dependency `DagExecutor` with Kahn's topological sort algorithm, SHA-256 interface fingerprinting post-task execution, and pre-execution cycle detection (`DagCycleError`).
72
+ - **Intent Journaling & Zombie Worktree Reaper (`src/journal.mjs`)**: Automatic boot-time scan (`reapOrphanedIntents`) in `agentctl` and MCP server that tracks git operations in `.agent/state/journal.jsonl` and prunes orphaned worktrees left by crashed/recycled processes.
73
+ - **Hermetic Network Egress Guard (`src/preload-net-guard.mjs`)**: Intercepts and blocks unmocked outbound HTTP/HTTPS egress during test execution (`NODE_OPTIONS="--import ./src/preload-net-guard.mjs"`), enforcing hermetic testing while allowing local loopback (`localhost`, `127.0.0.1`).
74
+ - **Linearizable VFS Mutex (`src/state.mjs`)**: Kernel-level directory mutex (`withVfsMutex`) guaranteeing serial linearizability for SHA-256 hash-chained session ledgers with atomic budget reservation (`reserveBudgetAtomic`).
75
+ - **PID Recycling & Stale Lock Protection (`src/state.mjs`)**: Linux `/proc/<pid>/stat` launch-time validation and random UUID nonces prevent false-positive lock reaps from recycled OS process IDs.
73
76
  - **Memory-Bounded Content-Length MCP Streaming (`src/mcp.mjs`)**: Native MCP server over stdio streams using `McpFrameDecoder` with a 4 MB memory safety ceiling and panic boundaries to prevent stdout stack trace leaks.
74
77
  - **Process Group Isolation (`src/process-group.mjs`)**: `ProcessGroupManager` creates isolated process groups (`detached: true`) and catches `SIGINT`/`SIGTERM`/`exit` signals to execute `process.kill(-pgid)`, guaranteeing zero zombie processes.
75
78
  - **TOCTOU & Symlink Defense (`src/security.mjs`)**: `safeAtomicWrite()` uses `O_CREAT | O_EXCL | O_WRONLY` temp files with `fsyncSync` + `renameSync` and `lstatSync`/`realpathSync` symlink checks.
@@ -84,12 +87,14 @@ Whether you are dispatching your first automated coding task or managing high-th
84
87
 
85
88
  ![Zero-Trust Security Guarantees](docs/assets/security-shield.svg?v=3)
86
89
 
87
- ### The 4-Phase Safety Audit (`agentctl gate`)
90
+ ### The 4-Phase Safety Audit & Security Boundary (`agentctl gate`)
88
91
 
89
92
  1. **Scope Fencing (`forbidden_paths`)**: Ensures agents cannot modify protected files (`package.json`, `.github/`, deployment keys) without explicit overrides.
90
93
  2. **Diff Payload Governor**: Rejects oversized diffs (> 75 KB) to prevent truncation and hidden payload injections.
91
94
  3. **Secret Entropy Scanner**: Scans diffs for high-confidence secrets (AWS keys, Stripe keys, GitHub tokens, SSH private keys) using Shannon Entropy analysis (> 3.6 bits).
92
- 4. **Trusted Verification Suite**: Executes auto-detected unit tests and linters (`npm test`) to guarantee zero regressions before merging.
95
+ 4. **Trusted Verification Suite**: Executes auto-detected unit tests and linters (`npm test`) inside a hermetic network sandbox to guarantee zero regressions before merging.
96
+ 5. **Prompt Guard Boundary (`src/prompt-guard.mjs`)**: `sanitizeUntrustedData` strips bidi control characters, ANSI escape sequences, zero-width unicode, and neutralizes prompt injection tags (`<|im_start|>`, `[INST]`).
97
+ 6. **MCP Stream Isolation (`src/mcp.mjs`)**: Seals `process.stdout.write` framing stream to prevent log output from corrupting JSON-RPC stdio frames.
93
98
 
94
99
  > [!NOTE]
95
100
  > All security rules are fetched strictly from `origin/main` (never untrusted PR branches) to prevent prompt-injection attacks from altering security rules.
@@ -178,7 +183,7 @@ steps:
178
183
  | **`mcp`** | `agentctl mcp` | Starts stdio Model Context Protocol (MCP) JSON-RPC 2.0 server |
179
184
  | **`doctor`** | `agentctl doctor` | Verifies stack configuration, environment keys, and daily token budget |
180
185
  | **`scan`** | `agentctl scan` | Scans codebase for `TODO` and `FIXME` comments and generates task queue |
181
- | **`cleanup`** | `agentctl cleanup` | Audits and cleans up stale git worktrees and temporary state files |
186
+ | **`clean`** | `agentctl clean` | Audits and cleans up stale git worktrees, orphaned intents, locks, and temporary state files |
182
187
 
183
188
  </details>
184
189
 
package/bin/agentctl.mjs CHANGED
@@ -7,13 +7,14 @@ import { loadConfig, resolveRoot, detectStack } from "../src/config.mjs";
7
7
  import { gate, dispatch, run, isTaskFile } from "../src/engine.mjs";
8
8
  import { acquireLock, releaseLock, lockStatus, checkDailyBudget, getQueueDir, ensureDir } from "../src/state.mjs";
9
9
  import { worktreePrune } from "../src/git.mjs";
10
+ import { reapOrphanedIntents, reapStaleMutexDirs } from "../src/journal.mjs";
10
11
 
11
12
  const args = process.argv.slice(2);
12
13
  const command = args[0];
13
14
 
14
15
  function printHelp() {
15
16
  console.log(`
16
- šŸš€ agentctl v0.22.5 — Universal Agent Orchestrator & Safety Gatekeeper
17
+ šŸš€ agentctl v0.22.7 — Universal Agent Orchestrator & Safety Gatekeeper
17
18
 
18
19
  Usage: agentctl <command> [options]
19
20
 
@@ -43,11 +44,13 @@ async function main() {
43
44
  }
44
45
 
45
46
  if (command === "version" || command === "--version" || command === "-v") {
46
- console.log("agentctl v0.22.5");
47
+ console.log("agentctl v0.22.7");
47
48
  process.exit(0);
48
49
  }
49
50
 
50
51
  const root = resolveRoot();
52
+ reapOrphanedIntents(root);
53
+ reapStaleMutexDirs(root);
51
54
  const config = loadConfig(root);
52
55
 
53
56
  switch (command) {
@@ -223,7 +226,7 @@ async function main() {
223
226
  }
224
227
 
225
228
  case "doctor": {
226
- console.log(`\nšŸ” agentctl System Diagnostics (v0.22.5)`);
229
+ console.log(`\nšŸ” agentctl System Diagnostics (v0.22.7)`);
227
230
  console.log(`--------------------------------------------------`);
228
231
  console.log(` Project Root : ${root}`);
229
232
  console.log(` Config File : ${config._file || "None (Using defaults)"}`);
package/index.mjs CHANGED
@@ -50,6 +50,7 @@ export { checkAssetIntegrity } from "./src/asset_integrity.mjs";
50
50
  export { classifyRiskTier, RISK_TIERS } from "./src/risk.mjs";
51
51
  export { checkRulesBudget } from "./src/rules_budget.mjs";
52
52
  export { DagExecutor, DagCycleError } from "./src/dag-engine.mjs";
53
+ export { journalIntent, journalDone, reapOrphanedIntents, reapStaleMutexDirs } from "./src/journal.mjs";
53
54
 
54
55
  // Legacy SDK shims for backward compatibility
55
56
  export { resolveProjectCommands, resolveWorkspaceExecutionBoundary } from "./scripts/command-resolver.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.22.5",
3
+ "version": "0.22.7",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/engine.mjs CHANGED
@@ -3,6 +3,7 @@ import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
3
3
  import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
4
4
  import { createProvider } from "./provider.mjs";
5
5
  import { withBudget, appendLedger, getQueueDir, ensureDir } from "./state.mjs";
6
+ import { sanitizeUntrustedData, buildAgentEnvelope } from "./prompt-guard.mjs";
6
7
  import { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
7
8
  import { join } from "node:path";
8
9
  import { createHash } from "node:crypto";
@@ -164,8 +165,9 @@ export async function gate(opts = {}) {
164
165
  let buildResult = { ok: true, status: 0 };
165
166
 
166
167
  const existingNodeOptions = process.env.NODE_OPTIONS || "";
167
- const netGuardFlag = "--import ./src/preload-net-guard.mjs";
168
- const guardNodeOptions = existingNodeOptions && !existingNodeOptions.includes(netGuardFlag)
168
+ const netGuardUrl = new URL("./preload-net-guard.mjs", import.meta.url).href;
169
+ const netGuardFlag = `--import ${netGuardUrl}`;
170
+ const guardNodeOptions = existingNodeOptions && !existingNodeOptions.includes(netGuardFlag) && !existingNodeOptions.includes("preload-net-guard.mjs")
169
171
  ? `${existingNodeOptions} ${netGuardFlag}`
170
172
  : existingNodeOptions ? existingNodeOptions : netGuardFlag;
171
173
 
@@ -354,8 +356,15 @@ export async function dispatch(task, opts = {}) {
354
356
  }
355
357
 
356
358
  // Redact secrets in prompt before dispatching
357
- const cleanPrompt = redactSecrets(task.prompt);
358
- const cleanTask = { ...task, prompt: cleanPrompt };
359
+ const cleanPrompt = redactSecrets(task.prompt || "");
360
+
361
+ // Wire Prompt Guard & Envelope to wrap raw task arguments
362
+ const untrustedData = task.untrustedData || [cleanPrompt];
363
+ const systemPolicy = task.systemPolicy || config.systemPolicy || "";
364
+ const taskInstructions = task.taskInstructions || task.title || "Autonomous Task Execution";
365
+ const envelopedPrompt = buildAgentEnvelope(systemPolicy, taskInstructions, untrustedData);
366
+
367
+ const cleanTask = { ...task, prompt: envelopedPrompt };
359
368
 
360
369
  return withBudget(
361
370
  () => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun }),
package/src/git.mjs CHANGED
@@ -1,5 +1,9 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { sep } from "node:path";
3
+ import { journalIntent, journalDone } from "./journal.mjs";
4
+
5
+ export const NET_GUARD_PRELOAD_URL = new URL("./preload-net-guard.mjs", import.meta.url).href;
6
+ export const NET_GUARD_FLAG = `--import ${NET_GUARD_PRELOAD_URL}`;
3
7
 
4
8
  export class GateError extends Error {
5
9
  constructor(message, opts = {}) {
@@ -174,8 +178,28 @@ export function showFromOrigin(root = process.cwd(), base = "main", filePath = "
174
178
  }
175
179
  }
176
180
 
181
+ export function createBranch(root = process.cwd(), branch = "") {
182
+ const opId = journalIntent(root, { type: "create_branch", branch, targetPath: "" });
183
+ try {
184
+ const res = git(["branch", branch], { cwd: root });
185
+ journalDone(root, opId);
186
+ return res;
187
+ } catch (err) {
188
+ journalDone(root, opId);
189
+ throw err;
190
+ }
191
+ }
192
+
177
193
  export function worktreeAdd(root = process.cwd(), branch = "agent/task", targetDir = "") {
178
- return git(["worktree", "add", targetDir, "-b", branch], { cwd: root });
194
+ const opId = journalIntent(root, { type: "worktree_add", targetPath: targetDir, branch });
195
+ try {
196
+ const res = git(["worktree", "add", targetDir, "-b", branch], { cwd: root });
197
+ journalDone(root, opId);
198
+ return res;
199
+ } catch (err) {
200
+ journalDone(root, opId);
201
+ throw err;
202
+ }
179
203
  }
180
204
 
181
205
  export function worktreeRemove(root = process.cwd(), targetDir = "") {
@@ -0,0 +1,230 @@
1
+ import { openSync, writeSync, fsyncSync, closeSync, existsSync, readFileSync, unlinkSync, readdirSync, statSync, renameSync, rmdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { getStateDir, getProcessStartTime, isPidAlive, getLockDir } from "./state.mjs";
5
+ import { worktreeRemove, worktreePrune } from "./git.mjs";
6
+
7
+ /**
8
+ * Scans .agent/state/ for .mutex directories.
9
+ * If empty AND older than ttlMs, renames to a temporary .grave-<pid> path and rmdirSyncs it (atomic CAS pattern).
10
+ * @param {string} root - Project root directory
11
+ * @param {Object} [opts] - Options object
12
+ * @param {number} [opts.ttlMs=30000] - Time to live in milliseconds
13
+ * @returns {{ reapedCount: number, reaped: Array<string> }}
14
+ */
15
+ export function reapStaleMutexDirs(root, { ttlMs = 30000 } = {}) {
16
+ const stateDir = getStateDir(root);
17
+ if (!existsSync(stateDir)) {
18
+ return { reapedCount: 0, reaped: [] };
19
+ }
20
+
21
+ const reaped = [];
22
+ try {
23
+ const entries = readdirSync(stateDir, { withFileTypes: true });
24
+ const now = Date.now();
25
+ for (const entry of entries) {
26
+ if (entry.isDirectory() && entry.name.endsWith(".mutex")) {
27
+ const mutexPath = join(stateDir, entry.name);
28
+ try {
29
+ const contents = readdirSync(mutexPath);
30
+ if (contents.length === 0) {
31
+ const stat = statSync(mutexPath);
32
+ const age = now - stat.mtimeMs;
33
+ if (age >= ttlMs) {
34
+ const gravePath = join(stateDir, `.grave-${process.pid}-${randomUUID()}`);
35
+ try {
36
+ renameSync(mutexPath, gravePath);
37
+ try {
38
+ rmdirSync(gravePath);
39
+ reaped.push(entry.name);
40
+ } catch (_) {}
41
+ } catch (_) {}
42
+ }
43
+ }
44
+ } catch (_) {}
45
+ }
46
+ }
47
+ } catch (_) {}
48
+
49
+ return { reapedCount: reaped.length, reaped };
50
+ }
51
+
52
+ /**
53
+ * Appends an intent record to .agent/state/journal.jsonl before a mutating operation.
54
+ * @param {string} root - Project root directory
55
+ * @param {Object|string} op - Operation payload or name
56
+ * @returns {string} opId - Unique operation identifier
57
+ */
58
+ export function journalIntent(root, op) {
59
+ const stateDir = getStateDir(root);
60
+ const journalPath = join(stateDir, "journal.jsonl");
61
+
62
+ const opObj = typeof op === "string" ? { type: op } : op || {};
63
+ const opId = opObj.opId || `op-${randomUUID()}`;
64
+ const pid = process.pid;
65
+ const processStartTime = getProcessStartTime(pid);
66
+
67
+ const record = {
68
+ opId,
69
+ event: "intent",
70
+ type: opObj.type || opObj.operation || "unknown",
71
+ targetPath: opObj.targetPath || opObj.targetDir || opObj.path || "",
72
+ pid,
73
+ processStartTime,
74
+ timestamp: new Date().toISOString(),
75
+ ...opObj,
76
+ };
77
+
78
+ const fd = openSync(journalPath, "a");
79
+ try {
80
+ writeSync(fd, JSON.stringify(record) + "\n", "utf-8");
81
+ fsyncSync(fd);
82
+ } finally {
83
+ closeSync(fd);
84
+ }
85
+
86
+ return opId;
87
+ }
88
+
89
+ /**
90
+ * Appends a journal_done record to .agent/state/journal.jsonl upon operation completion.
91
+ * @param {string} root - Project root directory
92
+ * @param {string} opId - Operation identifier returned by journalIntent
93
+ * @returns {string|null} opId
94
+ */
95
+ export function journalDone(root, opId) {
96
+ if (!opId) return null;
97
+ const stateDir = getStateDir(root);
98
+ const journalPath = join(stateDir, "journal.jsonl");
99
+
100
+ const record = {
101
+ opId,
102
+ event: "journal_done",
103
+ timestamp: new Date().toISOString(),
104
+ };
105
+
106
+ const fd = openSync(journalPath, "a");
107
+ try {
108
+ writeSync(fd, JSON.stringify(record) + "\n", "utf-8");
109
+ fsyncSync(fd);
110
+ } finally {
111
+ closeSync(fd);
112
+ }
113
+
114
+ return opId;
115
+ }
116
+
117
+ /**
118
+ * Scans .agent/state/journal.jsonl for uncompleted intents belonging to dead PIDs,
119
+ * prunes orphaned worktrees, removes stale lock files, and marks intents reaped.
120
+ * Idempotent: Subsequent calls result in 0 additional reaps.
121
+ * @param {string} root - Project root directory
122
+ * @returns {{ reapedCount: number, reaped: Array<Object> }}
123
+ */
124
+ export function reapOrphanedIntents(root) {
125
+ const stateDir = getStateDir(root);
126
+ const journalPath = join(stateDir, "journal.jsonl");
127
+
128
+ if (!existsSync(journalPath)) {
129
+ return { reapedCount: 0, reaped: [] };
130
+ }
131
+
132
+ let lines = [];
133
+ try {
134
+ const raw = readFileSync(journalPath, "utf-8");
135
+ lines = raw.split("\n").filter(Boolean);
136
+ } catch (_) {
137
+ return { reapedCount: 0, reaped: [] };
138
+ }
139
+
140
+ const intents = new Map();
141
+ const finishedOpIds = new Set();
142
+
143
+ for (const line of lines) {
144
+ try {
145
+ const entry = JSON.parse(line);
146
+ if (!entry || !entry.opId) continue;
147
+ if (entry.event === "intent") {
148
+ intents.set(entry.opId, entry);
149
+ } else if (entry.event === "journal_done" || entry.event === "journal_reaped") {
150
+ finishedOpIds.add(entry.opId);
151
+ }
152
+ } catch (_) {
153
+ // Ignore corrupted or partial lines
154
+ }
155
+ }
156
+
157
+ const reaped = [];
158
+
159
+ for (const [opId, intent] of intents.entries()) {
160
+ if (finishedOpIds.has(opId)) {
161
+ continue;
162
+ }
163
+
164
+ const alive = isPidAlive(intent.pid, intent.processStartTime);
165
+ if (!alive) {
166
+ // Owner PID is dead or recycled -> reap orphaned intent
167
+ if (intent.targetPath) {
168
+ try {
169
+ worktreeRemove(root, intent.targetPath);
170
+ } catch (_) {}
171
+ try {
172
+ worktreePrune(root);
173
+ } catch (_) {}
174
+ }
175
+
176
+ // Clean up stale lock files associated with this PID or targetPath
177
+ try {
178
+ const lockDir = getLockDir(root);
179
+ if (existsSync(lockDir)) {
180
+ const lockFiles = readdirSync(lockDir);
181
+ for (const file of lockFiles) {
182
+ if (file.endsWith(".json")) {
183
+ const lockPath = join(lockDir, file);
184
+ try {
185
+ const lockContent = JSON.parse(readFileSync(lockPath, "utf-8"));
186
+ const lockPid = lockContent.pid;
187
+ const lockStartTime = lockContent.processStartTime ?? lockContent.starttime ?? null;
188
+
189
+ // Do NOT unlink if the PID exists AND its start time matches a live process
190
+ if (lockPid && isPidAlive(lockPid, lockStartTime)) {
191
+ continue;
192
+ }
193
+
194
+ if (
195
+ lockContent.pid === intent.pid ||
196
+ (intent.targetPath && Array.isArray(lockContent.files) && lockContent.files.includes(intent.targetPath))
197
+ ) {
198
+ unlinkSync(lockPath);
199
+ }
200
+ } catch (_) {}
201
+ }
202
+ }
203
+ }
204
+ } catch (_) {}
205
+
206
+ // Record journal_reaped to guarantee idempotency
207
+ const reapedRecord = {
208
+ opId,
209
+ event: "journal_reaped",
210
+ reapedAt: new Date().toISOString(),
211
+ pid: intent.pid,
212
+ };
213
+
214
+ try {
215
+ const fd = openSync(journalPath, "a");
216
+ try {
217
+ writeSync(fd, JSON.stringify(reapedRecord) + "\n", "utf-8");
218
+ fsyncSync(fd);
219
+ } finally {
220
+ closeSync(fd);
221
+ }
222
+ } catch (_) {}
223
+
224
+ reaped.push(intent);
225
+ }
226
+ }
227
+
228
+ return { reapedCount: reaped.length, reaped };
229
+ }
230
+
package/src/mcp.mjs CHANGED
@@ -3,10 +3,11 @@ import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
3
3
  import { gate, dispatch } from "./engine.mjs";
4
4
  import { classifyRiskTier } from "./risk.mjs";
5
5
  import { checkDailyBudget, lockStatus } from "./state.mjs";
6
+ import { reapOrphanedIntents, reapStaleMutexDirs } from "./journal.mjs";
6
7
 
7
8
  export const MCP_SERVER_INFO = {
8
9
  name: "jules-orchestrator-kit",
9
- version: "0.20.0",
10
+ version: "0.22.7",
10
11
  };
11
12
 
12
13
  export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
@@ -352,6 +353,9 @@ export function writeMcpFrame(targetOutput, data) {
352
353
  }
353
354
 
354
355
  export function startMcpServer(input = process.stdin, output = process.stdout, opts = {}) {
356
+ const root = opts.root || resolveRoot();
357
+ reapOrphanedIntents(root);
358
+ reapStaleMutexDirs(root);
355
359
  isolateMcpStdout(process.stdout, opts);
356
360
  if (output && output !== process.stdout) {
357
361
  isolateMcpStdout(output, opts);
@@ -78,8 +78,12 @@ export function buildAgentEnvelope(systemPolicy = "", taskInstructions = "", unt
78
78
  if (Array.isArray(untrustedDataArray)) {
79
79
  for (const item of untrustedDataArray) {
80
80
  if (typeof item === "string") {
81
- if (item.trim().startsWith("<<<UNTRUSTED-DATA-BEGIN")) {
82
- sanitizedBlocks.push(item);
81
+ const trimmed = item.trim();
82
+ const match = /^<<<UNTRUSTED-DATA-BEGIN(?:\s+source="([^"]+)")?>\n?([\s\S]*?)\n?<<<UNTRUSTED-DATA-END>>>$/.exec(trimmed);
83
+ if (match) {
84
+ const src = match[1] || "untrusted";
85
+ const body = match[2];
86
+ sanitizedBlocks.push(sanitizeUntrustedData(body, src));
83
87
  } else {
84
88
  sanitizedBlocks.push(sanitizeUntrustedData(item, "untrusted"));
85
89
  }
package/src/state.mjs CHANGED
@@ -188,7 +188,15 @@ export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
188
188
  try {
189
189
  const content = readFileSync(filePath, "utf-8");
190
190
  const lines = content.split("\n").filter(Boolean);
191
- const count = lines.length;
191
+ let count = 0;
192
+ for (const line of lines) {
193
+ try {
194
+ const entry = JSON.parse(line);
195
+ if (entry && entry.event === "budget_reserved") {
196
+ count++;
197
+ }
198
+ } catch (_) {}
199
+ }
192
200
  return {
193
201
  ok: count < limit,
194
202
  used: count,
@@ -224,13 +232,14 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
224
232
  try {
225
233
  const raw = readFileSync(filePath, "utf-8");
226
234
  const lines = raw.split("\n").filter(Boolean);
227
- count = lines.length;
228
- for (let i = lines.length - 1; i >= 0; i--) {
235
+ for (const line of lines) {
229
236
  try {
230
- const lastObj = JSON.parse(lines[i]);
231
- if (lastObj.hash) {
232
- prevHash = lastObj.hash;
233
- break;
237
+ const entry = JSON.parse(line);
238
+ if (entry && entry.event === "budget_reserved") {
239
+ count++;
240
+ }
241
+ if (entry && entry.hash) {
242
+ prevHash = entry.hash;
234
243
  }
235
244
  } catch (_) {}
236
245
  }