lazycodex-ai 5.0.0-beta.45 → 5.0.0-beta.47

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 (99) hide show
  1. package/dist/cli/index.js +43 -27
  2. package/dist/cli-node/index.js +43 -27
  3. package/package.json +1 -1
  4. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  5. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  6. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  7. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  8. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  9. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  10. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  11. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  12. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  13. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  14. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  15. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  16. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  17. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  18. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  19. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  20. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  21. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  22. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  23. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  24. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +2 -2
  25. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  26. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -7
  27. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.d.ts +1 -1
  28. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.js +1 -1
  29. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +294 -96
  30. package/packages/omo-codex/plugin/components/ulw-loop/dist/constants.d.ts +1 -0
  31. package/packages/omo-codex/plugin/components/ulw-loop/dist/constants.js +1 -0
  32. package/packages/omo-codex/plugin/components/ulw-loop/dist/paths.d.ts +1 -0
  33. package/packages/omo-codex/plugin/components/ulw-loop/dist/paths.js +12 -3
  34. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +1 -0
  35. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +21 -6
  36. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.d.ts +1 -0
  37. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.js +10 -0
  38. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.d.ts +4 -1
  39. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +25 -57
  40. package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.d.ts +9 -0
  41. package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.js +227 -0
  42. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +17 -3
  43. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  44. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  45. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +2 -1
  46. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +10 -7
  47. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +18 -7
  48. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts +1 -1
  49. package/packages/omo-codex/plugin/components/ulw-loop/src/constants.ts +1 -0
  50. package/packages/omo-codex/plugin/components/ulw-loop/src/paths.ts +24 -3
  51. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +25 -5
  52. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-missing-recovery.ts +11 -0
  53. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +34 -64
  54. package/packages/omo-codex/plugin/components/ulw-loop/src/state-lock.ts +239 -0
  55. package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +16 -3
  56. package/packages/omo-codex/plugin/components/ulw-loop/test/checkpoint-template.test.ts +14 -4
  57. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-checkpoint-continuation.test.ts +4 -2
  58. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-checkpoint.test.ts +4 -2
  59. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-commands.test.ts +2 -0
  60. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-complete-goals.test.ts +3 -1
  61. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +10 -8
  62. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-entrypoint.test.ts +12 -2
  63. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-json-errors.test.ts +2 -0
  64. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-scope-required.test.ts +144 -0
  65. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-status-next-actions.test.ts +7 -5
  66. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-steering-batch.test.ts +8 -3
  67. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-validation-batch.test.ts +6 -0
  68. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/cli-session.ts +4 -0
  69. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/quality-gate-builder.ts +2 -1
  70. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io-cross-process.test.ts +106 -0
  71. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +36 -2
  72. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +37 -0
  73. package/packages/omo-codex/plugin/components/ulw-loop/test/state-lock.test.ts +218 -0
  74. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  75. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  76. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  77. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  78. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  79. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  80. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  81. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  82. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  83. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  84. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  85. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  86. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  87. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  88. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  89. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  90. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-ulw-execute-continuation.json +1 -1
  91. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  92. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  93. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  94. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  95. package/packages/omo-codex/plugin/package-lock.json +12 -12
  96. package/packages/omo-codex/plugin/package.json +1 -1
  97. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +2 -1
  98. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +10 -7
  99. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
@@ -13,7 +13,7 @@ Use GPT-5.x style: outcome-first, evidence-bound, atomic decisions, no nested br
13
13
  Deliver every goal in `.omo/ulw-loop/goals.json` end-to-end.
14
14
  Prove EVERY success criterion with captured observable evidence from a real-usage scenario you ran (HTTP / tmux / browser / computer-use below).
15
15
  TESTS ALONE NEVER PROVE DONE. A green test suite is supporting evidence, not completion proof.
16
- Audit each pass, fail, block, steering change, and checkpoint in `.omo/ulw-loop/ledger.jsonl`.
16
+ Audit each pass, fail, block, steering change, and checkpoint in `.omo/ulw-loop/<session-id>/ledger.jsonl`.
17
17
 
18
18
  ## Manual-QA channels
19
19
  Run each criterion's real-surface proof yourself through the channel that faithfully exercises it; capture the artifact before recording PASS.
@@ -110,13 +110,16 @@ If `ULW_LOOP_CLI` is empty, open the durable notepad first, record the missing C
110
110
 
111
111
  Run one form:
112
112
  ```sh
113
- omo-agent-toolkit ulw-loop create-goals --brief "<brief>" [--validation-batch-json <json-or-path>] --json
114
- omo-agent-toolkit ulw-loop create-goals --brief-file <path> [--validation-batch-json <json-or-path>] --json
115
- cat <brief> | omo-agent-toolkit ulw-loop create-goals --from-stdin [--validation-batch-json <json-or-path>] --json
113
+ omo-agent-toolkit ulw-loop create-goals --session-id <id> --brief "<brief>" [--validation-batch-json <json-or-path>] --json
114
+ omo-agent-toolkit ulw-loop create-goals --session-id <id> --brief-file <path> [--validation-batch-json <json-or-path>] --json
115
+ cat <brief> | omo-agent-toolkit ulw-loop create-goals --session-id <id> --from-stdin [--validation-batch-json <json-or-path>] --json
116
116
  ```
117
- If the existing aggregate is already complete, do not steer or force the
118
- completed default state for unrelated new work. Start a fresh run with
119
- `omo-agent-toolkit ulw-loop create-goals --session-id <new-id> ...`; use `--force`
117
+ Every state subcommand runs against exactly one session scope: pass `--session-id <id>` on every call (the printed handoff and the Stop hook resume directive carry this session's id; `PI_SESSION_ID`, `CODEX_THREAD_ID`, `CODEX_SESSION_ID`, or `OMO_ULW_LOOP_SESSION_ID` in the environment also resolve it). The CLI refuses with `ULW_LOOP_SESSION_SCOPE_REQUIRED` when neither is present instead of touching the shared `.omo/ulw-loop` root, because eval kernels, subprocesses, and hooks do not inherit the session env and every session in the directory would otherwise read and overwrite the same plan. Mutations are serialized across processes by `.omo/ulw-loop/<id>/.state.lock`, so parallel `record-evidence` calls from several workers are safe; `ULW_LOOP_LOCK_TIMEOUT` means another live process held the state for more than 10s — retry, never delete the lock while that process is alive.
118
+ If this session's aggregate is already complete, do not steer or force the
119
+ completed state for unrelated new work. Start a fresh run with
120
+ `omo-agent-toolkit ulw-loop create-goals --session-id <new-id> ...` and keep passing
121
+ that id on every later call; the Stop hook auto-resume follows only the
122
+ Codex session's own id, so a run under a custom id is resumed by hand. Use `--force`
120
123
  only when deliberately overwriting completed evidence.
121
124
  Write state through the CLI path. Do not hand-edit state files.
122
125
 
@@ -12,7 +12,8 @@ import {
12
12
  steer,
13
13
  } from "./cli-subcommands.js";
14
14
  import { resolveUlwLoopSessionIdFromEnv, type UlwLoopScope } from "./paths.js";
15
- import { sessionIdRequiredMessage } from "./plan-missing-recovery.js";
15
+ import { listUlwLoopSessionIds } from "./plan-io.js";
16
+ import { sessionIdRequiredMessage, sessionScopeRequiredMessage } from "./plan-missing-recovery.js";
16
17
  import { UlwLoopError } from "./types.js";
17
18
 
18
19
  export const ULW_LOOP_SUBCOMMANDS = [
@@ -41,7 +42,6 @@ export async function ulwLoopCommand(argv: readonly string[]): Promise<number> {
41
42
  const repoRoot = process.cwd();
42
43
  const json = hasFlag(rest, "--json");
43
44
  try {
44
- const scope = commandScope(rest);
45
45
  if (!isUlwLoopSubcommand(command)) {
46
46
  if (json) {
47
47
  printJsonError(
@@ -58,10 +58,12 @@ export async function ulwLoopCommand(argv: readonly string[]): Promise<number> {
58
58
  process.stdout.write(`${subcommandHelp(command)}\n`);
59
59
  return 0;
60
60
  }
61
+ if (command === "help") {
62
+ process.stdout.write(`${ULW_LOOP_HELP}\n`);
63
+ return 0;
64
+ }
65
+ const scope = commandScope(repoRoot, rest);
61
66
  switch (command) {
62
- case "help":
63
- process.stdout.write(`${ULW_LOOP_HELP}\n`);
64
- return 0;
65
67
  case "create-goals":
66
68
  return await createGoals(repoRoot, rest, json, scope);
67
69
  case "status":
@@ -105,7 +107,10 @@ function sessionIdFlagPresent(argv: readonly string[]): boolean {
105
107
  return hasFlag(argv, SESSION_ID_FLAG) || argv.some((arg) => arg.startsWith(`${SESSION_ID_FLAG}=`));
106
108
  }
107
109
 
108
- function commandScope(argv: readonly string[]): UlwLoopScope | undefined {
110
+ // Every state subcommand runs against exactly one session directory. Without a flag or
111
+ // a session env there is no owner to resolve, so the command refuses instead of
112
+ // falling back to the repo-global root that every session in the cwd would share.
113
+ function commandScope(repoRoot: string, argv: readonly string[]): UlwLoopScope {
109
114
  if (sessionIdFlagPresent(argv)) {
110
115
  const sessionId = readValue(argv, SESSION_ID_FLAG)?.trim();
111
116
  if (!sessionId) {
@@ -116,5 +121,11 @@ function commandScope(argv: readonly string[]): UlwLoopScope | undefined {
116
121
  return { sessionId };
117
122
  }
118
123
  const sessionId = resolveUlwLoopSessionIdFromEnv();
119
- return sessionId === null ? undefined : { sessionId };
124
+ if (sessionId !== null) return { sessionId };
125
+ const existingSessionIds = listUlwLoopSessionIds(repoRoot);
126
+ throw new UlwLoopError(
127
+ sessionScopeRequiredMessage(SESSION_ID_FLAG, existingSessionIds),
128
+ "ULW_LOOP_SESSION_SCOPE_REQUIRED",
129
+ { details: { flag: SESSION_ID_FLAG, existingSessionIds } },
130
+ );
120
131
  }
@@ -16,7 +16,7 @@ export const ULW_LOOP_HELP = `Usage:
16
16
  omo-agent-toolkit ulw-loop add-goal --title "..." --objective "..." [--json]
17
17
  omo-agent-toolkit ulw-loop record-review-blockers --goal-id <id> --title "..." --objective "..." --evidence "..." --codex-goal-json <...> [--json]
18
18
 
19
- All subcommands accept [--session-id <id>] to isolate state under .omo/ulw-loop/<id>/; without it, Codex session env is used when present.
19
+ Every state subcommand needs a session scope: [--session-id <id>] or the session env (OMO_ULW_LOOP_SESSION_ID / CODEX_SESSION_ID / CODEX_THREAD_ID / PI_SESSION_ID); state lives under .omo/ulw-loop/<id>/ and the unscoped root is never used implicitly.
20
20
  Every subcommand accepts --help | -h to print its own usage line.`;
21
21
 
22
22
  export function subcommandHelp(subcommand: string): string {
@@ -2,6 +2,7 @@ export const ULW_LOOP_DIR = ".omo/ulw-loop";
2
2
  export const ULW_LOOP_BRIEF = "brief.md";
3
3
  export const ULW_LOOP_GOALS = "goals.json";
4
4
  export const ULW_LOOP_LEDGER = "ledger.jsonl";
5
+ export const ULW_LOOP_STATE_LOCK = ".state.lock";
5
6
 
6
7
  export type UlwLoopStatus =
7
8
  | "pending"
@@ -1,5 +1,12 @@
1
1
  import { isAbsolute, join, relative, sep } from "node:path";
2
- import { ULW_LOOP_BRIEF, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER } from "./types.js";
2
+ import {
3
+ ULW_LOOP_BRIEF,
4
+ ULW_LOOP_DIR,
5
+ ULW_LOOP_GOALS,
6
+ ULW_LOOP_LEDGER,
7
+ ULW_LOOP_STATE_LOCK,
8
+ UlwLoopError,
9
+ } from "./types.js";
3
10
 
4
11
  export interface UlwLoopScope {
5
12
  readonly sessionId?: string | null;
@@ -63,6 +70,12 @@ export function ulwLoopLedgerPath(repoRoot: string, scope?: UlwLoopScope): strin
63
70
  return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_LEDGER);
64
71
  }
65
72
 
73
+ // One lock per state directory covers goals.json, ledger.jsonl, and the hook
74
+ // counters beside them; the CLI mutations and the Codex hooks all take it.
75
+ export function ulwLoopStateLockPath(repoRoot: string, scope?: UlwLoopScope): string {
76
+ return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_STATE_LOCK);
77
+ }
78
+
66
79
  export function repoRelative(absolutePath: string, repoRoot: string): string {
67
80
  const slashPrefix = `${repoRoot}/`;
68
81
  const backslashPrefix = `${repoRoot}\\`;
@@ -73,9 +86,17 @@ export function repoRelative(absolutePath: string, repoRoot: string): string {
73
86
  }
74
87
 
75
88
  // Both the status --json emitter and the checkpoint enforcement resolve the attempt dir through
76
- // this function; a second resolution path would let the gate reject its own advertised directory.
89
+ // this function from the scope alone; a second resolution path (env, a literal placeholder)
90
+ // would let the gate reject its own advertised directory.
77
91
  export function ulwLoopAttemptEvidenceDir(goalId: string, attempt: number, scope?: UlwLoopScope): string {
78
- const sessionId = normalizeUlwLoopSessionId(scope?.sessionId) ?? resolveUlwLoopSessionIdFromEnv() ?? "session";
92
+ const sessionId = normalizeUlwLoopSessionId(scope?.sessionId);
93
+ if (sessionId === null) {
94
+ throw new UlwLoopError(
95
+ `Evidence for ${goalId} attempt ${attempt} needs a session scope; pass --session-id <id> so the attempt directory lives under .omo/evidence/ulw/<id>/.`,
96
+ "ULW_LOOP_SESSION_SCOPE_REQUIRED",
97
+ { details: { goalId, attempt } },
98
+ );
99
+ }
79
100
  return `.omo/evidence/ulw/${sessionId}/${goalId}/a${attempt}`;
80
101
  }
81
102
 
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { createReadStream, readdirSync } from "node:fs";
2
3
  import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
4
  import { createInterface } from "node:readline";
@@ -10,14 +11,20 @@ import {
10
11
  ulwLoopGoalsPath,
11
12
  ulwLoopLedgerPath,
12
13
  ulwLoopRelativeDir,
14
+ ulwLoopStateLockPath,
13
15
  } from "./paths.js";
14
16
  import { planMissingRecovery } from "./plan-missing-recovery.js";
17
+ import { withStateLock } from "./state-lock.js";
15
18
  import type { UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js";
16
19
  import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js";
17
20
 
18
21
  const LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `;
19
22
  const LEGACY_OBJECTIVE = `Complete all ulw-loop stories listed in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}. Use ${ULW_LOOP_DIR}/${ULW_LOOP_LEDGER} as the durable audit trail.`;
20
23
  const locks = new Map<string, Promise<undefined>>();
24
+ // Tracks which state dirs the CURRENT async continuation holds, so a read nested
25
+ // inside a locked mutation can tell itself apart from an unlocked read elsewhere
26
+ // in the same process.
27
+ const heldLocks = new AsyncLocalStorage<ReadonlySet<string>>();
21
28
 
22
29
  function hasCode(error: unknown, code: string): boolean {
23
30
  return error instanceof Error && "code" in error && error.code === code;
@@ -51,8 +58,13 @@ export async function withUlwLoopMutationLock<T>(
51
58
  const fn = typeof scopeOrFn === "function" ? scopeOrFn : maybeFn;
52
59
  if (fn === undefined) throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING");
53
60
  const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`;
61
+ const lockPath = ulwLoopStateLockPath(repoRoot, scope);
62
+ // The promise chain orders callers inside this process; the file lock is what
63
+ // excludes every other process (each CLI invocation) touching the same state dir.
64
+ const locked = (): Promise<T> =>
65
+ withStateLock(lockPath, () => heldLocks.run(new Set([...(heldLocks.getStore() ?? []), lockKey]), fn));
54
66
  const prior = locks.get(lockKey) ?? Promise.resolve(undefined);
55
- const run = prior.then(fn, fn);
67
+ const run = prior.then(locked, locked);
56
68
  // The stored gate resolves to undefined so the map never retains fn's result
57
69
  // (plans/audits), and it removes itself once no newer waiter replaced it —
58
70
  // otherwise a long-lived host leaks one entry per (repo, scope) forever.
@@ -74,7 +86,7 @@ export async function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): P
74
86
  raw = await readFile(path, "utf8");
75
87
  } catch (error) {
76
88
  if (!hasCode(error, "ENOENT")) throw error;
77
- const recovery = planMissingRecovery(readSessionDirs(repoRoot));
89
+ const recovery = planMissingRecovery(listUlwLoopSessionIds(repoRoot));
78
90
  throw new UlwLoopError(
79
91
  `No ulw-loop plan found at ${repoRelative(path, repoRoot)}.\n${recovery.message}`,
80
92
  "ULW_LOOP_PLAN_MISSING",
@@ -90,6 +102,14 @@ export async function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): P
90
102
  (parsed.codexGoalMode ?? "per_story") === "aggregate" &&
91
103
  isLegacyEnumeratedAggregateObjective(previousObjective)
92
104
  ) {
105
+ if (!(heldLocks.getStore()?.has(`${repoRoot}\0${ulwLoopRelativeDir(scope)}`) ?? false)) {
106
+ // A read path (status/criteria) must not mutate state: mutating here runs
107
+ // unlocked and a second reader could write a partially-migrated plan.
108
+ throw new UlwLoopError(
109
+ `The ulw-loop plan at ${repoRelative(path, repoRoot)} carries a legacy enumerated aggregate objective that must be migrated before reads continue. Run any state-mutating ulw-loop command once (e.g. \`record-evidence\`, \`steer\`, \`checkpoint\`) to migrate it under the state lock, then retry.`,
110
+ "ULW_LOOP_MIGRATION_REQUIRED",
111
+ );
112
+ }
93
113
  const now = iso();
94
114
  parsed.codexObjective = aggregateCodexObjectiveForScope(scope);
95
115
  parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])];
@@ -110,9 +130,9 @@ export async function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): P
110
130
  return parsed;
111
131
  }
112
132
 
113
- // Session dirs are the only recovery hint that matters when a plan is missing: the
114
- // caller is almost always scoped to a session whose sibling actually holds the plan.
115
- function readSessionDirs(repoRoot: string): readonly string[] {
133
+ // Session dirs are the only recovery hint that matters when a plan or a scope is
134
+ // missing: the caller is almost always meant to target one of these siblings.
135
+ export function listUlwLoopSessionIds(repoRoot: string): readonly string[] {
116
136
  try {
117
137
  return readdirSync(ulwLoopDir(repoRoot), { withFileTypes: true })
118
138
  .filter((entry) => entry.isDirectory())
@@ -18,6 +18,17 @@ export function planMissingRecovery(existingSessionIds: readonly string[]): Plan
18
18
  return { message: lines.join("\n"), details: { existingSessionIds } };
19
19
  }
20
20
 
21
+ export function sessionScopeRequiredMessage(flag: string, existingSessionIds: readonly string[]): string {
22
+ const lines = [
23
+ "No ulw-loop session scope: neither the session env (OMO_ULW_LOOP_SESSION_ID / CODEX_SESSION_ID / CODEX_THREAD_ID / PI_SESSION_ID) nor the flag names this run, and the shared .omo/ulw-loop root is never used implicitly because every session in this directory would read and overwrite it.",
24
+ `Recovery: pass the scope explicitly: \`${flag} <id>\` (subprocess, eval, and hook contexts do not inherit the session env).`,
25
+ ];
26
+ if (existingSessionIds.length > 0) {
27
+ lines.push(`Existing ulw-loop session ids under .omo/ulw-loop/: ${existingSessionIds.join(", ")}.`);
28
+ }
29
+ return lines.join("\n");
30
+ }
31
+
21
32
  export function sessionIdRequiredMessage(flag: string): string {
22
33
  return [
23
34
  `${flag} requires a non-empty value.`,
@@ -1,21 +1,12 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- openSync,
6
- readdirSync,
7
- readFileSync,
8
- renameSync,
9
- statSync,
10
- unlinkSync,
11
- writeFileSync,
12
- } from "node:fs";
2
+ import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
13
3
  import { dirname, join } from "node:path";
14
4
 
15
5
  import type { PreToolUsePayload } from "./codex-hook.js";
16
6
  import { parsePreToolUsePayload } from "./codex-hook.js";
17
7
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
18
- import { ulwLoopAttemptEvidenceDir, ulwLoopDir } from "./paths.js";
8
+ import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
9
+ import { isStateLockTimeout, type StateLockOptions, withStateLockSync } from "./state-lock.js";
19
10
  import {
20
11
  GATE_REVIEWER_AGENT_NAMES,
21
12
  REVIEWER_ROLES_BY_SURFACE,
@@ -38,11 +29,32 @@ const REVIEW_AGENT_TYPES = [
38
29
  ] as const;
39
30
  const REVIEW_AGENT_TYPE_SET = new Set<string>(REVIEW_AGENT_TYPES);
40
31
 
41
- export function applySpawnGuards(payload: PreToolUsePayload): string {
32
+ export interface SpawnGuardOptions {
33
+ readonly lockTimeoutMs?: number;
34
+ }
35
+
36
+ export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
42
37
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
43
- const stateDir = ulwLoopDir(payload.cwd, { sessionId: payload.session_id });
38
+ const scope = { sessionId: payload.session_id } as const;
39
+ const stateDir = ulwLoopDir(payload.cwd, scope);
44
40
  const plan = readPlan(join(stateDir, "goals.json"));
45
41
  if (plan === null) return "";
42
+ const lockOptions: StateLockOptions =
43
+ options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs };
44
+ try {
45
+ return withStateLockSync(
46
+ ulwLoopStateLockPath(payload.cwd, scope),
47
+ () => evaluateGuards(payload, plan, stateDir),
48
+ lockOptions,
49
+ );
50
+ } catch (error) {
51
+ if (isStateLockTimeout(error))
52
+ return deny(`ulw-loop spawn guard could not take the session state lock: ${error.message}`);
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ function evaluateGuards(payload: PreToolUsePayload, plan: UlwLoopPlan, stateDir: string): string {
46
58
  const fanOutPeek = peekFanOutBudget(stateDir);
47
59
  if (fanOutPeek !== null) return deny(fanOutPeek);
48
60
  const missingArtifact = missingGateArtifact(payload, plan);
@@ -99,18 +111,15 @@ function consumeReviewSpawnBudget(payload: PreToolUsePayload, plan: UlwLoopPlan,
99
111
  plan.goals.find((candidate) => isFinalRunCompletionCandidate(plan, candidate));
100
112
  if (goal === undefined) return null;
101
113
  const counterPath = join(stateDir, "review-spawn-counts.json");
102
- const lockPath = `${counterPath}.lock`;
103
114
  const limit = reviewSpawnLimit();
104
- return withExclusiveLock(lockPath, () => {
105
- const counts = readCounts(counterPath);
106
- const key = `${agentType}:${goal.id}:a${goal.attempt}`;
107
- const count = (counts[key] ?? 0) + 1;
108
- if (count > limit)
109
- return `ulw-loop reviewer no-progress cap reached (${agentType} ${count}/${limit}) for ${goal.id} attempt ${goal.attempt}. Consolidate existing review findings, or checkpoint and start a new attempt after concrete progress.`;
110
- counts[key] = count;
111
- atomicWriteJson(counterPath, counts);
112
- return null;
113
- });
115
+ const counts = readCounts(counterPath);
116
+ const key = `${agentType}:${goal.id}:a${goal.attempt}`;
117
+ const count = (counts[key] ?? 0) + 1;
118
+ if (count > limit)
119
+ return `ulw-loop reviewer no-progress cap reached (${agentType} ${count}/${limit}) for ${goal.id} attempt ${goal.attempt}. Consolidate existing review findings, or checkpoint and start a new attempt after concrete progress.`;
120
+ counts[key] = count;
121
+ atomicWriteJson(counterPath, counts);
122
+ return null;
114
123
  }
115
124
 
116
125
  function missingGateArtifact(payload: PreToolUsePayload, plan: UlwLoopPlan): string | null {
@@ -190,45 +199,6 @@ function activeSurfaceReviewerAlias(reviewer: string): string {
190
199
  return reviewer;
191
200
  }
192
201
 
193
- function withExclusiveLock<T>(lockPath: string, fn: () => T): T {
194
- mkdirSync(dirname(lockPath), { recursive: true });
195
- const maxAttempts = 10;
196
- const baseDelayMs = 10;
197
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
198
- let fd: number | null = null;
199
- try {
200
- fd = openSync(lockPath, "wx");
201
- writeFileSync(fd, process.pid.toString());
202
- try {
203
- return fn();
204
- } finally {
205
- try {
206
- unlinkSync(lockPath);
207
- } catch {
208
- /* empty */
209
- }
210
- }
211
- } catch (error) {
212
- if (fd !== null) {
213
- try {
214
- unlinkSync(lockPath);
215
- } catch {
216
- /* empty */
217
- }
218
- }
219
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
220
- return fn();
221
- }
222
- const delayMs = baseDelayMs * 2 ** attempt + Math.random() * baseDelayMs;
223
- const deadline = Date.now() + delayMs;
224
- while (Date.now() < deadline) {
225
- /* spin */
226
- }
227
- }
228
- }
229
- return fn();
230
- }
231
-
232
202
  function atomicWriteJson(targetPath: string, data: unknown): void {
233
203
  const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
234
204
  writeFileSync(tmp, JSON.stringify(data));
@@ -0,0 +1,239 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+
6
+ import { UlwLoopError } from "./types.js";
7
+
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
+
21
+ export const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
22
+
23
+ export interface StateLockOptions {
24
+ readonly timeoutMs?: number;
25
+ readonly staleMs?: number;
26
+ }
27
+
28
+ interface LockRecord {
29
+ readonly pid: number;
30
+ readonly createdAt: string;
31
+ readonly token: string;
32
+ }
33
+
34
+ interface LockSnapshot {
35
+ readonly raw: string;
36
+ readonly record: LockRecord | null;
37
+ readonly ageMs: number;
38
+ }
39
+
40
+ type AttemptOutcome = { readonly kind: "acquired"; readonly token: string } | { readonly kind: "retry" | "wait" };
41
+
42
+ const DEFAULT_TIMEOUT_MS = 10_000;
43
+ const DEFAULT_STALE_MS = 60_000;
44
+ const MIN_DELAY_MS = 5;
45
+ const MAX_DELAY_MS = 100;
46
+ const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
47
+
48
+ export async function withStateLock<T>(
49
+ lockPath: string,
50
+ fn: () => Promise<T>,
51
+ options: StateLockOptions = {},
52
+ ): Promise<T> {
53
+ const token = await acquireAsync(lockPath, options);
54
+ try {
55
+ return await fn();
56
+ } finally {
57
+ release(lockPath, token);
58
+ }
59
+ }
60
+
61
+ export function withStateLockSync<T>(lockPath: string, fn: () => T, options: StateLockOptions = {}): T {
62
+ const token = acquireSync(lockPath, options);
63
+ try {
64
+ return fn();
65
+ } finally {
66
+ release(lockPath, token);
67
+ }
68
+ }
69
+
70
+ export function isStateLockTimeout(error: unknown): error is UlwLoopError {
71
+ return error instanceof UlwLoopError && error.code === ULW_LOOP_LOCK_TIMEOUT_CODE;
72
+ }
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;
77
+ mkdirSync(dirname(lockPath), { recursive: true });
78
+ for (let attempt = 0; ; ) {
79
+ const outcome = attemptOnce(lockPath, staleMs);
80
+ if (outcome.kind === "acquired") return outcome.token;
81
+ if (outcome.kind === "retry") continue;
82
+ if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
83
+ await sleep(backoffMs(attempt));
84
+ attempt += 1;
85
+ }
86
+ }
87
+
88
+ function acquireSync(lockPath: string, options: StateLockOptions): string {
89
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
90
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
91
+ mkdirSync(dirname(lockPath), { recursive: true });
92
+ for (let attempt = 0; ; ) {
93
+ const outcome = attemptOnce(lockPath, staleMs);
94
+ if (outcome.kind === "acquired") return outcome.token;
95
+ if (outcome.kind === "retry") continue;
96
+ if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
97
+ Atomics.wait(SLEEP_CELL, 0, 0, backoffMs(attempt));
98
+ attempt += 1;
99
+ }
100
+ }
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 {
104
+ try {
105
+ const token = tryCreate(lockPath);
106
+ if (token !== null) return { kind: "acquired", token };
107
+ const snapshot = readSnapshot(lockPath);
108
+ if (snapshot === null) return { kind: "retry" };
109
+ if (isStale(snapshot, staleMs) && reclaim(lockPath, snapshot.raw)) return { kind: "retry" };
110
+ return { kind: "wait" };
111
+ } catch (error) {
112
+ if (hasCode(error, "EINTR")) return { kind: "wait" };
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ function tryCreate(lockPath: string): string | null {
118
+ let fd: number;
119
+ try {
120
+ fd = openSync(lockPath, "wx");
121
+ } catch (error) {
122
+ if (hasCode(error, "EEXIST")) return null;
123
+ throw error;
124
+ }
125
+ const record: LockRecord = { pid: process.pid, createdAt: new Date().toISOString(), token: randomUUID() };
126
+ try {
127
+ writeSync(fd, JSON.stringify(record));
128
+ } catch (error) {
129
+ 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
+ try {
133
+ unlinkSync(lockPath);
134
+ } catch {
135
+ // another process already reclaimed the partial file; leave it alone
136
+ }
137
+ throw error;
138
+ }
139
+ closeSync(fd);
140
+ return record.token;
141
+ }
142
+
143
+ function readSnapshot(lockPath: string): LockSnapshot | null {
144
+ try {
145
+ const raw = readFileSync(lockPath, "utf8");
146
+ const ageMs = Date.now() - statSync(lockPath).mtimeMs;
147
+ return { raw, record: parseRecord(raw), ageMs };
148
+ } catch (error) {
149
+ if (hasCode(error, "ENOENT")) return null;
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ function parseRecord(raw: string): LockRecord | null {
155
+ 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 };
165
+ } catch (error) {
166
+ if (error instanceof SyntaxError) return null;
167
+ throw error;
168
+ }
169
+ }
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
+ function isProcessAlive(pid: number): boolean {
180
+ try {
181
+ process.kill(pid, 0);
182
+ return true;
183
+ } catch (error) {
184
+ if (hasCode(error, "ESRCH")) return false;
185
+ if (hasCode(error, "EPERM")) return true;
186
+ throw error;
187
+ }
188
+ }
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
+ function reclaim(lockPath: string, expectedRaw: string): boolean {
193
+ const current = readSnapshot(lockPath);
194
+ if (current === null) return true;
195
+ if (current.raw !== expectedRaw) return false;
196
+ try {
197
+ unlinkSync(lockPath);
198
+ } catch (error) {
199
+ if (!hasCode(error, "ENOENT")) throw error;
200
+ }
201
+ return true;
202
+ }
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
+ function release(lockPath: string, token: string): void {
207
+ const current = readSnapshot(lockPath);
208
+ if (current === null || current.record?.token !== token) return;
209
+ try {
210
+ unlinkSync(lockPath);
211
+ } catch (error) {
212
+ if (!hasCode(error, "ENOENT")) throw error;
213
+ }
214
+ }
215
+
216
+ 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;
219
+ }
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}`;
224
+ 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.`,
226
+ ULW_LOOP_LOCK_TIMEOUT_CODE,
227
+ {
228
+ details: {
229
+ lockPath,
230
+ timeoutMs,
231
+ ...(holder === undefined || holder === null ? {} : { holderPid: holder.pid }),
232
+ },
233
+ },
234
+ );
235
+ }
236
+
237
+ function hasCode(error: unknown, code: string): boolean {
238
+ return error instanceof Error && "code" in error && error.code === code;
239
+ }
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve, sep } from "node:path";
3
3
 
4
- import { normalizeUlwLoopSessionId, ulwLoopDir } from "./paths.js";
4
+ import { normalizeUlwLoopSessionId, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
5
+ import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
5
6
  import type { UlwLoopItem, UlwLoopPlan } from "./types.js";
6
7
 
7
8
  // Turn-death recovery only: Codex emits Stop when a turn ends, so a run that
@@ -34,12 +35,13 @@ export function runStopResumeHook(input: unknown): string {
34
35
  if (payload === null || payload.stop_hook_active) return "";
35
36
  if (transcriptShowsContextPressure(payload.transcript_path)) return "";
36
37
  if (boulderContinuationWillFire(payload.cwd, payload.session_id)) return "";
37
- const stateDir = ulwLoopDir(payload.cwd, { sessionId: payload.session_id });
38
+ const scope = { sessionId: payload.session_id } as const;
39
+ const stateDir = ulwLoopDir(payload.cwd, scope);
38
40
  const plan = readPlan(join(stateDir, "goals.json"));
39
41
  if (plan === null || plan.aggregateCompletion?.status === "complete") return "";
40
42
  const goal = resumableGoal(plan);
41
43
  if (goal === undefined) return "";
42
- if (!consumeResumeBudget(stateDir, goal.id)) return "";
44
+ if (!consumeResumeBudgetLocked(ulwLoopStateLockPath(payload.cwd, scope), stateDir, goal.id)) return "";
43
45
  const output: { decision: "block"; reason: string } = {
44
46
  decision: "block",
45
47
  reason: renderResumeDirective(plan, goal, payload.session_id),
@@ -68,6 +70,17 @@ function isResumableStatus(status: UlwLoopItem["status"]): boolean {
68
70
  return status === "pending" || status === "in_progress";
69
71
  }
70
72
 
73
+ // A resume is a budgeted side effect; when the session lock cannot be taken the
74
+ // hook stays silent (fail closed) instead of charging the counter unlocked.
75
+ function consumeResumeBudgetLocked(lockPath: string, stateDir: string, goalId: string): boolean {
76
+ try {
77
+ return withStateLockSync(lockPath, () => consumeResumeBudget(stateDir, goalId));
78
+ } catch (error) {
79
+ if (isStateLockTimeout(error)) return false;
80
+ throw error;
81
+ }
82
+ }
83
+
71
84
  // Two-strike cap keyed on ledger movement: an unchanged ledger.jsonl line
72
85
  // count across resumes means the loop is not progressing. The stuck marker is
73
86
  // a separate file — a ledger append would change the count and self-reset.