lazycodex-ai 5.0.0-beta.61 → 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,11 +1,12 @@
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
 
5
4
  import type { PreToolUsePayload } from "./codex-hook.js";
6
5
  import { parsePreToolUsePayload } from "./codex-hook.js";
7
6
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
8
7
  import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
8
+ import { readUlwLoopPlanSync } from "./plan-io.js";
9
+ import { atomicWriteJson, isNonEmptyFile, readAdmissionBreaker, readCount, readCounts } from "./spawn-budget-io.js";
9
10
  import { spawnRoleDenial } from "./spawn-role-guard.js";
10
11
  import { isStateLockTimeout, type StateLockOptions, withStateLockSync } from "./state-lock.js";
11
12
  import {
@@ -57,7 +58,7 @@ export function applySpawnBudgetGuards(payload: PreToolUsePayload, options: Spaw
57
58
  );
58
59
  const scope = { sessionId: payload.session_id } as const;
59
60
  const stateDir = ulwLoopDir(payload.cwd, scope);
60
- const plan = readPlan(join(stateDir, "goals.json"));
61
+ const plan = readPlan(payload.cwd, payload.session_id);
61
62
  if (plan === null) return "";
62
63
  const lockOptions: StateLockOptions =
63
64
  options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs };
@@ -148,6 +149,7 @@ function peekFanOutBudget(stateDir: string): string | null {
148
149
  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.`;
149
150
  }
150
151
 
152
+ // Hook budget counters are exempt from plan/audit commits.
151
153
  // Per-session spawn counter; depth/lineage tracking is descoped — this is a
152
154
  // total-volume backstop against fan-out explosions, not a recursion tracker.
153
155
  function consumeFanOutBudget(stateDir: string): string | null {
@@ -245,12 +247,6 @@ function activeSurfaceReviewerAlias(reviewer: string): string {
245
247
  return reviewer;
246
248
  }
247
249
 
248
- function atomicWriteJson(targetPath: string, data: unknown): void {
249
- const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
250
- writeFileSync(tmp, JSON.stringify(data));
251
- renameSync(tmp, targetPath);
252
- }
253
-
254
250
  function deny(reason: string): string {
255
251
  return `${JSON.stringify({
256
252
  hookSpecificOutput: {
@@ -262,19 +258,6 @@ function deny(reason: string): string {
262
258
  })}\n`;
263
259
  }
264
260
 
265
- function readAdmissionBreaker(sessionId: string): string | null {
266
- const dataDir = process.env["PLUGIN_DATA"];
267
- if (typeof dataDir !== "string") return null;
268
- try {
269
- const value = JSON.parse(readFileSync(join(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8")) as {
270
- reason?: unknown;
271
- };
272
- return typeof value.reason === "string" ? value.reason : "capacity limit";
273
- } catch {
274
- return null;
275
- }
276
- }
277
-
278
261
  function fanOutLimit(): number {
279
262
  const raw = process.env["OMO_SPAWN_FANOUT_LIMIT"];
280
263
  if (raw === undefined) return DEFAULT_FANOUT_LIMIT;
@@ -289,43 +272,9 @@ function reviewSpawnLimit(): number {
289
272
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_REVIEW_SPAWN_LIMIT;
290
273
  }
291
274
 
292
- function isNonEmptyFile(path: string): boolean {
293
- try {
294
- return existsSync(path) && statSync(path).size > 0;
295
- } catch (error) {
296
- if (error instanceof Error) return false;
297
- throw error;
298
- }
299
- }
300
-
301
- function readCount(counterPath: string): number {
302
- try {
303
- const parsed = JSON.parse(readFileSync(counterPath, "utf8")) as Record<string, unknown>;
304
- return typeof parsed["count"] === "number" && parsed["count"] >= 0 ? parsed["count"] : 0;
305
- } catch (error) {
306
- if (error instanceof Error) return 0;
307
- throw error;
308
- }
309
- }
310
-
311
- function readCounts(counterPath: string): Record<string, number> {
312
- try {
313
- const parsed: unknown = JSON.parse(readFileSync(counterPath, "utf8"));
314
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
315
- const counts: Record<string, number> = {};
316
- for (const [key, value] of Object.entries(parsed)) {
317
- if (typeof value === "number" && value >= 0) counts[key] = value;
318
- }
319
- return counts;
320
- } catch (error) {
321
- if (error instanceof Error) return {};
322
- throw error;
323
- }
324
- }
325
-
326
- function readPlan(goalsPath: string): UlwLoopPlan | null {
275
+ function readPlan(repoRoot: string, sessionId: string): UlwLoopPlan | null {
327
276
  try {
328
- return JSON.parse(readFileSync(goalsPath, "utf8")) as UlwLoopPlan;
277
+ return readUlwLoopPlanSync(repoRoot, { sessionId });
329
278
  } catch (error) {
330
279
  if (error instanceof Error) return null;
331
280
  throw error;
@@ -1,120 +1,159 @@
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
-
6
4
  import { UlwLoopError } from "./types.js";
7
5
 
8
- // Cross-process exclusive lock for one ulw-loop state directory. Every CLI
9
- // invocation is its own process, so the in-process promise chain in plan-io
10
- // serializes nothing across them; this file-level lock is what makes the
11
- // read-modify-write of goals.json (and the counters next to it) atomic.
12
- //
13
- // Protocol: the lock is a file created with O_EXCL whose body records the
14
- // owner (pid + a per-acquisition token). A waiter reclaims it only when the
15
- // owner is provably gone (pid dead) or the body never became a record within
16
- // `staleMs` (a creator that died mid-write); a live owner is never reclaimed by
17
- // age alone, because overlapping two bodies is exactly the lost update this lock
18
- // exists to prevent. Waiters back off and fail closed at `timeoutMs`. Release
19
- // unlinks only a lock that still carries the releaser's own token.
20
-
6
+ // O_EXCL establishes ownership. Async holders keep the fd open and refresh a
7
+ // lease through that fd, never through the pathname: a reclaimed inode is invisible.
8
+ // Sync/legacy holders are lease-less and only dead pids retire their records.
9
+ // Expiry permits overlapping bodies; immutable revision publication fences them.
10
+ // Reclaim's reread/unlink is not atomic. On Windows an open inode may not unlink:
11
+ // treat that refusal as a live owner and fail closed. Release checks its token.
21
12
  export const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
22
-
13
+ export interface StateLockClock {
14
+ readonly now: () => number;
15
+ readonly schedule: (fn: () => void, ms: number) => { unref(): void; cancel(): void };
16
+ }
23
17
  export interface StateLockOptions {
24
18
  readonly timeoutMs?: number;
25
19
  readonly staleMs?: number;
20
+ readonly leaseMs?: number;
21
+ readonly heartbeatMs?: number;
22
+ readonly clock?: StateLockClock;
26
23
  }
27
-
28
24
  interface LockRecord {
29
25
  readonly pid: number;
30
26
  readonly createdAt: string;
31
27
  readonly token: string;
28
+ leaseUntil?: number;
29
+ }
30
+ interface Holder {
31
+ readonly fd: number;
32
+ readonly record: LockRecord;
32
33
  }
33
-
34
34
  interface LockSnapshot {
35
35
  readonly raw: string;
36
36
  readonly record: LockRecord | null;
37
37
  readonly ageMs: number;
38
38
  }
39
-
40
- type AttemptOutcome = { readonly kind: "acquired"; readonly token: string } | { readonly kind: "retry" | "wait" };
41
-
39
+ type AttemptOutcome = { readonly kind: "acquired"; readonly holder: Holder } | { readonly kind: "retry" | "wait" };
42
40
  const DEFAULT_TIMEOUT_MS = 10_000;
43
41
  const DEFAULT_STALE_MS = 60_000;
44
- const MIN_DELAY_MS = 5;
45
- const MAX_DELAY_MS = 100;
42
+ const DEFAULT_LEASE_MS = 30_000;
46
43
  const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
44
+ const systemClock: StateLockClock = {
45
+ now: Date.now,
46
+ schedule(fn, ms) {
47
+ const timer = setTimeout(fn, ms);
48
+ return {
49
+ unref: () => {
50
+ timer.unref();
51
+ },
52
+ cancel: () => clearTimeout(timer),
53
+ };
54
+ },
55
+ };
47
56
 
48
57
  export async function withStateLock<T>(
49
58
  lockPath: string,
50
- fn: () => Promise<T>,
59
+ fn: (token: string) => Promise<T>,
51
60
  options: StateLockOptions = {},
52
61
  ): Promise<T> {
53
- const token = await acquireAsync(lockPath, options);
62
+ const holder = await acquireAsync(lockPath, options);
63
+ const clock = options.clock ?? systemClock;
64
+ let timer: ReturnType<StateLockClock["schedule"]> | undefined;
65
+ let heartbeatError: unknown;
66
+ const beat = () => {
67
+ try {
68
+ holder.record.leaseUntil = clock.now() + (options.leaseMs ?? DEFAULT_LEASE_MS);
69
+ writeRecord(holder);
70
+ schedule();
71
+ } catch (error) {
72
+ heartbeatError = error;
73
+ }
74
+ };
75
+ const schedule = () => {
76
+ if (options.heartbeatMs === 0) return;
77
+ timer = clock.schedule(beat, options.heartbeatMs ?? (options.leaseMs ?? DEFAULT_LEASE_MS) / 3);
78
+ timer.unref();
79
+ };
80
+ schedule();
54
81
  try {
55
- return await fn();
82
+ const result = await fn(holder.record.token);
83
+ if (heartbeatError !== undefined) throw heartbeatError;
84
+ return result;
56
85
  } finally {
57
- release(lockPath, token);
86
+ timer?.cancel();
87
+ closeSync(holder.fd);
88
+ release(lockPath, holder.record.token);
58
89
  }
59
90
  }
60
-
61
91
  export function withStateLockSync<T>(lockPath: string, fn: () => T, options: StateLockOptions = {}): T {
62
- const token = acquireSync(lockPath, options);
92
+ const holder = acquireSync(lockPath, options);
63
93
  try {
64
94
  return fn();
65
95
  } finally {
66
- release(lockPath, token);
96
+ closeSync(holder.fd);
97
+ release(lockPath, holder.record.token);
67
98
  }
68
99
  }
69
-
70
100
  export function isStateLockTimeout(error: unknown): error is UlwLoopError {
71
101
  return error instanceof UlwLoopError && error.code === ULW_LOOP_LOCK_TIMEOUT_CODE;
72
102
  }
73
-
74
- async function acquireAsync(lockPath: string, options: StateLockOptions): Promise<string> {
75
- const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
76
- const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
103
+ async function acquireAsync(lockPath: string, options: StateLockOptions): Promise<Holder> {
104
+ const clock = options.clock ?? systemClock;
105
+ const deadline = clock.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
77
106
  mkdirSync(dirname(lockPath), { recursive: true });
78
107
  for (let attempt = 0; ; ) {
79
- const outcome = attemptOnce(lockPath, staleMs);
80
- if (outcome.kind === "acquired") return outcome.token;
108
+ const outcome = attemptOnce(lockPath, options, clock.now(), options.leaseMs ?? DEFAULT_LEASE_MS);
109
+ if (outcome.kind === "acquired") return outcome.holder;
81
110
  if (outcome.kind === "retry") continue;
82
- if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
83
- await sleep(backoffMs(attempt));
111
+ if (clock.now() >= deadline) throw lockTimeout(lockPath, options);
112
+ await new Promise<void>((resolve) => clock.schedule(resolve, backoffMs(attempt)));
84
113
  attempt += 1;
85
114
  }
86
115
  }
87
-
88
- function acquireSync(lockPath: string, options: StateLockOptions): string {
116
+ function acquireSync(lockPath: string, options: StateLockOptions): Holder {
89
117
  const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
90
- const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
91
118
  mkdirSync(dirname(lockPath), { recursive: true });
92
119
  for (let attempt = 0; ; ) {
93
- const outcome = attemptOnce(lockPath, staleMs);
94
- if (outcome.kind === "acquired") return outcome.token;
120
+ const outcome = attemptOnce(lockPath, options, Date.now());
121
+ if (outcome.kind === "acquired") return outcome.holder;
95
122
  if (outcome.kind === "retry") continue;
96
- if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
123
+ if (Date.now() >= deadline) throw lockTimeout(lockPath, options);
97
124
  Atomics.wait(SLEEP_CELL, 0, 0, backoffMs(attempt));
98
125
  attempt += 1;
99
126
  }
100
127
  }
101
-
102
- // EINTR surfaces raw from macOS fs syscalls under some runtimes; it is a retry, never a verdict.
103
- function attemptOnce(lockPath: string, staleMs: number): AttemptOutcome {
128
+ function attemptOnce(lockPath: string, options: StateLockOptions, now: number, leaseMs?: number): AttemptOutcome {
104
129
  try {
105
- const token = tryCreate(lockPath);
106
- if (token !== null) return { kind: "acquired", token };
107
- const snapshot = readSnapshot(lockPath);
130
+ const holder = tryCreate(lockPath, now, leaseMs);
131
+ if (holder !== null) return { kind: "acquired", holder };
132
+ const snapshot = readSnapshot(lockPath, now);
108
133
  if (snapshot === null) return { kind: "retry" };
109
- if (isStale(snapshot, staleMs) && reclaim(lockPath, snapshot.raw)) return { kind: "retry" };
134
+ const record = snapshot.record;
135
+ const stale =
136
+ record === null
137
+ ? snapshot.ageMs > (options.staleMs ?? DEFAULT_STALE_MS)
138
+ : !isProcessAlive(record.pid) || (record.leaseUntil !== undefined && now > record.leaseUntil);
139
+ if (stale && reclaim(lockPath, snapshot.raw)) return { kind: "retry" };
110
140
  return { kind: "wait" };
111
141
  } catch (error) {
112
142
  if (hasCode(error, "EINTR")) return { kind: "wait" };
113
143
  throw error;
114
144
  }
115
145
  }
116
-
117
- function tryCreate(lockPath: string): string | null {
146
+ function writeRecord(holder: Holder): void {
147
+ const buf = Buffer.from(JSON.stringify(holder.record));
148
+ ftruncateSync(holder.fd, 0);
149
+ let offset = 0;
150
+ while (offset < buf.length) {
151
+ const written = writeSync(holder.fd, buf, offset, buf.length - offset, offset);
152
+ if (written === 0) throw new Error("State lock write made no progress.");
153
+ offset += written;
154
+ }
155
+ }
156
+ function tryCreate(lockPath: string, now: number, leaseMs?: number): Holder | null {
118
157
  let fd: number;
119
158
  try {
120
159
  fd = openSync(lockPath, "wx");
@@ -122,60 +161,54 @@ function tryCreate(lockPath: string): string | null {
122
161
  if (hasCode(error, "EEXIST")) return null;
123
162
  throw error;
124
163
  }
125
- const record: LockRecord = { pid: process.pid, createdAt: new Date().toISOString(), token: randomUUID() };
164
+ const record: LockRecord = {
165
+ pid: process.pid,
166
+ createdAt: new Date(now).toISOString(),
167
+ token: randomUUID(),
168
+ ...(leaseMs === undefined ? {} : { leaseUntil: now + leaseMs }),
169
+ };
170
+ const holder = { fd, record };
126
171
  try {
127
- writeSync(fd, JSON.stringify(record));
172
+ writeRecord(holder);
128
173
  } catch (error) {
129
174
  closeSync(fd);
130
- // A failed write (e.g. EINTR) must not leave a partial lock the owner never recorded;
131
- // otherwise later readers see a young ownerless lock and only age can retire it.
132
175
  try {
133
176
  unlinkSync(lockPath);
134
- } catch {
135
- // another process already reclaimed the partial file; leave it alone
177
+ } catch (cleanup) {
178
+ if (!hasCode(cleanup, "ENOENT")) throw cleanup;
136
179
  }
137
180
  throw error;
138
181
  }
139
- closeSync(fd);
140
- return record.token;
182
+ return holder;
141
183
  }
142
-
143
- function readSnapshot(lockPath: string): LockSnapshot | null {
184
+ function readSnapshot(lockPath: string, now: number): LockSnapshot | null {
144
185
  try {
145
186
  const raw = readFileSync(lockPath, "utf8");
146
- const ageMs = Date.now() - statSync(lockPath).mtimeMs;
147
- return { raw, record: parseRecord(raw), ageMs };
187
+ return { raw, record: parseRecord(raw), ageMs: now - statSync(lockPath).mtimeMs };
148
188
  } catch (error) {
149
189
  if (hasCode(error, "ENOENT")) return null;
150
190
  throw error;
151
191
  }
152
192
  }
153
-
154
193
  function parseRecord(raw: string): LockRecord | null {
155
194
  try {
156
- const parsed: unknown = JSON.parse(raw);
157
- if (typeof parsed !== "object" || parsed === null) return null;
158
- const record = parsed as Record<string, unknown>;
159
- const pid = record["pid"];
160
- const createdAt = record["createdAt"];
161
- const token = record["token"];
162
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || typeof createdAt !== "string") return null;
163
- if (typeof token !== "string" || token.length === 0) return null;
164
- return { pid, createdAt, token };
195
+ const record: unknown = JSON.parse(raw);
196
+ if (typeof record !== "object" || record === null) return null;
197
+ if (!("pid" in record) || typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0)
198
+ return null;
199
+ if (!("createdAt" in record) || typeof record.createdAt !== "string") return null;
200
+ if (!("token" in record) || typeof record.token !== "string" || record.token.length === 0) return null;
201
+ return {
202
+ pid: record.pid,
203
+ createdAt: record.createdAt,
204
+ token: record.token,
205
+ ...("leaseUntil" in record && typeof record.leaseUntil === "number" ? { leaseUntil: record.leaseUntil } : {}),
206
+ };
165
207
  } catch (error) {
166
208
  if (error instanceof SyntaxError) return null;
167
209
  throw error;
168
210
  }
169
211
  }
170
-
171
- // A body that is not a record is a lock mid-write (or a foreign/legacy file); only
172
- // age retires it. A parsable record is stale only when its owner is dead: a live
173
- // owner running past staleMs is slow, not gone, and the waiter fails closed instead.
174
- function isStale(snapshot: LockSnapshot, staleMs: number): boolean {
175
- if (snapshot.record === null) return snapshot.ageMs > staleMs;
176
- return !isProcessAlive(snapshot.record.pid);
177
- }
178
-
179
212
  function isProcessAlive(pid: number): boolean {
180
213
  try {
181
214
  process.kill(pid, 0);
@@ -186,54 +219,39 @@ function isProcessAlive(pid: number): boolean {
186
219
  throw error;
187
220
  }
188
221
  }
189
-
190
- // Re-read right before unlinking so a lock that changed hands since the stale
191
- // verdict (a sibling waiter reclaimed and re-acquired it) is left alone.
192
222
  function reclaim(lockPath: string, expectedRaw: string): boolean {
193
- const current = readSnapshot(lockPath);
223
+ const current = readSnapshot(lockPath, Date.now());
194
224
  if (current === null) return true;
195
225
  if (current.raw !== expectedRaw) return false;
196
226
  try {
197
227
  unlinkSync(lockPath);
198
228
  } catch (error) {
229
+ if (hasCode(error, "EPERM") || hasCode(error, "EACCES")) return false;
199
230
  if (!hasCode(error, "ENOENT")) throw error;
200
231
  }
201
232
  return true;
202
233
  }
203
-
204
- // Only the acquisition that wrote this token may unlink: if the file now carries
205
- // another token, a waiter has legitimately taken over and its lock must stand.
206
234
  function release(lockPath: string, token: string): void {
207
- const current = readSnapshot(lockPath);
208
- if (current === null || current.record?.token !== token) return;
235
+ if (readSnapshot(lockPath, Date.now())?.record?.token !== token) return;
209
236
  try {
210
237
  unlinkSync(lockPath);
211
238
  } catch (error) {
212
239
  if (!hasCode(error, "ENOENT")) throw error;
213
240
  }
214
241
  }
215
-
216
242
  function backoffMs(attempt: number): number {
217
- const exponential = Math.min(MAX_DELAY_MS, MIN_DELAY_MS * 2 ** attempt);
218
- return exponential + Math.random() * MIN_DELAY_MS;
243
+ return Math.min(100, 5 * 2 ** attempt) + Math.random() * 5;
219
244
  }
220
-
221
- function lockTimeout(lockPath: string, timeoutMs: number): UlwLoopError {
222
- const holder = readSnapshot(lockPath)?.record;
223
- const owner = holder === undefined || holder === null ? "another process" : `pid ${holder.pid}`;
245
+ function lockTimeout(lockPath: string, options: StateLockOptions): UlwLoopError {
246
+ const holder = readSnapshot(lockPath, Date.now())?.record;
247
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
248
+ const owner = holder == null ? "another process" : `pid ${holder.pid}`;
224
249
  return new UlwLoopError(
225
- `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.`,
250
+ `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.`,
226
251
  ULW_LOOP_LOCK_TIMEOUT_CODE,
227
- {
228
- details: {
229
- lockPath,
230
- timeoutMs,
231
- ...(holder === undefined || holder === null ? {} : { holderPid: holder.pid }),
232
- },
233
- },
252
+ { details: { lockPath, timeoutMs, ...(holder == null ? {} : { holderPid: holder.pid }) } },
234
253
  );
235
254
  }
236
-
237
255
  function hasCode(error: unknown, code: string): boolean {
238
256
  return error instanceof Error && "code" in error && error.code === code;
239
257
  }
@@ -1,36 +1,45 @@
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
+ import type { UlwLoopToolkitSurface } from "./surface.js";
3
4
  import type { UlwLoopItem, UlwLoopPlan } from "./types.js";
4
5
 
5
6
  /**
6
- * `status --json` is the one call every agent already makes between steps, so it is
7
+ * Status is the one call every agent already makes between steps, so it is
7
8
  * also the cheapest place to answer "what now?" without a second round trip.
8
9
  */
9
- export function statusNextActions(plan: UlwLoopPlan): readonly string[] {
10
+ export function statusNextActions(plan: UlwLoopPlan, surface: UlwLoopToolkitSurface = "lazycodex"): readonly string[] {
10
11
  const actions: string[] = [];
11
12
  const active = plan.goals.find((goal) => goal.id === plan.activeGoalId);
12
- if (plan.goals.length === 0) actions.push(`No goals yet: bootstrap with \`${ULW_LOOP_CREATE_GOALS_COMMAND}\`.`);
13
- else if (active !== undefined) actions.push(...activeGoalActions(plan, active));
13
+ if (plan.goals.length === 0) actions.push(`No goals yet: bootstrap with \`${createGoalsAction(surface)}\`.`);
14
+ else if (active !== undefined) actions.push(...activeGoalActions(plan, active, surface));
14
15
  for (const goal of plan.goals.filter((candidate) => candidate.status === "review_blocked"))
15
16
  actions.push(
16
- `${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>'\`.`,
17
+ surface === "omo-senpi"
18
+ ? `${goal.id} is review_blocked: capture the reviewer verdict with \`agentToolkit.recordReviewBlockers({ goalId: "${goal.id}", title: "<title>", objective: "<objective>", evidence: "<verdict>" })\`.`
19
+ : `${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>'\`.`,
17
20
  );
18
21
  if (plan.evidenceLayoutVersion !== 2 || active === undefined)
19
22
  actions.push("plan is evidence-layout v1; artifacts go under .omo/evidence/");
20
23
  return actions;
21
24
  }
22
25
 
23
- function activeGoalActions(plan: UlwLoopPlan, active: UlwLoopItem): readonly string[] {
26
+ function activeGoalActions(plan: UlwLoopPlan, active: UlwLoopItem, surface: UlwLoopToolkitSurface): readonly string[] {
24
27
  const unresolved = active.successCriteria.filter((criterion) => criterion.status !== "pass");
25
28
  if (unresolved.length > 0)
26
29
  return [
27
- `${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>"\`.`,
30
+ surface === "omo-senpi"
31
+ ? `${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>" })\`.`
32
+ : `${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>"\`.`,
28
33
  ];
29
34
  if (hasAllCriteriaPass(active) && isFinalRunCompletionCandidate(plan, active))
30
35
  return [
31
- `${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.`,
36
+ surface === "omo-senpi"
37
+ ? `${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.`
38
+ : `${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
39
  ];
33
40
  return [
34
- `${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.`,
41
+ surface === "omo-senpi"
42
+ ? `${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.`
43
+ : `${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.`,
35
44
  ];
36
45
  }
@@ -1,6 +1,7 @@
1
1
  // biome-ignore-all format: compact batch steering module stays below the pure LOC budget.
2
2
  import type { UlwLoopScope } from "./paths.js";
3
- import { appendLedger, appendLedgerEntries, 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 { applySteeringMutation, validateUlwLoopSteeringProposal } from "./steering.js";
5
6
  import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "./steering-snapshot.js";
6
7
  import type { UlwLoopLedgerEntry, UlwLoopPlan, UlwLoopSteeringAudit, UlwLoopSteeringProposal } from "./types.js";
@@ -43,17 +44,16 @@ export async function steerUlwLoopBatch(
43
44
  const failed = prepared.results.find((item) => !item.accepted);
44
45
  if (failed !== undefined) {
45
46
  const entry = rejectedLedgerEntry(prepared.results);
46
- await appendLedger(repoRoot, entry, scope);
47
+ await commit(repoRoot, scope, { plan, entries: [entry] });
47
48
  return rejected(plan, prepared.results, failed.rejectedReasons);
48
49
  }
49
50
  let next = plan;
50
51
  for (const item of prepared.items) if (item.kind === "fresh") next = item.prepared.next;
51
52
  const fresh = prepared.items.filter((item): item is Extract<PreparedItem, { readonly kind: "fresh" }> => item.kind === "fresh");
52
53
  if (fresh.length > 0) {
53
- await writePlan(repoRoot, next, scope);
54
54
  const entries = fresh.map((item) => ledgerEntry(item.prepared.proposal, item.prepared.audit, item.prepared.proposal.now?.toISOString() ?? iso()));
55
55
  const batchEntry = batchUpdateLedgerEntry(plan, next, iso());
56
- await appendLedgerEntries(repoRoot, batchEntry === null ? entries : [...entries, batchEntry], scope);
56
+ await commit(repoRoot, scope, { plan: next, entries: batchEntry === null ? entries : [...entries, batchEntry] });
57
57
  }
58
58
  return { plan: next, accepted: true, results: prepared.results, rejectedReasons: [] };
59
59
  });
@@ -1,7 +1,8 @@
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
3
  import type { UlwLoopScope } from "./paths.js";
4
- import { appendLedger, findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
4
+ import { commit } from "./plan-commit.js";
5
+ import { findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock } from "./plan-io.js";
5
6
  import { makeGoal, reviseCriterion, reviseWording, splitOrBlock } from "./steering-mutations.js";
6
7
  import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "./steering-snapshot.js";
7
8
  import type {
@@ -209,9 +210,9 @@ export async function steerUlwLoop(repoRoot: string, proposal: UlwLoopSteeringPr
209
210
  }
210
211
  const at = proposal.now?.toISOString() ?? iso();
211
212
  const batchEntry = accepted ? batchUpdateLedgerEntry(plan, next, at) : null;
212
- if (accepted) await writePlan(repoRoot, next, scope);
213
- await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, at), scope);
214
- if (batchEntry !== null) await appendLedger(repoRoot, batchEntry, scope);
213
+ const entries = [ledgerEntry(proposal, finalAudit, at)];
214
+ if (batchEntry !== null) entries.push(batchEntry);
215
+ await commit(repoRoot, scope, { plan: next, entries });
215
216
  return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false };
216
217
  });
217
218
  }
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve, sep } from "node:path";
3
3
 
4
+ import { readLedgerAt } from "./ledger.js";
4
5
  import { normalizeUlwLoopSessionId, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
6
+ import { readUlwLoopPlanSync } from "./plan-io.js";
5
7
  import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
6
8
  import type { UlwLoopItem, UlwLoopPlan } from "./types.js";
7
9
 
@@ -37,7 +39,7 @@ export function runStopResumeHook(input: unknown): string {
37
39
  if (boulderContinuationWillFire(payload.cwd, payload.session_id)) return "";
38
40
  const scope = { sessionId: payload.session_id } as const;
39
41
  const stateDir = ulwLoopDir(payload.cwd, scope);
40
- const plan = readPlan(join(stateDir, "goals.json"));
42
+ const plan = readPlan(payload.cwd, payload.session_id);
41
43
  if (plan === null || plan.aggregateCompletion?.status === "complete") return "";
42
44
  const goal = resumableGoal(plan);
43
45
  if (goal === undefined) return "";
@@ -81,11 +83,10 @@ function consumeResumeBudgetLocked(lockPath: string, stateDir: string, goalId: s
81
83
  }
82
84
  }
83
85
 
84
- // Two-strike cap keyed on ledger movement: an unchanged ledger.jsonl line
85
- // count across resumes means the loop is not progressing. The stuck marker is
86
- // a separate file — a ledger append would change the count and self-reset.
86
+ // Hook budget counters are exempt from plan/audit commits. Count reconciled
87
+ // entries, not cache lines: a writer dying after link still made progress.
87
88
  function consumeResumeBudget(stateDir: string, goalId: string): boolean {
88
- const ledgerLineCount = countLedgerLines(join(stateDir, "ledger.jsonl"));
89
+ const ledgerLineCount = readLedgerAt(stateDir).length;
89
90
  const counterPath = resolve(stateDir, `auto-resume-${goalId}.json`);
90
91
  const stuckPath = resolve(stateDir, `auto-resume-${goalId}.stuck`);
91
92
  // goals.json is untrusted input: a crafted goal id (e.g. `../../x`) must
@@ -119,24 +120,15 @@ function renderResumeDirective(plan: UlwLoopPlan, goal: UlwLoopItem, sessionId:
119
120
  ].join("\n");
120
121
  }
121
122
 
122
- function readPlan(goalsPath: string): UlwLoopPlan | null {
123
+ function readPlan(repoRoot: string, sessionId: string): UlwLoopPlan | null {
123
124
  try {
124
- return JSON.parse(readFileSync(goalsPath, "utf8")) as UlwLoopPlan;
125
+ return readUlwLoopPlanSync(repoRoot, { sessionId });
125
126
  } catch (error) {
126
127
  if (error instanceof Error) return null;
127
128
  throw error;
128
129
  }
129
130
  }
130
131
 
131
- function countLedgerLines(ledgerPath: string): number {
132
- try {
133
- return readFileSync(ledgerPath, "utf8").split("\n").filter(Boolean).length;
134
- } catch (error) {
135
- if (error instanceof Error) return 0;
136
- throw error;
137
- }
138
- }
139
-
140
132
  function readCounter(counterPath: string): { count: number; ledgerLineCount: number } | null {
141
133
  try {
142
134
  if (!existsSync(counterPath)) return null;