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
@@ -2,6 +2,7 @@ export declare const ULW_LOOP_DIR = ".omo/ulw-loop";
2
2
  export declare const ULW_LOOP_BRIEF = "brief.md";
3
3
  export declare const ULW_LOOP_GOALS = "goals.json";
4
4
  export declare const ULW_LOOP_LEDGER = "ledger.jsonl";
5
+ export declare const ULW_LOOP_STATE_LOCK = ".state.lock";
5
6
  export type UlwLoopStatus = "pending" | "in_progress" | "complete" | "failed" | "blocked" | "review_blocked" | "needs_user_decision";
6
7
  export type UlwLoopCodexGoalMode = "aggregate" | "per_story";
7
8
  export type UlwLoopSteeringStatus = "superseded" | "blocked";
@@ -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
  export const ULW_LOOP_STEERING_MUTATION_KINDS = [
6
7
  "add_subgoal",
7
8
  "split_subgoal",
@@ -12,6 +12,7 @@ export declare function ulwLoopLedgerRelativePath(scope?: UlwLoopScope): string;
12
12
  export declare function ulwLoopBriefPath(repoRoot: string, scope?: UlwLoopScope): string;
13
13
  export declare function ulwLoopGoalsPath(repoRoot: string, scope?: UlwLoopScope): string;
14
14
  export declare function ulwLoopLedgerPath(repoRoot: string, scope?: UlwLoopScope): string;
15
+ export declare function ulwLoopStateLockPath(repoRoot: string, scope?: UlwLoopScope): string;
15
16
  export declare function repoRelative(absolutePath: string, repoRoot: string): string;
16
17
  export declare function ulwLoopAttemptEvidenceDir(goalId: string, attempt: number, scope?: UlwLoopScope): string;
17
18
  interface AttemptPathApi {
@@ -1,5 +1,5 @@
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 { ULW_LOOP_BRIEF, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, ULW_LOOP_STATE_LOCK, UlwLoopError, } from "./types.js";
3
3
  const SESSION_ENV_KEYS = ["OMO_ULW_LOOP_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"];
4
4
  export function normalizeUlwLoopSessionId(sessionId) {
5
5
  const trimmed = sessionId?.trim();
@@ -48,6 +48,11 @@ export function ulwLoopGoalsPath(repoRoot, scope) {
48
48
  export function ulwLoopLedgerPath(repoRoot, scope) {
49
49
  return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_LEDGER);
50
50
  }
51
+ // One lock per state directory covers goals.json, ledger.jsonl, and the hook
52
+ // counters beside them; the CLI mutations and the Codex hooks all take it.
53
+ export function ulwLoopStateLockPath(repoRoot, scope) {
54
+ return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_STATE_LOCK);
55
+ }
51
56
  export function repoRelative(absolutePath, repoRoot) {
52
57
  const slashPrefix = `${repoRoot}/`;
53
58
  const backslashPrefix = `${repoRoot}\\`;
@@ -58,9 +63,13 @@ export function repoRelative(absolutePath, repoRoot) {
58
63
  return absolutePath.split("\\").join("/");
59
64
  }
60
65
  // Both the status --json emitter and the checkpoint enforcement resolve the attempt dir through
61
- // this function; a second resolution path would let the gate reject its own advertised directory.
66
+ // this function from the scope alone; a second resolution path (env, a literal placeholder)
67
+ // would let the gate reject its own advertised directory.
62
68
  export function ulwLoopAttemptEvidenceDir(goalId, attempt, scope) {
63
- const sessionId = normalizeUlwLoopSessionId(scope?.sessionId) ?? resolveUlwLoopSessionIdFromEnv() ?? "session";
69
+ const sessionId = normalizeUlwLoopSessionId(scope?.sessionId);
70
+ if (sessionId === null) {
71
+ throw new UlwLoopError(`Evidence for ${goalId} attempt ${attempt} needs a session scope; pass --session-id <id> so the attempt directory lives under .omo/evidence/ulw/<id>/.`, "ULW_LOOP_SESSION_SCOPE_REQUIRED", { details: { goalId, attempt } });
72
+ }
64
73
  return `.omo/evidence/ulw/${sessionId}/${goalId}/a${attempt}`;
65
74
  }
66
75
  const PLATFORM_PATH_API = { relative, isAbsolute, sep };
@@ -3,6 +3,7 @@ import type { UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js";
3
3
  export declare function withUlwLoopMutationLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T>;
4
4
  export declare function withUlwLoopMutationLock<T>(repoRoot: string, scope: UlwLoopScope | undefined, fn: () => Promise<T>): Promise<T>;
5
5
  export declare function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): Promise<UlwLoopPlan>;
6
+ export declare function listUlwLoopSessionIds(repoRoot: string): readonly string[];
6
7
  export declare function writePlan(repoRoot: string, plan: UlwLoopPlan, scope?: UlwLoopScope): Promise<void>;
7
8
  export declare function appendLedger(repoRoot: string, entry: UlwLoopLedgerEntry, scope?: UlwLoopScope): Promise<void>;
8
9
  export declare function appendLedgerEntries(repoRoot: string, entries: readonly UlwLoopLedgerEntry[], scope?: UlwLoopScope): Promise<void>;
@@ -1,13 +1,19 @@
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";
4
5
  import { aggregateCodexObjectiveForScope } from "./goal-status.js";
5
- import { repoRelative, ulwLoopDir, ulwLoopGoalsPath, ulwLoopLedgerPath, ulwLoopRelativeDir, } from "./paths.js";
6
+ import { repoRelative, ulwLoopDir, ulwLoopGoalsPath, ulwLoopLedgerPath, ulwLoopRelativeDir, ulwLoopStateLockPath, } from "./paths.js";
6
7
  import { planMissingRecovery } from "./plan-missing-recovery.js";
8
+ import { withStateLock } from "./state-lock.js";
7
9
  import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js";
8
10
  const LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `;
9
11
  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.`;
10
12
  const locks = new Map();
13
+ // Tracks which state dirs the CURRENT async continuation holds, so a read nested
14
+ // inside a locked mutation can tell itself apart from an unlocked read elsewhere
15
+ // in the same process.
16
+ const heldLocks = new AsyncLocalStorage();
11
17
  function hasCode(error, code) {
12
18
  return error instanceof Error && "code" in error && error.code === code;
13
19
  }
@@ -26,8 +32,12 @@ export async function withUlwLoopMutationLock(repoRoot, scopeOrFn, maybeFn) {
26
32
  if (fn === undefined)
27
33
  throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING");
28
34
  const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`;
35
+ const lockPath = ulwLoopStateLockPath(repoRoot, scope);
36
+ // The promise chain orders callers inside this process; the file lock is what
37
+ // excludes every other process (each CLI invocation) touching the same state dir.
38
+ const locked = () => withStateLock(lockPath, () => heldLocks.run(new Set([...(heldLocks.getStore() ?? []), lockKey]), fn));
29
39
  const prior = locks.get(lockKey) ?? Promise.resolve(undefined);
30
- const run = prior.then(fn, fn);
40
+ const run = prior.then(locked, locked);
31
41
  // The stored gate resolves to undefined so the map never retains fn's result
32
42
  // (plans/audits), and it removes itself once no newer waiter replaced it —
33
43
  // otherwise a long-lived host leaks one entry per (repo, scope) forever.
@@ -48,7 +58,7 @@ export async function readUlwLoopPlan(repoRoot, scope) {
48
58
  catch (error) {
49
59
  if (!hasCode(error, "ENOENT"))
50
60
  throw error;
51
- const recovery = planMissingRecovery(readSessionDirs(repoRoot));
61
+ const recovery = planMissingRecovery(listUlwLoopSessionIds(repoRoot));
52
62
  throw new UlwLoopError(`No ulw-loop plan found at ${repoRelative(path, repoRoot)}.\n${recovery.message}`, "ULW_LOOP_PLAN_MISSING", { cause: error, ...(recovery.details === undefined ? {} : { details: recovery.details }) });
53
63
  }
54
64
  const parsed = JSON.parse(raw);
@@ -58,6 +68,11 @@ export async function readUlwLoopPlan(repoRoot, scope) {
58
68
  const previousObjective = parsed.codexObjective;
59
69
  if ((parsed.codexGoalMode ?? "per_story") === "aggregate" &&
60
70
  isLegacyEnumeratedAggregateObjective(previousObjective)) {
71
+ if (!(heldLocks.getStore()?.has(`${repoRoot}\0${ulwLoopRelativeDir(scope)}`) ?? false)) {
72
+ // A read path (status/criteria) must not mutate state: mutating here runs
73
+ // unlocked and a second reader could write a partially-migrated plan.
74
+ throw new UlwLoopError(`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.`, "ULW_LOOP_MIGRATION_REQUIRED");
75
+ }
61
76
  const now = iso();
62
77
  parsed.codexObjective = aggregateCodexObjectiveForScope(scope);
63
78
  parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])];
@@ -73,9 +88,9 @@ export async function readUlwLoopPlan(repoRoot, scope) {
73
88
  }
74
89
  return parsed;
75
90
  }
76
- // Session dirs are the only recovery hint that matters when a plan is missing: the
77
- // caller is almost always scoped to a session whose sibling actually holds the plan.
78
- function readSessionDirs(repoRoot) {
91
+ // Session dirs are the only recovery hint that matters when a plan or a scope is
92
+ // missing: the caller is almost always meant to target one of these siblings.
93
+ export function listUlwLoopSessionIds(repoRoot) {
79
94
  try {
80
95
  return readdirSync(ulwLoopDir(repoRoot), { withFileTypes: true })
81
96
  .filter((entry) => entry.isDirectory())
@@ -10,4 +10,5 @@ export interface PlanMissingRecovery {
10
10
  * session id"; the recovery text has to answer both without the caller guessing.
11
11
  */
12
12
  export declare function planMissingRecovery(existingSessionIds: readonly string[]): PlanMissingRecovery;
13
+ export declare function sessionScopeRequiredMessage(flag: string, existingSessionIds: readonly string[]): string;
13
14
  export declare function sessionIdRequiredMessage(flag: string): string;
@@ -10,6 +10,16 @@ export function planMissingRecovery(existingSessionIds) {
10
10
  lines.push(`Existing ulw-loop session ids under .omo/ulw-loop/: ${existingSessionIds.join(", ")}. Re-run with \`--session-id <id>\` to target one of them.`);
11
11
  return { message: lines.join("\n"), details: { existingSessionIds } };
12
12
  }
13
+ export function sessionScopeRequiredMessage(flag, existingSessionIds) {
14
+ const lines = [
15
+ "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.",
16
+ `Recovery: pass the scope explicitly: \`${flag} <id>\` (subprocess, eval, and hook contexts do not inherit the session env).`,
17
+ ];
18
+ if (existingSessionIds.length > 0) {
19
+ lines.push(`Existing ulw-loop session ids under .omo/ulw-loop/: ${existingSessionIds.join(", ")}.`);
20
+ }
21
+ return lines.join("\n");
22
+ }
13
23
  export function sessionIdRequiredMessage(flag) {
14
24
  return [
15
25
  `${flag} requires a non-empty value.`,
@@ -1,3 +1,6 @@
1
1
  import type { PreToolUsePayload } from "./codex-hook.js";
2
- export declare function applySpawnGuards(payload: PreToolUsePayload): string;
2
+ export interface SpawnGuardOptions {
3
+ readonly lockTimeoutMs?: number;
4
+ }
5
+ export declare function applySpawnGuards(payload: PreToolUsePayload, options?: SpawnGuardOptions): string;
3
6
  export declare function runSpawnGuardCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise<void>;
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { parsePreToolUsePayload } from "./codex-hook.js";
5
5
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
6
- import { ulwLoopAttemptEvidenceDir, ulwLoopDir } from "./paths.js";
6
+ import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
7
+ import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
7
8
  import { GATE_REVIEWER_AGENT_NAMES, REVIEWER_ROLES_BY_SURFACE, resolveToolkitSurface, reviewerRolesFor, } from "./surface.js";
8
9
  // spawn_agent = v1; collaborationspawn_agent = the delimiter-free flattened v2
9
10
  // hook token from codex-rs hook_names.rs; collaboration.spawn_agent = the
@@ -18,13 +19,25 @@ const REVIEW_AGENT_TYPES = [
18
19
  ...Object.values(REVIEWER_ROLES_BY_SURFACE).map((roles) => roles.manualQa),
19
20
  ];
20
21
  const REVIEW_AGENT_TYPE_SET = new Set(REVIEW_AGENT_TYPES);
21
- export function applySpawnGuards(payload) {
22
+ export function applySpawnGuards(payload, options = {}) {
22
23
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name))
23
24
  return "";
24
- const stateDir = ulwLoopDir(payload.cwd, { sessionId: payload.session_id });
25
+ const scope = { sessionId: payload.session_id };
26
+ const stateDir = ulwLoopDir(payload.cwd, scope);
25
27
  const plan = readPlan(join(stateDir, "goals.json"));
26
28
  if (plan === null)
27
29
  return "";
30
+ const lockOptions = options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs };
31
+ try {
32
+ return withStateLockSync(ulwLoopStateLockPath(payload.cwd, scope), () => evaluateGuards(payload, plan, stateDir), lockOptions);
33
+ }
34
+ catch (error) {
35
+ if (isStateLockTimeout(error))
36
+ return deny(`ulw-loop spawn guard could not take the session state lock: ${error.message}`);
37
+ throw error;
38
+ }
39
+ }
40
+ function evaluateGuards(payload, plan, stateDir) {
28
41
  const fanOutPeek = peekFanOutBudget(stateDir);
29
42
  if (fanOutPeek !== null)
30
43
  return deny(fanOutPeek);
@@ -88,18 +101,15 @@ function consumeReviewSpawnBudget(payload, plan, stateDir) {
88
101
  if (goal === undefined)
89
102
  return null;
90
103
  const counterPath = join(stateDir, "review-spawn-counts.json");
91
- const lockPath = `${counterPath}.lock`;
92
104
  const limit = reviewSpawnLimit();
93
- return withExclusiveLock(lockPath, () => {
94
- const counts = readCounts(counterPath);
95
- const key = `${agentType}:${goal.id}:a${goal.attempt}`;
96
- const count = (counts[key] ?? 0) + 1;
97
- if (count > limit)
98
- 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.`;
99
- counts[key] = count;
100
- atomicWriteJson(counterPath, counts);
101
- return null;
102
- });
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;
103
113
  }
104
114
  function missingGateArtifact(payload, plan) {
105
115
  if (!isGateReviewerSpawn(payload.tool_input))
@@ -187,48 +197,6 @@ function activeSurfaceReviewerAlias(reviewer) {
187
197
  }
188
198
  return reviewer;
189
199
  }
190
- function withExclusiveLock(lockPath, fn) {
191
- mkdirSync(dirname(lockPath), { recursive: true });
192
- const maxAttempts = 10;
193
- const baseDelayMs = 10;
194
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
195
- let fd = null;
196
- try {
197
- fd = openSync(lockPath, "wx");
198
- writeFileSync(fd, process.pid.toString());
199
- try {
200
- return fn();
201
- }
202
- finally {
203
- try {
204
- unlinkSync(lockPath);
205
- }
206
- catch {
207
- /* empty */
208
- }
209
- }
210
- }
211
- catch (error) {
212
- if (fd !== null) {
213
- try {
214
- unlinkSync(lockPath);
215
- }
216
- catch {
217
- /* empty */
218
- }
219
- }
220
- if (error.code !== "EEXIST") {
221
- return fn();
222
- }
223
- const delayMs = baseDelayMs * 2 ** attempt + Math.random() * baseDelayMs;
224
- const deadline = Date.now() + delayMs;
225
- while (Date.now() < deadline) {
226
- /* spin */
227
- }
228
- }
229
- }
230
- return fn();
231
- }
232
200
  function atomicWriteJson(targetPath, data) {
233
201
  const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
234
202
  writeFileSync(tmp, JSON.stringify(data));
@@ -0,0 +1,9 @@
1
+ import { UlwLoopError } from "./types.js";
2
+ export declare const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
3
+ export interface StateLockOptions {
4
+ readonly timeoutMs?: number;
5
+ readonly staleMs?: number;
6
+ }
7
+ export declare function withStateLock<T>(lockPath: string, fn: () => Promise<T>, options?: StateLockOptions): Promise<T>;
8
+ export declare function withStateLockSync<T>(lockPath: string, fn: () => T, options?: StateLockOptions): T;
9
+ export declare function isStateLockTimeout(error: unknown): error is UlwLoopError;
@@ -0,0 +1,227 @@
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
+ import { UlwLoopError } from "./types.js";
6
+ // Cross-process exclusive lock for one ulw-loop state directory. Every CLI
7
+ // invocation is its own process, so the in-process promise chain in plan-io
8
+ // serializes nothing across them; this file-level lock is what makes the
9
+ // read-modify-write of goals.json (and the counters next to it) atomic.
10
+ //
11
+ // Protocol: the lock is a file created with O_EXCL whose body records the
12
+ // owner (pid + a per-acquisition token). A waiter reclaims it only when the
13
+ // owner is provably gone (pid dead) or the body never became a record within
14
+ // `staleMs` (a creator that died mid-write); a live owner is never reclaimed by
15
+ // age alone, because overlapping two bodies is exactly the lost update this lock
16
+ // exists to prevent. Waiters back off and fail closed at `timeoutMs`. Release
17
+ // unlinks only a lock that still carries the releaser's own token.
18
+ export const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
19
+ const DEFAULT_TIMEOUT_MS = 10_000;
20
+ const DEFAULT_STALE_MS = 60_000;
21
+ const MIN_DELAY_MS = 5;
22
+ const MAX_DELAY_MS = 100;
23
+ const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
24
+ export async function withStateLock(lockPath, fn, options = {}) {
25
+ const token = await acquireAsync(lockPath, options);
26
+ try {
27
+ return await fn();
28
+ }
29
+ finally {
30
+ release(lockPath, token);
31
+ }
32
+ }
33
+ export function withStateLockSync(lockPath, fn, options = {}) {
34
+ const token = acquireSync(lockPath, options);
35
+ try {
36
+ return fn();
37
+ }
38
+ finally {
39
+ release(lockPath, token);
40
+ }
41
+ }
42
+ export function isStateLockTimeout(error) {
43
+ return error instanceof UlwLoopError && error.code === ULW_LOOP_LOCK_TIMEOUT_CODE;
44
+ }
45
+ async function acquireAsync(lockPath, options) {
46
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
47
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
48
+ mkdirSync(dirname(lockPath), { recursive: true });
49
+ for (let attempt = 0;;) {
50
+ const outcome = attemptOnce(lockPath, staleMs);
51
+ if (outcome.kind === "acquired")
52
+ return outcome.token;
53
+ if (outcome.kind === "retry")
54
+ continue;
55
+ if (Date.now() >= deadline)
56
+ throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
57
+ await sleep(backoffMs(attempt));
58
+ attempt += 1;
59
+ }
60
+ }
61
+ function acquireSync(lockPath, options) {
62
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
63
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
64
+ mkdirSync(dirname(lockPath), { recursive: true });
65
+ for (let attempt = 0;;) {
66
+ const outcome = attemptOnce(lockPath, staleMs);
67
+ if (outcome.kind === "acquired")
68
+ return outcome.token;
69
+ if (outcome.kind === "retry")
70
+ continue;
71
+ if (Date.now() >= deadline)
72
+ throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
73
+ Atomics.wait(SLEEP_CELL, 0, 0, backoffMs(attempt));
74
+ attempt += 1;
75
+ }
76
+ }
77
+ // EINTR surfaces raw from macOS fs syscalls under some runtimes; it is a retry, never a verdict.
78
+ function attemptOnce(lockPath, staleMs) {
79
+ try {
80
+ const token = tryCreate(lockPath);
81
+ if (token !== null)
82
+ return { kind: "acquired", token };
83
+ const snapshot = readSnapshot(lockPath);
84
+ if (snapshot === null)
85
+ return { kind: "retry" };
86
+ if (isStale(snapshot, staleMs) && reclaim(lockPath, snapshot.raw))
87
+ return { kind: "retry" };
88
+ return { kind: "wait" };
89
+ }
90
+ catch (error) {
91
+ if (hasCode(error, "EINTR"))
92
+ return { kind: "wait" };
93
+ throw error;
94
+ }
95
+ }
96
+ function tryCreate(lockPath) {
97
+ let fd;
98
+ try {
99
+ fd = openSync(lockPath, "wx");
100
+ }
101
+ catch (error) {
102
+ if (hasCode(error, "EEXIST"))
103
+ return null;
104
+ throw error;
105
+ }
106
+ const record = { pid: process.pid, createdAt: new Date().toISOString(), token: randomUUID() };
107
+ try {
108
+ writeSync(fd, JSON.stringify(record));
109
+ }
110
+ catch (error) {
111
+ closeSync(fd);
112
+ // A failed write (e.g. EINTR) must not leave a partial lock the owner never recorded;
113
+ // otherwise later readers see a young ownerless lock and only age can retire it.
114
+ try {
115
+ unlinkSync(lockPath);
116
+ }
117
+ catch {
118
+ // another process already reclaimed the partial file; leave it alone
119
+ }
120
+ throw error;
121
+ }
122
+ closeSync(fd);
123
+ return record.token;
124
+ }
125
+ function readSnapshot(lockPath) {
126
+ try {
127
+ const raw = readFileSync(lockPath, "utf8");
128
+ const ageMs = Date.now() - statSync(lockPath).mtimeMs;
129
+ return { raw, record: parseRecord(raw), ageMs };
130
+ }
131
+ catch (error) {
132
+ if (hasCode(error, "ENOENT"))
133
+ return null;
134
+ throw error;
135
+ }
136
+ }
137
+ function parseRecord(raw) {
138
+ try {
139
+ const parsed = JSON.parse(raw);
140
+ if (typeof parsed !== "object" || parsed === null)
141
+ return null;
142
+ const record = parsed;
143
+ const pid = record["pid"];
144
+ const createdAt = record["createdAt"];
145
+ const token = record["token"];
146
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || typeof createdAt !== "string")
147
+ return null;
148
+ if (typeof token !== "string" || token.length === 0)
149
+ return null;
150
+ return { pid, createdAt, token };
151
+ }
152
+ catch (error) {
153
+ if (error instanceof SyntaxError)
154
+ return null;
155
+ throw error;
156
+ }
157
+ }
158
+ // A body that is not a record is a lock mid-write (or a foreign/legacy file); only
159
+ // age retires it. A parsable record is stale only when its owner is dead: a live
160
+ // owner running past staleMs is slow, not gone, and the waiter fails closed instead.
161
+ function isStale(snapshot, staleMs) {
162
+ if (snapshot.record === null)
163
+ return snapshot.ageMs > staleMs;
164
+ return !isProcessAlive(snapshot.record.pid);
165
+ }
166
+ function isProcessAlive(pid) {
167
+ try {
168
+ process.kill(pid, 0);
169
+ return true;
170
+ }
171
+ catch (error) {
172
+ if (hasCode(error, "ESRCH"))
173
+ return false;
174
+ if (hasCode(error, "EPERM"))
175
+ return true;
176
+ throw error;
177
+ }
178
+ }
179
+ // Re-read right before unlinking so a lock that changed hands since the stale
180
+ // verdict (a sibling waiter reclaimed and re-acquired it) is left alone.
181
+ function reclaim(lockPath, expectedRaw) {
182
+ const current = readSnapshot(lockPath);
183
+ if (current === null)
184
+ return true;
185
+ if (current.raw !== expectedRaw)
186
+ return false;
187
+ try {
188
+ unlinkSync(lockPath);
189
+ }
190
+ catch (error) {
191
+ if (!hasCode(error, "ENOENT"))
192
+ throw error;
193
+ }
194
+ return true;
195
+ }
196
+ // Only the acquisition that wrote this token may unlink: if the file now carries
197
+ // another token, a waiter has legitimately taken over and its lock must stand.
198
+ function release(lockPath, token) {
199
+ const current = readSnapshot(lockPath);
200
+ if (current === null || current.record?.token !== token)
201
+ return;
202
+ try {
203
+ unlinkSync(lockPath);
204
+ }
205
+ catch (error) {
206
+ if (!hasCode(error, "ENOENT"))
207
+ throw error;
208
+ }
209
+ }
210
+ function backoffMs(attempt) {
211
+ const exponential = Math.min(MAX_DELAY_MS, MIN_DELAY_MS * 2 ** attempt);
212
+ return exponential + Math.random() * MIN_DELAY_MS;
213
+ }
214
+ function lockTimeout(lockPath, timeoutMs) {
215
+ const holder = readSnapshot(lockPath)?.record;
216
+ const owner = holder === undefined || holder === null ? "another process" : `pid ${holder.pid}`;
217
+ return new UlwLoopError(`ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms; retry once that process finishes, or delete the lock file if that process is gone.`, ULW_LOOP_LOCK_TIMEOUT_CODE, {
218
+ details: {
219
+ lockPath,
220
+ timeoutMs,
221
+ ...(holder === undefined || holder === null ? {} : { holderPid: holder.pid }),
222
+ },
223
+ });
224
+ }
225
+ function hasCode(error, code) {
226
+ return error instanceof Error && "code" in error && error.code === code;
227
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve, sep } from "node:path";
3
- import { normalizeUlwLoopSessionId, ulwLoopDir } from "./paths.js";
3
+ import { normalizeUlwLoopSessionId, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
4
+ import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
4
5
  // Turn-death recovery only: Codex emits Stop when a turn ends, so a run that
5
6
  // dies mid-turn (crash, kill, context blowup before any Stop) never reaches
6
7
  // this handler. Mid-turn stalls are out of scope by design.
@@ -24,14 +25,15 @@ export function runStopResumeHook(input) {
24
25
  return "";
25
26
  if (boulderContinuationWillFire(payload.cwd, payload.session_id))
26
27
  return "";
27
- const stateDir = ulwLoopDir(payload.cwd, { sessionId: payload.session_id });
28
+ const scope = { sessionId: payload.session_id };
29
+ const stateDir = ulwLoopDir(payload.cwd, scope);
28
30
  const plan = readPlan(join(stateDir, "goals.json"));
29
31
  if (plan === null || plan.aggregateCompletion?.status === "complete")
30
32
  return "";
31
33
  const goal = resumableGoal(plan);
32
34
  if (goal === undefined)
33
35
  return "";
34
- if (!consumeResumeBudget(stateDir, goal.id))
36
+ if (!consumeResumeBudgetLocked(ulwLoopStateLockPath(payload.cwd, scope), stateDir, goal.id))
35
37
  return "";
36
38
  const output = {
37
39
  decision: "block",
@@ -62,6 +64,18 @@ function resumableGoal(plan) {
62
64
  function isResumableStatus(status) {
63
65
  return status === "pending" || status === "in_progress";
64
66
  }
67
+ // A resume is a budgeted side effect; when the session lock cannot be taken the
68
+ // hook stays silent (fail closed) instead of charging the counter unlocked.
69
+ function consumeResumeBudgetLocked(lockPath, stateDir, goalId) {
70
+ try {
71
+ return withStateLockSync(lockPath, () => consumeResumeBudget(stateDir, goalId));
72
+ }
73
+ catch (error) {
74
+ if (isStateLockTimeout(error))
75
+ return false;
76
+ throw error;
77
+ }
78
+ }
65
79
  // Two-strike cap keyed on ledger movement: an unchanged ledger.jsonl line
66
80
  // count across resumes means the loop is not progressing. The stuck marker is
67
81
  // a separate file — a ledger append would change the count and self-reset.
@@ -7,7 +7,7 @@
7
7
  "type": "command",
8
8
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit --with-ultrawork",
9
9
  "timeout": 10,
10
- "statusMessage": "(OmO 5.0.0-beta.45) Checking Ulw-Loop Steering"
10
+ "statusMessage": "(OmO 5.0.0-beta.47) Checking Ulw-Loop Steering"
11
11
  }
12
12
  ]
13
13
  }
@@ -20,7 +20,7 @@
20
20
  "type": "command",
21
21
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use",
22
22
  "timeout": 5,
23
- "statusMessage": "(OmO 5.0.0-beta.45) Enforcing Unlimited Ulw-Loop Budget"
23
+ "statusMessage": "(OmO 5.0.0-beta.47) Enforcing Unlimited Ulw-Loop Budget"
24
24
  }
25
25
  ]
26
26
  },
@@ -31,7 +31,7 @@
31
31
  "type": "command",
32
32
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use-spawn",
33
33
  "timeout": 5,
34
- "statusMessage": "(OmO 5.0.0-beta.45) Guarding Ulw-Loop Spawns"
34
+ "statusMessage": "(OmO 5.0.0-beta.47) Guarding Ulw-Loop Spawns"
35
35
  }
36
36
  ]
37
37
  }
@@ -43,7 +43,7 @@
43
43
  "type": "command",
44
44
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook stop",
45
45
  "timeout": 10,
46
- "statusMessage": "(OmO 5.0.0-beta.45) Checking Ulw-Loop Resume"
46
+ "statusMessage": "(OmO 5.0.0-beta.47) Checking Ulw-Loop Resume"
47
47
  }
48
48
  ]
49
49
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/codex-ulw-loop",
3
- "version": "5.0.0-beta.45",
3
+ "version": "5.0.0-beta.47",
4
4
  "description": "Codex plugin: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit.",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.12.1",
@@ -23,7 +23,8 @@ This skill is intentionally compact. The full workflow lives in `references/full
23
23
  - Use the ulw-loop CLI state under `.omo/ulw-loop`; do not hand-edit goal state.
24
24
  - Register goals up front, shaped by `references/define-goal.md` (`omo-agent-toolkit ulw-loop create-goals`, then `create_goal` from the printed handoff), and mirror every atomic step into the live `update_plan` checklist: one ultra-granular step per action, exactly one in_progress, transitions marked the instant they happen.
25
25
  - After any compaction or context loss, re-read brief + goals + ledger FIRST plus `omo-agent-toolkit ulw-loop status --json`, then resume; never re-plan from scratch.
26
- - If `omo-agent-toolkit ulw-loop create-goals` says the existing aggregate is already complete, start unrelated new work with a fresh `--session-id <new-id>` instead of steering or forcing the completed default state. Use `--force` only to intentionally overwrite completed evidence.
26
+ - Every ulw-loop command needs the session scope: pass `--session-id <id>` (the printed handoff and resume directive carry it; `CODEX_THREAD_ID` in the environment also resolves it). The CLI refuses unscoped state (`ULW_LOOP_SESSION_SCOPE_REQUIRED`) instead of touching the shared `.omo/ulw-loop` root.
27
+ - If `omo-agent-toolkit ulw-loop create-goals` says this session's aggregate is already complete, start unrelated new work with a fresh `--session-id <new-id>` (passed on every later call) instead of steering or forcing the completed state. Use `--force` only to intentionally overwrite completed evidence.
27
28
  - Every success criterion needs observable evidence from a real surface: a channel (terminal/TUI via the xterm.js web terminal, HTTP, browser, computer-use) or, for CLI- or data-shaped criteria, an auxiliary surface (CLI stdout, DB diff, parsed config dump).
28
29
  - Evidence is bound to the tree it was captured at (`git rev-parse --short "HEAD^{tree}"`); it goes stale only when tracked content changes — a rebase or amend that keeps the tree identical keeps it valid. When the tree differs, re-run at the current HEAD and re-record, never relabel or regenerate. Record only after cleanup receipts exist.
29
30
  - Delegate code edits, test writes, fixes, and QA execution to right-sized Codex subagents when the workflow requires it.