lazycodex-ai 5.0.0-beta.62 → 5.0.0-beta.63

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.
Files changed (179) hide show
  1. package/dist/cli/doctor/checks/browser-provider.d.ts +2 -0
  2. package/dist/cli/index.js +64 -35
  3. package/dist/cli-node/index.js +64 -35
  4. package/package.json +2 -6
  5. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  6. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +2 -0
  7. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  8. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  9. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  10. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  11. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  12. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  13. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  14. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  15. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  16. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  17. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  18. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  19. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  20. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  21. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  22. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  23. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  24. package/packages/omo-codex/plugin/components/ultrawork/agents/plan.toml +2 -2
  25. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +5 -4
  26. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  29. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  30. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  31. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +13 -0
  33. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +5 -4
  34. package/packages/omo-codex/plugin/components/ulw-loop/dist/checkpoint.js +5 -4
  35. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +582 -322
  36. package/packages/omo-codex/plugin/components/ulw-loop/dist/domain-types.d.ts +5 -0
  37. package/packages/omo-codex/plugin/components/ulw-loop/dist/evidence.js +16 -12
  38. package/packages/omo-codex/plugin/components/ulw-loop/dist/ledger.d.ts +4 -0
  39. package/packages/omo-codex/plugin/components/ulw-loop/dist/ledger.js +34 -0
  40. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-commit.d.ts +19 -0
  41. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-commit.js +107 -0
  42. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.d.ts +2 -1
  43. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.js +48 -22
  44. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +9 -7
  45. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +105 -114
  46. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-log.d.ts +15 -0
  47. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-log.js +86 -0
  48. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.d.ts +5 -1
  49. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.js +15 -3
  50. package/packages/omo-codex/plugin/components/ulw-loop/dist/review-blockers.js +3 -4
  51. package/packages/omo-codex/plugin/components/ulw-loop/dist/sdk/factory.js +12 -5
  52. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-budget-io.d.ts +5 -0
  53. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-budget-io.js +67 -0
  54. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +8 -62
  55. package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.d.ts +11 -1
  56. package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.js +120 -88
  57. package/packages/omo-codex/plugin/components/ulw-loop/dist/status-next-actions.d.ts +3 -2
  58. package/packages/omo-codex/plugin/components/ulw-loop/dist/status-next-actions.js +18 -10
  59. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-batch.js +4 -4
  60. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +5 -5
  61. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +8 -17
  62. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  63. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  64. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +2 -2
  65. package/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts +5 -4
  66. package/packages/omo-codex/plugin/components/ulw-loop/src/domain-types.ts +5 -0
  67. package/packages/omo-codex/plugin/components/ulw-loop/src/evidence.ts +16 -16
  68. package/packages/omo-codex/plugin/components/ulw-loop/src/ledger.ts +33 -0
  69. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-commit.ts +124 -0
  70. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts +58 -51
  71. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +126 -140
  72. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-log.ts +84 -0
  73. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-missing-recovery.ts +26 -3
  74. package/packages/omo-codex/plugin/components/ulw-loop/src/review-blockers.ts +3 -3
  75. package/packages/omo-codex/plugin/components/ulw-loop/src/sdk/factory.ts +30 -5
  76. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-budget-io.ts +59 -0
  77. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +8 -59
  78. package/packages/omo-codex/plugin/components/ulw-loop/src/state-lock.ts +129 -111
  79. package/packages/omo-codex/plugin/components/ulw-loop/src/status-next-actions.ts +19 -10
  80. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-batch.ts +4 -4
  81. package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +5 -4
  82. package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +8 -16
  83. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/barrier.ts +13 -0
  84. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-child.ts +65 -0
  85. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-cli-qa.mjs +76 -0
  86. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-crash.ts +23 -0
  87. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-mutations.mjs +58 -0
  88. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-writer.ts +65 -0
  89. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/lease-clock.ts +23 -0
  90. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-commit-log.test.ts +252 -0
  91. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-commit-recovery.test.ts +190 -0
  92. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +50 -35
  93. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-log-newest.test.ts +72 -0
  94. package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-completed-plan.test.ts +43 -0
  95. package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-session-context.test.ts +21 -0
  96. package/packages/omo-codex/plugin/components/ulw-loop/test/state-lock.test.ts +92 -0
  97. package/packages/omo-codex/plugin/components/ulw-loop/test/status-next-actions-surface.test.ts +133 -0
  98. package/packages/omo-codex/plugin/components/ulw-loop/test/steering-batch.test.ts +12 -12
  99. package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +4 -1
  100. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  101. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  102. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  103. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  104. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  105. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  106. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  107. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  108. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  109. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  110. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  118. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  119. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  120. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  121. package/packages/omo-codex/plugin/package-lock.json +12 -12
  122. package/packages/omo-codex/plugin/package.json +1 -1
  123. package/packages/omo-codex/plugin/scripts/sync-skills.d.mts +4 -0
  124. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +15 -7
  125. package/packages/omo-codex/plugin/skills/ast-grep/SKILL.md +12 -0
  126. package/packages/omo-codex/plugin/skills/debugging/references/tools/playwright-cli.md +79 -161
  127. package/packages/omo-codex/plugin/skills/frontend/SKILL.md +1 -1
  128. package/packages/omo-codex/plugin/skills/frontend/references/design/aside.md +1 -1
  129. package/packages/omo-codex/plugin/skills/frontend/references/design/clone-from-url.md +1 -1
  130. package/packages/omo-codex/plugin/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
  131. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +1 -1
  132. package/packages/omo-codex/plugin/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
  133. package/packages/omo-codex/plugin/skills/ultimate-browsing/SKILL.md +17 -29
  134. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/AGENTS.md +2 -1
  135. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/package.json +2 -2
  136. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
  137. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
  138. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
  139. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
  140. package/packages/omo-codex/plugin/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
  141. package/packages/omo-codex/plugin/skills/ultimate-browsing/references/insane-search/README.md +9 -3
  142. package/packages/omo-codex/plugin/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
  143. package/packages/omo-codex/plugin/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
  144. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +5 -4
  145. package/packages/omo-codex/plugin/skills/ulw-execute/SKILL.md +1 -1
  146. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +2 -2
  147. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +3 -3
  148. package/packages/omo-codex/plugin/skills/visual-qa/AGENTS.md +1 -1
  149. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +1 -1
  150. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +75 -0
  151. package/packages/omo-codex/scripts/install-dist/install-local.mjs +4 -2
  152. package/packages/prompts-core/prompts/ultrawork/codex.md +5 -4
  153. package/packages/shared-skills/skills/ast-grep/SKILL.md +12 -0
  154. package/packages/shared-skills/skills/debugging/references/tools/playwright-cli.md +79 -161
  155. package/packages/shared-skills/skills/frontend/SKILL.md +1 -1
  156. package/packages/shared-skills/skills/frontend/references/design/aside.md +1 -1
  157. package/packages/shared-skills/skills/frontend/references/design/clone-from-url.md +1 -1
  158. package/packages/shared-skills/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
  159. package/packages/shared-skills/skills/review-work/SKILL.md +1 -1
  160. package/packages/shared-skills/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
  161. package/packages/shared-skills/skills/ultimate-browsing/SKILL.md +17 -29
  162. package/packages/shared-skills/skills/ultimate-browsing/engine/AGENTS.md +2 -1
  163. package/packages/shared-skills/skills/ultimate-browsing/engine/templates/package.json +2 -2
  164. package/packages/shared-skills/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
  165. package/packages/shared-skills/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
  166. package/packages/shared-skills/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
  167. package/packages/shared-skills/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
  168. package/packages/shared-skills/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
  169. package/packages/shared-skills/skills/ultimate-browsing/references/insane-search/README.md +9 -3
  170. package/packages/shared-skills/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
  171. package/packages/shared-skills/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
  172. package/packages/shared-skills/skills/ulw-execute/SKILL.md +1 -1
  173. package/packages/shared-skills/skills/ulw-plan/scripts/scaffold-plan.mjs +1 -1
  174. package/packages/shared-skills/skills/ulw-research/SKILL.md +3 -3
  175. package/packages/shared-skills/skills/visual-qa/AGENTS.md +1 -1
  176. package/packages/shared-skills/skills/visual-qa/SKILL.md +1 -1
  177. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +75 -0
  178. package/packages/omo-codex/plugin/skills/visual-qa/references/agent-browser-setup.md +0 -45
  179. package/packages/shared-skills/skills/visual-qa/references/agent-browser-setup.md +0 -45
@@ -1,9 +1,10 @@
1
- import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
4
3
  import { parsePreToolUsePayload } from "./codex-hook.js";
5
4
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
6
5
  import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
6
+ import { readUlwLoopPlanSync } from "./plan-io.js";
7
+ import { atomicWriteJson, isNonEmptyFile, readAdmissionBreaker, readCount, readCounts } from "./spawn-budget-io.js";
7
8
  import { spawnRoleDenial } from "./spawn-role-guard.js";
8
9
  import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
9
10
  import { GATE_REVIEWER_AGENT_NAMES, REVIEWER_ROLES_BY_SURFACE, resolveToolkitSurface, reviewerRolesFor, } from "./surface.js";
@@ -43,7 +44,7 @@ export function applySpawnBudgetGuards(payload, options = {}) {
43
44
  return deny(`Subagent admission failed earlier in this session (${breaker}). Do not spawn more workers or reviewers; report the capacity block and wait for the user.`);
44
45
  const scope = { sessionId: payload.session_id };
45
46
  const stateDir = ulwLoopDir(payload.cwd, scope);
46
- const plan = readPlan(join(stateDir, "goals.json"));
47
+ const plan = readPlan(payload.cwd, payload.session_id);
47
48
  if (plan === null)
48
49
  return "";
49
50
  const lockOptions = options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs };
@@ -133,6 +134,7 @@ function peekFanOutBudget(stateDir) {
133
134
  return null;
134
135
  return `ulw-loop spawn fan-out cap reached (${count}/${limit}). Consolidate work into the agents already running, or raise OMO_SPAWN_FANOUT_LIMIT if this volume is intentional.`;
135
136
  }
137
+ // Hook budget counters are exempt from plan/audit commits.
136
138
  // Per-session spawn counter; depth/lineage tracking is descoped — this is a
137
139
  // total-volume backstop against fan-out explosions, not a recursion tracker.
138
140
  function consumeFanOutBudget(stateDir) {
@@ -239,11 +241,6 @@ function activeSurfaceReviewerAlias(reviewer) {
239
241
  }
240
242
  return reviewer;
241
243
  }
242
- function atomicWriteJson(targetPath, data) {
243
- const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
244
- writeFileSync(tmp, JSON.stringify(data));
245
- renameSync(tmp, targetPath);
246
- }
247
244
  function deny(reason) {
248
245
  return `${JSON.stringify({
249
246
  hookSpecificOutput: {
@@ -254,18 +251,6 @@ function deny(reason) {
254
251
  },
255
252
  })}\n`;
256
253
  }
257
- function readAdmissionBreaker(sessionId) {
258
- const dataDir = process.env["PLUGIN_DATA"];
259
- if (typeof dataDir !== "string")
260
- return null;
261
- try {
262
- const value = JSON.parse(readFileSync(join(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8"));
263
- return typeof value.reason === "string" ? value.reason : "capacity limit";
264
- }
265
- catch {
266
- return null;
267
- }
268
- }
269
254
  function fanOutLimit() {
270
255
  const raw = process.env["OMO_SPAWN_FANOUT_LIMIT"];
271
256
  if (raw === undefined)
@@ -280,48 +265,9 @@ function reviewSpawnLimit() {
280
265
  const parsed = Number.parseInt(raw, 10);
281
266
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_REVIEW_SPAWN_LIMIT;
282
267
  }
283
- function isNonEmptyFile(path) {
284
- try {
285
- return existsSync(path) && statSync(path).size > 0;
286
- }
287
- catch (error) {
288
- if (error instanceof Error)
289
- return false;
290
- throw error;
291
- }
292
- }
293
- function readCount(counterPath) {
294
- try {
295
- const parsed = JSON.parse(readFileSync(counterPath, "utf8"));
296
- return typeof parsed["count"] === "number" && parsed["count"] >= 0 ? parsed["count"] : 0;
297
- }
298
- catch (error) {
299
- if (error instanceof Error)
300
- return 0;
301
- throw error;
302
- }
303
- }
304
- function readCounts(counterPath) {
305
- try {
306
- const parsed = JSON.parse(readFileSync(counterPath, "utf8"));
307
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
308
- return {};
309
- const counts = {};
310
- for (const [key, value] of Object.entries(parsed)) {
311
- if (typeof value === "number" && value >= 0)
312
- counts[key] = value;
313
- }
314
- return counts;
315
- }
316
- catch (error) {
317
- if (error instanceof Error)
318
- return {};
319
- throw error;
320
- }
321
- }
322
- function readPlan(goalsPath) {
268
+ function readPlan(repoRoot, sessionId) {
323
269
  try {
324
- return JSON.parse(readFileSync(goalsPath, "utf8"));
270
+ return readUlwLoopPlanSync(repoRoot, { sessionId });
325
271
  }
326
272
  catch (error) {
327
273
  if (error instanceof Error)
@@ -1,9 +1,19 @@
1
1
  import { UlwLoopError } from "./types.js";
2
2
  export declare const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
3
+ export interface StateLockClock {
4
+ readonly now: () => number;
5
+ readonly schedule: (fn: () => void, ms: number) => {
6
+ unref(): void;
7
+ cancel(): void;
8
+ };
9
+ }
3
10
  export interface StateLockOptions {
4
11
  readonly timeoutMs?: number;
5
12
  readonly staleMs?: number;
13
+ readonly leaseMs?: number;
14
+ readonly heartbeatMs?: number;
15
+ readonly clock?: StateLockClock;
6
16
  }
7
- export declare function withStateLock<T>(lockPath: string, fn: () => Promise<T>, options?: StateLockOptions): Promise<T>;
17
+ export declare function withStateLock<T>(lockPath: string, fn: (token: string) => Promise<T>, options?: StateLockOptions): Promise<T>;
8
18
  export declare function withStateLockSync<T>(lockPath: string, fn: () => T, options?: StateLockOptions): T;
9
19
  export declare function isStateLockTimeout(error: unknown): error is UlwLoopError;
@@ -1,89 +1,121 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
2
+ import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- import { setTimeout as sleep } from "node:timers/promises";
5
4
  import { UlwLoopError } from "./types.js";
6
- // Cross-process exclusive lock for one ulw-loop state directory. Every CLI
7
- // invocation is its own process, so the in-process promise chain in plan-io
8
- // serializes nothing across them; this file-level lock is what makes the
9
- // read-modify-write of goals.json (and the counters next to it) atomic.
10
- //
11
- // Protocol: the lock is a file created with O_EXCL whose body records the
12
- // owner (pid + a per-acquisition token). A waiter reclaims it only when the
13
- // owner is provably gone (pid dead) or the body never became a record within
14
- // `staleMs` (a creator that died mid-write); a live owner is never reclaimed by
15
- // age alone, because overlapping two bodies is exactly the lost update this lock
16
- // exists to prevent. Waiters back off and fail closed at `timeoutMs`. Release
17
- // unlinks only a lock that still carries the releaser's own token.
5
+ // O_EXCL establishes ownership. Async holders keep the fd open and refresh a
6
+ // lease through that fd, never through the pathname: a reclaimed inode is invisible.
7
+ // Sync/legacy holders are lease-less and only dead pids retire their records.
8
+ // Expiry permits overlapping bodies; immutable revision publication fences them.
9
+ // Reclaim's reread/unlink is not atomic. On Windows an open inode may not unlink:
10
+ // treat that refusal as a live owner and fail closed. Release checks its token.
18
11
  export const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
19
12
  const DEFAULT_TIMEOUT_MS = 10_000;
20
13
  const DEFAULT_STALE_MS = 60_000;
21
- const MIN_DELAY_MS = 5;
22
- const MAX_DELAY_MS = 100;
14
+ const DEFAULT_LEASE_MS = 30_000;
23
15
  const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
16
+ const systemClock = {
17
+ now: Date.now,
18
+ schedule(fn, ms) {
19
+ const timer = setTimeout(fn, ms);
20
+ return {
21
+ unref: () => {
22
+ timer.unref();
23
+ },
24
+ cancel: () => clearTimeout(timer),
25
+ };
26
+ },
27
+ };
24
28
  export async function withStateLock(lockPath, fn, options = {}) {
25
- const token = await acquireAsync(lockPath, options);
29
+ const holder = await acquireAsync(lockPath, options);
30
+ const clock = options.clock ?? systemClock;
31
+ let timer;
32
+ let heartbeatError;
33
+ const beat = () => {
34
+ try {
35
+ holder.record.leaseUntil = clock.now() + (options.leaseMs ?? DEFAULT_LEASE_MS);
36
+ writeRecord(holder);
37
+ schedule();
38
+ }
39
+ catch (error) {
40
+ heartbeatError = error;
41
+ }
42
+ };
43
+ const schedule = () => {
44
+ if (options.heartbeatMs === 0)
45
+ return;
46
+ timer = clock.schedule(beat, options.heartbeatMs ?? (options.leaseMs ?? DEFAULT_LEASE_MS) / 3);
47
+ timer.unref();
48
+ };
49
+ schedule();
26
50
  try {
27
- return await fn();
51
+ const result = await fn(holder.record.token);
52
+ if (heartbeatError !== undefined)
53
+ throw heartbeatError;
54
+ return result;
28
55
  }
29
56
  finally {
30
- release(lockPath, token);
57
+ timer?.cancel();
58
+ closeSync(holder.fd);
59
+ release(lockPath, holder.record.token);
31
60
  }
32
61
  }
33
62
  export function withStateLockSync(lockPath, fn, options = {}) {
34
- const token = acquireSync(lockPath, options);
63
+ const holder = acquireSync(lockPath, options);
35
64
  try {
36
65
  return fn();
37
66
  }
38
67
  finally {
39
- release(lockPath, token);
68
+ closeSync(holder.fd);
69
+ release(lockPath, holder.record.token);
40
70
  }
41
71
  }
42
72
  export function isStateLockTimeout(error) {
43
73
  return error instanceof UlwLoopError && error.code === ULW_LOOP_LOCK_TIMEOUT_CODE;
44
74
  }
45
75
  async function acquireAsync(lockPath, options) {
46
- const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
47
- const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
76
+ const clock = options.clock ?? systemClock;
77
+ const deadline = clock.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
48
78
  mkdirSync(dirname(lockPath), { recursive: true });
49
79
  for (let attempt = 0;;) {
50
- const outcome = attemptOnce(lockPath, staleMs);
80
+ const outcome = attemptOnce(lockPath, options, clock.now(), options.leaseMs ?? DEFAULT_LEASE_MS);
51
81
  if (outcome.kind === "acquired")
52
- return outcome.token;
82
+ return outcome.holder;
53
83
  if (outcome.kind === "retry")
54
84
  continue;
55
- if (Date.now() >= deadline)
56
- throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
57
- await sleep(backoffMs(attempt));
85
+ if (clock.now() >= deadline)
86
+ throw lockTimeout(lockPath, options);
87
+ await new Promise((resolve) => clock.schedule(resolve, backoffMs(attempt)));
58
88
  attempt += 1;
59
89
  }
60
90
  }
61
91
  function acquireSync(lockPath, options) {
62
92
  const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
63
- const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
64
93
  mkdirSync(dirname(lockPath), { recursive: true });
65
94
  for (let attempt = 0;;) {
66
- const outcome = attemptOnce(lockPath, staleMs);
95
+ const outcome = attemptOnce(lockPath, options, Date.now());
67
96
  if (outcome.kind === "acquired")
68
- return outcome.token;
97
+ return outcome.holder;
69
98
  if (outcome.kind === "retry")
70
99
  continue;
71
100
  if (Date.now() >= deadline)
72
- throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
101
+ throw lockTimeout(lockPath, options);
73
102
  Atomics.wait(SLEEP_CELL, 0, 0, backoffMs(attempt));
74
103
  attempt += 1;
75
104
  }
76
105
  }
77
- // EINTR surfaces raw from macOS fs syscalls under some runtimes; it is a retry, never a verdict.
78
- function attemptOnce(lockPath, staleMs) {
106
+ function attemptOnce(lockPath, options, now, leaseMs) {
79
107
  try {
80
- const token = tryCreate(lockPath);
81
- if (token !== null)
82
- return { kind: "acquired", token };
83
- const snapshot = readSnapshot(lockPath);
108
+ const holder = tryCreate(lockPath, now, leaseMs);
109
+ if (holder !== null)
110
+ return { kind: "acquired", holder };
111
+ const snapshot = readSnapshot(lockPath, now);
84
112
  if (snapshot === null)
85
113
  return { kind: "retry" };
86
- if (isStale(snapshot, staleMs) && reclaim(lockPath, snapshot.raw))
114
+ const record = snapshot.record;
115
+ const stale = record === null
116
+ ? snapshot.ageMs > (options.staleMs ?? DEFAULT_STALE_MS)
117
+ : !isProcessAlive(record.pid) || (record.leaseUntil !== undefined && now > record.leaseUntil);
118
+ if (stale && reclaim(lockPath, snapshot.raw))
87
119
  return { kind: "retry" };
88
120
  return { kind: "wait" };
89
121
  }
@@ -93,7 +125,18 @@ function attemptOnce(lockPath, staleMs) {
93
125
  throw error;
94
126
  }
95
127
  }
96
- function tryCreate(lockPath) {
128
+ function writeRecord(holder) {
129
+ const buf = Buffer.from(JSON.stringify(holder.record));
130
+ ftruncateSync(holder.fd, 0);
131
+ let offset = 0;
132
+ while (offset < buf.length) {
133
+ const written = writeSync(holder.fd, buf, offset, buf.length - offset, offset);
134
+ if (written === 0)
135
+ throw new Error("State lock write made no progress.");
136
+ offset += written;
137
+ }
138
+ }
139
+ function tryCreate(lockPath, now, leaseMs) {
97
140
  let fd;
98
141
  try {
99
142
  fd = openSync(lockPath, "wx");
@@ -103,30 +146,33 @@ function tryCreate(lockPath) {
103
146
  return null;
104
147
  throw error;
105
148
  }
106
- const record = { pid: process.pid, createdAt: new Date().toISOString(), token: randomUUID() };
149
+ const record = {
150
+ pid: process.pid,
151
+ createdAt: new Date(now).toISOString(),
152
+ token: randomUUID(),
153
+ ...(leaseMs === undefined ? {} : { leaseUntil: now + leaseMs }),
154
+ };
155
+ const holder = { fd, record };
107
156
  try {
108
- writeSync(fd, JSON.stringify(record));
157
+ writeRecord(holder);
109
158
  }
110
159
  catch (error) {
111
160
  closeSync(fd);
112
- // A failed write (e.g. EINTR) must not leave a partial lock the owner never recorded;
113
- // otherwise later readers see a young ownerless lock and only age can retire it.
114
161
  try {
115
162
  unlinkSync(lockPath);
116
163
  }
117
- catch {
118
- // another process already reclaimed the partial file; leave it alone
164
+ catch (cleanup) {
165
+ if (!hasCode(cleanup, "ENOENT"))
166
+ throw cleanup;
119
167
  }
120
168
  throw error;
121
169
  }
122
- closeSync(fd);
123
- return record.token;
170
+ return holder;
124
171
  }
125
- function readSnapshot(lockPath) {
172
+ function readSnapshot(lockPath, now) {
126
173
  try {
127
174
  const raw = readFileSync(lockPath, "utf8");
128
- const ageMs = Date.now() - statSync(lockPath).mtimeMs;
129
- return { raw, record: parseRecord(raw), ageMs };
175
+ return { raw, record: parseRecord(raw), ageMs: now - statSync(lockPath).mtimeMs };
130
176
  }
131
177
  catch (error) {
132
178
  if (hasCode(error, "ENOENT"))
@@ -136,18 +182,21 @@ function readSnapshot(lockPath) {
136
182
  }
137
183
  function parseRecord(raw) {
138
184
  try {
139
- const parsed = JSON.parse(raw);
140
- if (typeof parsed !== "object" || parsed === null)
185
+ const record = JSON.parse(raw);
186
+ if (typeof record !== "object" || record === null)
187
+ return null;
188
+ if (!("pid" in record) || typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0)
141
189
  return null;
142
- const record = parsed;
143
- const pid = record["pid"];
144
- const createdAt = record["createdAt"];
145
- const token = record["token"];
146
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || typeof createdAt !== "string")
190
+ if (!("createdAt" in record) || typeof record.createdAt !== "string")
147
191
  return null;
148
- if (typeof token !== "string" || token.length === 0)
192
+ if (!("token" in record) || typeof record.token !== "string" || record.token.length === 0)
149
193
  return null;
150
- return { pid, createdAt, token };
194
+ return {
195
+ pid: record.pid,
196
+ createdAt: record.createdAt,
197
+ token: record.token,
198
+ ...("leaseUntil" in record && typeof record.leaseUntil === "number" ? { leaseUntil: record.leaseUntil } : {}),
199
+ };
151
200
  }
152
201
  catch (error) {
153
202
  if (error instanceof SyntaxError)
@@ -155,14 +204,6 @@ function parseRecord(raw) {
155
204
  throw error;
156
205
  }
157
206
  }
158
- // A body that is not a record is a lock mid-write (or a foreign/legacy file); only
159
- // age retires it. A parsable record is stale only when its owner is dead: a live
160
- // owner running past staleMs is slow, not gone, and the waiter fails closed instead.
161
- function isStale(snapshot, staleMs) {
162
- if (snapshot.record === null)
163
- return snapshot.ageMs > staleMs;
164
- return !isProcessAlive(snapshot.record.pid);
165
- }
166
207
  function isProcessAlive(pid) {
167
208
  try {
168
209
  process.kill(pid, 0);
@@ -176,10 +217,8 @@ function isProcessAlive(pid) {
176
217
  throw error;
177
218
  }
178
219
  }
179
- // Re-read right before unlinking so a lock that changed hands since the stale
180
- // verdict (a sibling waiter reclaimed and re-acquired it) is left alone.
181
220
  function reclaim(lockPath, expectedRaw) {
182
- const current = readSnapshot(lockPath);
221
+ const current = readSnapshot(lockPath, Date.now());
183
222
  if (current === null)
184
223
  return true;
185
224
  if (current.raw !== expectedRaw)
@@ -188,16 +227,15 @@ function reclaim(lockPath, expectedRaw) {
188
227
  unlinkSync(lockPath);
189
228
  }
190
229
  catch (error) {
230
+ if (hasCode(error, "EPERM") || hasCode(error, "EACCES"))
231
+ return false;
191
232
  if (!hasCode(error, "ENOENT"))
192
233
  throw error;
193
234
  }
194
235
  return true;
195
236
  }
196
- // Only the acquisition that wrote this token may unlink: if the file now carries
197
- // another token, a waiter has legitimately taken over and its lock must stand.
198
237
  function release(lockPath, token) {
199
- const current = readSnapshot(lockPath);
200
- if (current === null || current.record?.token !== token)
238
+ if (readSnapshot(lockPath, Date.now())?.record?.token !== token)
201
239
  return;
202
240
  try {
203
241
  unlinkSync(lockPath);
@@ -208,19 +246,13 @@ function release(lockPath, token) {
208
246
  }
209
247
  }
210
248
  function backoffMs(attempt) {
211
- const exponential = Math.min(MAX_DELAY_MS, MIN_DELAY_MS * 2 ** attempt);
212
- return exponential + Math.random() * MIN_DELAY_MS;
213
- }
214
- function lockTimeout(lockPath, timeoutMs) {
215
- const holder = readSnapshot(lockPath)?.record;
216
- const owner = holder === undefined || holder === null ? "another process" : `pid ${holder.pid}`;
217
- return new UlwLoopError(`ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms; retry once that process finishes, or delete the lock file if that process is gone.`, ULW_LOOP_LOCK_TIMEOUT_CODE, {
218
- details: {
219
- lockPath,
220
- timeoutMs,
221
- ...(holder === undefined || holder === null ? {} : { holderPid: holder.pid }),
222
- },
223
- });
249
+ return Math.min(100, 5 * 2 ** attempt) + Math.random() * 5;
250
+ }
251
+ function lockTimeout(lockPath, options) {
252
+ const holder = readSnapshot(lockPath, Date.now())?.record;
253
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
254
+ const owner = holder == null ? "another process" : `pid ${holder.pid}`;
255
+ return new UlwLoopError(`ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms. The lock owner is still alive. If a JS eval kernel was interrupted while writing, wait for its lease to expire (${options.leaseMs ?? DEFAULT_LEASE_MS} ms) or restart the owning senpi process; never delete a lock owned by a live process.`, ULW_LOOP_LOCK_TIMEOUT_CODE, { details: { lockPath, timeoutMs, ...(holder == null ? {} : { holderPid: holder.pid }) } });
224
256
  }
225
257
  function hasCode(error, code) {
226
258
  return error instanceof Error && "code" in error && error.code === code;
@@ -1,6 +1,7 @@
1
+ import type { UlwLoopToolkitSurface } from "./surface.js";
1
2
  import type { UlwLoopPlan } from "./types.js";
2
3
  /**
3
- * `status --json` is the one call every agent already makes between steps, so it is
4
+ * Status is the one call every agent already makes between steps, so it is
4
5
  * also the cheapest place to answer "what now?" without a second round trip.
5
6
  */
6
- export declare function statusNextActions(plan: UlwLoopPlan): readonly string[];
7
+ export declare function statusNextActions(plan: UlwLoopPlan, surface?: UlwLoopToolkitSurface): readonly string[];
@@ -1,33 +1,41 @@
1
1
  import { hasAllCriteriaPass, isFinalRunCompletionCandidate } from "./goal-status.js";
2
- import { ULW_LOOP_CREATE_GOALS_COMMAND } from "./plan-missing-recovery.js";
2
+ import { createGoalsAction } from "./plan-missing-recovery.js";
3
3
  /**
4
- * `status --json` is the one call every agent already makes between steps, so it is
4
+ * Status is the one call every agent already makes between steps, so it is
5
5
  * also the cheapest place to answer "what now?" without a second round trip.
6
6
  */
7
- export function statusNextActions(plan) {
7
+ export function statusNextActions(plan, surface = "lazycodex") {
8
8
  const actions = [];
9
9
  const active = plan.goals.find((goal) => goal.id === plan.activeGoalId);
10
10
  if (plan.goals.length === 0)
11
- actions.push(`No goals yet: bootstrap with \`${ULW_LOOP_CREATE_GOALS_COMMAND}\`.`);
11
+ actions.push(`No goals yet: bootstrap with \`${createGoalsAction(surface)}\`.`);
12
12
  else if (active !== undefined)
13
- actions.push(...activeGoalActions(plan, active));
13
+ actions.push(...activeGoalActions(plan, active, surface));
14
14
  for (const goal of plan.goals.filter((candidate) => candidate.status === "review_blocked"))
15
- actions.push(`${goal.id} is review_blocked: capture the reviewer verdict with \`omo-agent-toolkit ulw-loop record-review-blockers --goal-id ${goal.id} --title "<title>" --objective "<objective>" --evidence "<verdict>" --codex-goal-json '<get_goal json>'\`.`);
15
+ actions.push(surface === "omo-senpi"
16
+ ? `${goal.id} is review_blocked: capture the reviewer verdict with \`agentToolkit.recordReviewBlockers({ goalId: "${goal.id}", title: "<title>", objective: "<objective>", evidence: "<verdict>" })\`.`
17
+ : `${goal.id} is review_blocked: capture the reviewer verdict with \`omo-agent-toolkit ulw-loop record-review-blockers --goal-id ${goal.id} --title "<title>" --objective "<objective>" --evidence "<verdict>" --codex-goal-json '<get_goal json>'\`.`);
16
18
  if (plan.evidenceLayoutVersion !== 2 || active === undefined)
17
19
  actions.push("plan is evidence-layout v1; artifacts go under .omo/evidence/");
18
20
  return actions;
19
21
  }
20
- function activeGoalActions(plan, active) {
22
+ function activeGoalActions(plan, active, surface) {
21
23
  const unresolved = active.successCriteria.filter((criterion) => criterion.status !== "pass");
22
24
  if (unresolved.length > 0)
23
25
  return [
24
- `${active.id} has ${unresolved.length} unresolved criterion(s) (${unresolved.map((criterion) => criterion.id).join(", ")}): record proof with \`omo-agent-toolkit ulw-loop record-evidence --goal-id ${active.id} --criterion-id <id> --status pass --evidence "<observable proof>"\`.`,
26
+ surface === "omo-senpi"
27
+ ? `${active.id} has ${unresolved.length} unresolved criterion(s) (${unresolved.map((criterion) => criterion.id).join(", ")}): record proof with \`agentToolkit.recordEvidence({ goalId: "${active.id}", criterionId: "<id>", status: "pass", evidence: "<observable proof>" })\`.`
28
+ : `${active.id} has ${unresolved.length} unresolved criterion(s) (${unresolved.map((criterion) => criterion.id).join(", ")}): record proof with \`omo-agent-toolkit ulw-loop record-evidence --goal-id ${active.id} --criterion-id <id> --status pass --evidence "<observable proof>"\`.`,
25
29
  ];
26
30
  if (hasAllCriteriaPass(active) && isFinalRunCompletionCandidate(plan, active))
27
31
  return [
28
- `${active.id} passes every criterion and is the final story: update_goal complete, then checkpoint --print-template to build the final quality gate. Use status --json's currentAttemptDir for all quality-gate artifacts.`,
32
+ surface === "omo-senpi"
33
+ ? `${active.id} passes every criterion and is the final story: complete the driver goal, then call \`agentToolkit.checkpoint({ goalId: "${active.id}", printTemplate: true })\` to build the final quality gate. Use status().result.currentAttemptDir for all quality-gate artifacts.`
34
+ : `${active.id} passes every criterion and is the final story: update_goal complete, then checkpoint --print-template to build the final quality gate. Use status --json's currentAttemptDir for all quality-gate artifacts.`,
29
35
  ];
30
36
  return [
31
- `${active.id} passes every criterion: close it with \`omo-agent-toolkit ulw-loop checkpoint --goal-id ${active.id} --status complete --evidence "<proof>" --codex-goal-json '<get_goal json>'\`. Put quality-gate artifacts under the currentAttemptDir shown by status --json.`,
37
+ surface === "omo-senpi"
38
+ ? `${active.id} passes every criterion: close it with \`agentToolkit.checkpoint({ goalId: "${active.id}", status: "complete", evidence: "<proof>" })\`. Put quality-gate artifacts under status().result.currentAttemptDir.`
39
+ : `${active.id} passes every criterion: close it with \`omo-agent-toolkit ulw-loop checkpoint --goal-id ${active.id} --status complete --evidence "<proof>" --codex-goal-json '<get_goal json>'\`. Put quality-gate artifacts under the currentAttemptDir shown by status --json.`,
32
40
  ];
33
41
  }
@@ -1,4 +1,5 @@
1
- import { appendLedger, appendLedgerEntries, findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
1
+ import { commit } from "./plan-commit.js";
2
+ import { findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock } from "./plan-io.js";
2
3
  import { applySteeringMutation, validateUlwLoopSteeringProposal } from "./steering.js";
3
4
  import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "./steering-snapshot.js";
4
5
  import { iso } from "./types.js";
@@ -10,7 +11,7 @@ export async function steerUlwLoopBatch(repoRoot, proposals, scope) {
10
11
  const failed = prepared.results.find((item) => !item.accepted);
11
12
  if (failed !== undefined) {
12
13
  const entry = rejectedLedgerEntry(prepared.results);
13
- await appendLedger(repoRoot, entry, scope);
14
+ await commit(repoRoot, scope, { plan, entries: [entry] });
14
15
  return rejected(plan, prepared.results, failed.rejectedReasons);
15
16
  }
16
17
  let next = plan;
@@ -19,10 +20,9 @@ export async function steerUlwLoopBatch(repoRoot, proposals, scope) {
19
20
  next = item.prepared.next;
20
21
  const fresh = prepared.items.filter((item) => item.kind === "fresh");
21
22
  if (fresh.length > 0) {
22
- await writePlan(repoRoot, next, scope);
23
23
  const entries = fresh.map((item) => ledgerEntry(item.prepared.proposal, item.prepared.audit, item.prepared.proposal.now?.toISOString() ?? iso()));
24
24
  const batchEntry = batchUpdateLedgerEntry(plan, next, iso());
25
- await appendLedgerEntries(repoRoot, batchEntry === null ? entries : [...entries, batchEntry], scope);
25
+ await commit(repoRoot, scope, { plan: next, entries: batchEntry === null ? entries : [...entries, batchEntry] });
26
26
  }
27
27
  return { plan: next, accepted: true, results: prepared.results, rejectedReasons: [] };
28
28
  });
@@ -1,6 +1,7 @@
1
1
  // biome-ignore-all format: compact steering module must stay below the 240 pure-LOC budget
2
2
  import { isUlwLoopDone } from "./goal-status.js";
3
- import { appendLedger, findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
3
+ import { commit } from "./plan-commit.js";
4
+ import { findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock } from "./plan-io.js";
4
5
  import { makeGoal, reviseCriterion, reviseWording, splitOrBlock } from "./steering-mutations.js";
5
6
  import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "./steering-snapshot.js";
6
7
  import { iso, ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS } from "./types.js";
@@ -223,11 +224,10 @@ export async function steerUlwLoop(repoRoot, proposal, scope) {
223
224
  }
224
225
  const at = proposal.now?.toISOString() ?? iso();
225
226
  const batchEntry = accepted ? batchUpdateLedgerEntry(plan, next, at) : null;
226
- if (accepted)
227
- await writePlan(repoRoot, next, scope);
228
- await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, at), scope);
227
+ const entries = [ledgerEntry(proposal, finalAudit, at)];
229
228
  if (batchEntry !== null)
230
- await appendLedger(repoRoot, batchEntry, scope);
229
+ entries.push(batchEntry);
230
+ await commit(repoRoot, scope, { plan: next, entries });
231
231
  return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false };
232
232
  });
233
233
  }
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve, sep } from "node:path";
3
+ import { readLedgerAt } from "./ledger.js";
3
4
  import { normalizeUlwLoopSessionId, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
5
+ import { readUlwLoopPlanSync } from "./plan-io.js";
4
6
  import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
5
7
  // Turn-death recovery only: Codex emits Stop when a turn ends, so a run that
6
8
  // dies mid-turn (crash, kill, context blowup before any Stop) never reaches
@@ -27,7 +29,7 @@ export function runStopResumeHook(input) {
27
29
  return "";
28
30
  const scope = { sessionId: payload.session_id };
29
31
  const stateDir = ulwLoopDir(payload.cwd, scope);
30
- const plan = readPlan(join(stateDir, "goals.json"));
32
+ const plan = readPlan(payload.cwd, payload.session_id);
31
33
  if (plan === null || plan.aggregateCompletion?.status === "complete")
32
34
  return "";
33
35
  const goal = resumableGoal(plan);
@@ -76,11 +78,10 @@ function consumeResumeBudgetLocked(lockPath, stateDir, goalId) {
76
78
  throw error;
77
79
  }
78
80
  }
79
- // Two-strike cap keyed on ledger movement: an unchanged ledger.jsonl line
80
- // count across resumes means the loop is not progressing. The stuck marker is
81
- // a separate file — a ledger append would change the count and self-reset.
81
+ // Hook budget counters are exempt from plan/audit commits. Count reconciled
82
+ // entries, not cache lines: a writer dying after link still made progress.
82
83
  function consumeResumeBudget(stateDir, goalId) {
83
- const ledgerLineCount = countLedgerLines(join(stateDir, "ledger.jsonl"));
84
+ const ledgerLineCount = readLedgerAt(stateDir).length;
84
85
  const counterPath = resolve(stateDir, `auto-resume-${goalId}.json`);
85
86
  const stuckPath = resolve(stateDir, `auto-resume-${goalId}.stuck`);
86
87
  // goals.json is untrusted input: a crafted goal id (e.g. `../../x`) must
@@ -111,9 +112,9 @@ function renderResumeDirective(plan, goal, sessionId) {
111
112
  "If the loop is genuinely blocked on the user, checkpoint the goal as blocked with the reason instead.",
112
113
  ].join("\n");
113
114
  }
114
- function readPlan(goalsPath) {
115
+ function readPlan(repoRoot, sessionId) {
115
116
  try {
116
- return JSON.parse(readFileSync(goalsPath, "utf8"));
117
+ return readUlwLoopPlanSync(repoRoot, { sessionId });
117
118
  }
118
119
  catch (error) {
119
120
  if (error instanceof Error)
@@ -121,16 +122,6 @@ function readPlan(goalsPath) {
121
122
  throw error;
122
123
  }
123
124
  }
124
- function countLedgerLines(ledgerPath) {
125
- try {
126
- return readFileSync(ledgerPath, "utf8").split("\n").filter(Boolean).length;
127
- }
128
- catch (error) {
129
- if (error instanceof Error)
130
- return 0;
131
- throw error;
132
- }
133
- }
134
125
  function readCounter(counterPath) {
135
126
  try {
136
127
  if (!existsSync(counterPath))