lazycodex-ai 4.16.0 → 4.16.1

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 (112) hide show
  1. package/dist/cli/codex-ulw-loop.d.ts +8 -0
  2. package/dist/cli/get-local-version/types.d.ts +1 -1
  3. package/dist/cli/index.js +260 -207
  4. package/dist/cli-node/index.js +260 -207
  5. package/docs/reference/web-terminal-visual-qa.md +39 -45
  6. package/package.json +3 -2
  7. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  8. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  9. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  10. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  11. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  12. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  13. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  14. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  15. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  16. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  17. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  18. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  19. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  20. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  21. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +2 -2
  22. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  23. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  24. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  25. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  26. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/ultrawork/directive.md +18 -10
  29. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  30. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  31. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +18 -10
  32. package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +11 -0
  33. package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +22 -1
  34. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +4 -0
  35. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +18 -10
  36. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -2
  37. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-steering.js +2 -1
  38. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +89 -27
  39. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +6 -0
  40. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +55 -9
  41. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.d.ts +15 -0
  42. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.js +33 -0
  43. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-types.d.ts +10 -3
  44. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +15 -11
  45. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
  46. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  47. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  48. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +7 -7
  49. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +17 -2
  50. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts +2 -1
  51. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +59 -11
  52. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-snapshot.ts +38 -0
  53. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-types.ts +11 -3
  54. package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +15 -7
  55. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +16 -0
  56. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +260 -2
  57. package/packages/omo-codex/plugin/components/ulw-loop/test/steering-snapshot.test.ts +124 -0
  58. package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +101 -2
  59. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  71. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  72. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  73. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  74. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  75. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  76. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  77. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  78. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  79. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  80. package/packages/omo-codex/plugin/package-lock.json +13 -13
  81. package/packages/omo-codex/plugin/package.json +1 -1
  82. package/packages/omo-codex/plugin/scripts/hook-status-message.mjs +10 -6
  83. package/packages/omo-codex/plugin/skills/frontend/SKILL.md +9 -9
  84. package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +7 -3
  85. package/packages/omo-codex/plugin/skills/frontend/references/design/_INDEX.md +1 -1
  86. package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +24 -2
  87. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/README.md +2 -2
  88. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  89. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -2
  90. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +18 -10
  91. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  92. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +7 -7
  93. package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +11 -0
  94. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +13 -17
  95. package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +4 -4
  96. package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +1 -1
  97. package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +22 -9
  98. package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +6 -6
  99. package/packages/omo-codex/plugin/test/ulw-plan-skill-contract.test.mjs +52 -0
  100. package/packages/omo-codex/scripts/install-dist/install-local.mjs +65 -39
  101. package/packages/shared-skills/skills/frontend/SKILL.md +9 -9
  102. package/packages/shared-skills/skills/frontend/references/design/README.md +7 -3
  103. package/packages/shared-skills/skills/frontend/references/design/_INDEX.md +1 -1
  104. package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +24 -2
  105. package/packages/shared-skills/skills/frontend/references/designpowers/README.md +2 -2
  106. package/packages/shared-skills/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  107. package/packages/shared-skills/skills/start-work/SKILL.md +2 -2
  108. package/packages/shared-skills/skills/visual-qa/SKILL.md +13 -17
  109. package/script/qa/strip-ansi.mjs +10 -0
  110. package/script/qa/web-terminal-visual-qa.mjs +112 -195
  111. package/script/qa/xterm-live-terminal.mjs +180 -0
  112. package/script/qa/web-terminal-renderer.mjs +0 -218
@@ -73,7 +73,8 @@ function model(value: string | undefined): UlwLoopSuccessCriterionUserModel | un
73
73
  function neverKind(kind: never): never { return fail(`Unsupported steering kind: ${String(kind)}.`, "ULW_LOOP_STEERING_KIND_UNSUPPORTED", { kind }); }
74
74
 
75
75
  export async function parseSteeringProposal(argv: readonly string[]): Promise<CliSteeringProposal> {
76
- const kind = parseSteeringKind(argv); const source = parseSteeringSource(argv); const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale") };
76
+ const kind = parseSteeringKind(argv); const source = parseSteeringSource(argv); const idempotencyKey = text(readValue(argv, "--idempotency-key"), "--idempotency-key");
77
+ const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale"), ...(idempotencyKey === undefined ? {} : { idempotencyKey }) };
77
78
  switch (kind) {
78
79
  case "add_subgoal": return normalizeSteeringProposal({ ...base, title: required(argv, "--title"), objective: required(argv, "--objective") });
79
80
  case "split_subgoal": { const goalId = requiredGoal(argv); return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, childGoals: await children(argv, "--children", true) }); }
@@ -1,4 +1,6 @@
1
+ import { createReadStream } from "node:fs";
1
2
  import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
2
4
 
3
5
  import { aggregateCodexObjectiveForScope } from "./goal-status.js";
4
6
  import {
@@ -14,7 +16,7 @@ import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from
14
16
 
15
17
  const LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `;
16
18
  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.`;
17
- const locks = new Map<string, Promise<unknown>>();
19
+ const locks = new Map<string, Promise<undefined>>();
18
20
 
19
21
  function hasCode(error: unknown, code: string): boolean {
20
22
  return error instanceof Error && "code" in error && error.code === code;
@@ -43,12 +45,19 @@ export async function withUlwLoopMutationLock<T>(
43
45
  const fn = typeof scopeOrFn === "function" ? scopeOrFn : maybeFn;
44
46
  if (fn === undefined) throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING");
45
47
  const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`;
46
- const prior = locks.get(lockKey) ?? Promise.resolve();
48
+ const prior = locks.get(lockKey) ?? Promise.resolve(undefined);
47
49
  const run = prior.then(fn, fn);
48
- locks.set(
49
- lockKey,
50
- run.catch(() => undefined),
50
+ // The stored gate resolves to undefined so the map never retains fn's result
51
+ // (plans/audits), and it removes itself once no newer waiter replaced it —
52
+ // otherwise a long-lived host leaks one entry per (repo, scope) forever.
53
+ const gate: Promise<undefined> = run.then(
54
+ () => undefined,
55
+ () => undefined,
51
56
  );
57
+ locks.set(lockKey, gate);
58
+ void gate.then(() => {
59
+ if (locks.get(lockKey) === gate) locks.delete(lockKey);
60
+ });
52
61
  return run;
53
62
  }
54
63
 
@@ -107,18 +116,57 @@ export async function appendLedger(repoRoot: string, entry: UlwLoopLedgerEntry,
107
116
  await appendFile(ulwLoopLedgerPath(repoRoot, scope), `${JSON.stringify(entry)}\n`, "utf8");
108
117
  }
109
118
 
110
- export async function readSteeringLedgerEntries(repoRoot: string, scope?: UlwLoopScope): Promise<UlwLoopLedgerEntry[]> {
111
- let raw: string;
119
+ /**
120
+ * Streams raw ledger lines without materializing the file. Real ledgers reach
121
+ * many MB (legacy entries embedded full-plan snapshots), so `readFile` here
122
+ * ballooned every steer/dedup path; line-at-a-time keeps memory O(longest line).
123
+ */
124
+ async function* ledgerLines(repoRoot: string, scope?: UlwLoopScope): AsyncGenerator<string> {
125
+ const stream = createReadStream(ulwLoopLedgerPath(repoRoot, scope), { encoding: "utf8" });
126
+ const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
112
127
  try {
113
- raw = await readFile(ulwLoopLedgerPath(repoRoot, scope), "utf8");
128
+ for await (const line of lines) {
129
+ if (line.trim().length > 0) yield line;
130
+ }
114
131
  } catch (error) {
115
- if (hasCode(error, "ENOENT")) return [];
116
- throw error;
132
+ if (!hasCode(error, "ENOENT")) throw error;
133
+ } finally {
134
+ lines.close();
135
+ stream.destroy();
117
136
  }
137
+ }
138
+
139
+ export async function readSteeringLedgerEntries(repoRoot: string, scope?: UlwLoopScope): Promise<UlwLoopLedgerEntry[]> {
118
140
  const entries: UlwLoopLedgerEntry[] = [];
119
- for (const line of raw.split(/\r?\n/).filter(Boolean)) {
141
+ for await (const line of ledgerLines(repoRoot, scope)) {
120
142
  const entry: UlwLoopLedgerEntry = JSON.parse(line);
121
143
  if (isSteeringKind(entry.kind)) entries.push(entry);
122
144
  }
123
145
  return entries;
124
146
  }
147
+
148
+ /**
149
+ * First accepted steering entry matching an idempotency key/prompt signature.
150
+ * A cheap substring probe on the raw line skips JSON.parse for the vast
151
+ * majority of entries, so dedup stays flat even on legacy multi-MB ledgers.
152
+ */
153
+ export async function findAcceptedSteeringLedgerEntry(
154
+ repoRoot: string,
155
+ key: string,
156
+ scope?: UlwLoopScope,
157
+ ): Promise<UlwLoopLedgerEntry | undefined> {
158
+ const probe = JSON.stringify(key);
159
+ for await (const line of ledgerLines(repoRoot, scope)) {
160
+ if (!line.includes(probe)) continue;
161
+ const entry: UlwLoopLedgerEntry = JSON.parse(line);
162
+ if (!isSteeringKind(entry.kind)) continue;
163
+ if (entry.steering?.invariant.accepted !== true) continue;
164
+ if (
165
+ entry.idempotencyKey === key ||
166
+ entry.steering.idempotencyKey === key ||
167
+ entry.steering.promptSignature === key
168
+ )
169
+ return entry;
170
+ }
171
+ return undefined;
172
+ }
@@ -0,0 +1,38 @@
1
+ import type { UlwLoopItem, UlwLoopPlan } from "./domain-types.js";
2
+ import type { UlwLoopSteeringPlanSnapshot } from "./steering-types.js";
3
+
4
+ /**
5
+ * Compact before/after snapshots for steering ledger entries.
6
+ *
7
+ * Ledger entries used to embed the FULL plan (every goal, criterion, and
8
+ * evidence string) twice per audit and twice more at the entry top level.
9
+ * That made each accepted steer O(plan size) on disk, so the ledger grew
10
+ * quadratically over a run, and every dedup scan re-hydrated all of it into
11
+ * memory. A snapshot instead records plan-level counters plus only the goals
12
+ * the mutation actually touched, keeping each entry O(changed goals).
13
+ */
14
+ export function buildSteeringPlanSnapshot(
15
+ plan: UlwLoopPlan,
16
+ changedGoalIds: ReadonlySet<string>,
17
+ ): UlwLoopSteeringPlanSnapshot {
18
+ const snapshot: UlwLoopSteeringPlanSnapshot = {
19
+ updatedAt: plan.updatedAt,
20
+ goalCount: plan.goals.length,
21
+ goalIds: plan.goals.map((goal) => goal.id),
22
+ goals: plan.goals.filter((goal) => changedGoalIds.has(goal.id)),
23
+ };
24
+ return plan.activeGoalId === undefined ? snapshot : { ...snapshot, activeGoalId: plan.activeGoalId };
25
+ }
26
+
27
+ /** Ids of goals that differ between two plans, including added or removed goals. */
28
+ export function changedGoalIdsBetween(before: UlwLoopPlan, after: UlwLoopPlan): Set<string> {
29
+ const beforeById = new Map<string, UlwLoopItem>(before.goals.map((goal) => [goal.id, goal]));
30
+ const changed = new Set<string>();
31
+ for (const goal of after.goals) {
32
+ const prior = beforeById.get(goal.id);
33
+ if (prior === undefined || JSON.stringify(prior) !== JSON.stringify(goal)) changed.add(goal.id);
34
+ beforeById.delete(goal.id);
35
+ }
36
+ for (const id of beforeById.keys()) changed.add(id);
37
+ return changed;
38
+ }
@@ -1,5 +1,5 @@
1
1
  import type { UlwLoopSteeringMutationKind, UlwLoopSteeringSource } from "./constants.js";
2
- import type { UlwLoopPlan } from "./domain-types.js";
2
+ import type { UlwLoopItem, UlwLoopPlan } from "./domain-types.js";
3
3
 
4
4
  export interface UlwLoopSteeringInvariantResult {
5
5
  accepted: boolean;
@@ -44,13 +44,21 @@ export interface UlwLoopSteeringProposal {
44
44
  now?: Date;
45
45
  }
46
46
 
47
+ export interface UlwLoopSteeringPlanSnapshot {
48
+ readonly updatedAt: string;
49
+ readonly activeGoalId?: string;
50
+ readonly goalCount: number;
51
+ readonly goalIds: readonly string[];
52
+ readonly goals: readonly UlwLoopItem[];
53
+ }
54
+
47
55
  export interface UlwLoopSteeringAudit {
48
56
  kind: UlwLoopSteeringMutationKind;
49
57
  source: UlwLoopSteeringSource;
50
58
  targetGoalIds: string[];
51
59
  criterionId?: string;
52
- before?: unknown;
53
- after?: unknown;
60
+ before?: UlwLoopSteeringPlanSnapshot;
61
+ after?: UlwLoopSteeringPlanSnapshot;
54
62
  evidence: string;
55
63
  rationale: string;
56
64
  invariant: UlwLoopSteeringInvariantResult;
@@ -2,7 +2,8 @@
2
2
  import { isUlwLoopDone } from "./goal-status.js";
3
3
  import type { UlwLoopScope } from "./paths.js";
4
4
  import { seedDefaultSuccessCriteria } from "./plan-crud.js";
5
- import { appendLedger, readSteeringLedgerEntries, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
5
+ import { appendLedger, findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
6
+ import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "./steering-snapshot.js";
6
7
  import type {
7
8
  SteerUlwLoopResult,
8
9
  UlwLoopItem,
@@ -245,13 +246,22 @@ export async function steerUlwLoop(repoRoot: string, proposal: UlwLoopSteeringPr
245
246
  return withUlwLoopMutationLock(repoRoot, scope, async () => {
246
247
  const plan = await readUlwLoopPlan(repoRoot, scope);
247
248
  const key = proposal.idempotencyKey ?? proposal.promptSignature;
248
- const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(repoRoot, scope)).find((entry) => entry.steering?.invariant.accepted === true && (entry.idempotencyKey === key || entry.steering.idempotencyKey === key || entry.steering.promptSignature === key));
249
- if (prior?.steering !== undefined) return { plan, accepted: true, audit: { ...prior.steering, deduped: true }, rejectedReasons: [], deduped: true };
249
+ const prior = key === undefined ? undefined : await findAcceptedSteeringLedgerEntry(repoRoot, key, scope);
250
+ if (prior?.steering !== undefined) {
251
+ // Legacy entries embedded full-plan before/after snapshots; never
252
+ // re-surface those multi-MB payloads on the dedup path.
253
+ const { before: _before, after: _after, ...compactPrior } = prior.steering;
254
+ return { plan, accepted: true, audit: { ...compactPrior, deduped: true }, rejectedReasons: [], deduped: true };
255
+ }
250
256
  const audit = validateUlwLoopSteeringProposal(plan, proposal);
251
257
  const accepted = audit.invariant.accepted;
252
258
  const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan;
253
- const finalAudit: UlwLoopSteeringAudit = { ...audit, before: plan };
254
- if (accepted) finalAudit.after = next;
259
+ const finalAudit: UlwLoopSteeringAudit = { ...audit };
260
+ if (accepted) {
261
+ const changed = changedGoalIdsBetween(plan, next);
262
+ finalAudit.before = buildSteeringPlanSnapshot(plan, changed);
263
+ finalAudit.after = buildSteeringPlanSnapshot(next, changed);
264
+ }
255
265
  if (accepted) await writePlan(repoRoot, next, scope);
256
266
  await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso()), scope);
257
267
  return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false };
@@ -264,7 +274,5 @@ function ledgerEntry(proposal: UlwLoopSteeringProposal, audit: UlwLoopSteeringAu
264
274
  if (goalId !== undefined) entry.goalId = goalId;
265
275
  if (proposal.criterionId !== undefined) entry.criterionId = proposal.criterionId;
266
276
  if (proposal.idempotencyKey !== undefined) entry.idempotencyKey = proposal.idempotencyKey;
267
- if (audit.before !== undefined) entry.before = audit.before;
268
- if (audit.after !== undefined) entry.after = audit.after;
269
277
  return entry;
270
278
  }
@@ -169,4 +169,20 @@ describe("ulwLoopCommand create-goals", () => {
169
169
 
170
170
  expect(await readFile(join(testDir, ".omo/ulw-loop/manual-456/goals.json"), "utf8")).toContain("Manual scoped");
171
171
  });
172
+
173
+ it("#given a valueless --session-id #when creating goals #then it fails and writes no plan", async () => {
174
+ const code = await ulwLoopCommand(["create-goals", "--session-id", "--brief", "- Alpha"]);
175
+
176
+ expect(code).toBe(1);
177
+ expect(err.join("")).toContain("--session-id requires a non-empty value");
178
+ await expect(readFile(join(testDir, ".omo/ulw-loop/goals.json"), "utf8")).rejects.toThrow();
179
+ });
180
+
181
+ it("#given an empty --session-id= #when creating goals #then it fails and writes no plan", async () => {
182
+ const code = await ulwLoopCommand(["create-goals", "--session-id=", "--brief", "- Alpha"]);
183
+
184
+ expect(code).toBe(1);
185
+ expect(err.join("")).toContain("--session-id requires a non-empty value");
186
+ await expect(readFile(join(testDir, ".omo/ulw-loop/goals.json"), "utf8")).rejects.toThrow();
187
+ });
172
188
  });
@@ -1,16 +1,18 @@
1
- import { copyFile, mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
1
+ import { appendFile, copyFile, mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { setImmediate as tick } from "node:timers/promises";
4
5
  import { beforeEach, describe, expect, it } from "vitest";
5
6
  import { ulwLoopDir, ulwLoopGoalsPath, ulwLoopLedgerPath } from "../src/paths.js";
6
7
  import {
7
8
  appendLedger,
9
+ findAcceptedSteeringLedgerEntry,
8
10
  readSteeringLedgerEntries,
9
11
  readUlwLoopPlan,
10
12
  withUlwLoopMutationLock,
11
13
  writePlan,
12
14
  } from "../src/plan-io.js";
13
- import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan } from "../src/types.js";
15
+ import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan, UlwLoopSteeringAudit } from "../src/types.js";
14
16
  import { UlwLoopError } from "../src/types.js";
15
17
 
16
18
  const NOW = "2026-05-23T00:00:00.000Z";
@@ -51,6 +53,45 @@ function entry(kind: UlwLoopLedgerEntry["kind"], goalId = "G001"): UlwLoopLedger
51
53
  return { at: NOW, kind, goalId };
52
54
  }
53
55
 
56
+ function steeringAudit(overrides: Partial<UlwLoopSteeringAudit> = {}): UlwLoopSteeringAudit {
57
+ return {
58
+ kind: "revise_pending_wording",
59
+ source: "cli",
60
+ targetGoalIds: ["G001"],
61
+ evidence: "observed evidence",
62
+ rationale: "needed change",
63
+ invariant: {
64
+ accepted: true,
65
+ structuralInvariantAccepted: true,
66
+ evidenceBackedNecessity: true,
67
+ noEasierCompletion: true,
68
+ rejectedReasons: [],
69
+ },
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ function rejectedSteeringAudit(overrides: Partial<UlwLoopSteeringAudit> = {}): UlwLoopSteeringAudit {
75
+ return steeringAudit({
76
+ invariant: {
77
+ accepted: false,
78
+ structuralInvariantAccepted: false,
79
+ evidenceBackedNecessity: false,
80
+ noEasierCompletion: true,
81
+ rejectedReasons: ["missing evidence"],
82
+ },
83
+ ...overrides,
84
+ });
85
+ }
86
+
87
+ function steeringEntry(
88
+ kind: UlwLoopLedgerEntry["kind"],
89
+ audit: UlwLoopSteeringAudit,
90
+ overrides: Partial<UlwLoopLedgerEntry> = {},
91
+ ): UlwLoopLedgerEntry {
92
+ return { at: NOW, kind, goalId: "G001", steering: audit, ...overrides };
93
+ }
94
+
54
95
  async function makeRepo(): Promise<string> {
55
96
  return mkdtemp(join(tmpdir(), "ug-io-"));
56
97
  }
@@ -209,6 +250,112 @@ describe("readSteeringLedgerEntries", () => {
209
250
  });
210
251
  });
211
252
 
253
+ describe("findAcceptedSteeringLedgerEntry", () => {
254
+ it.each([
255
+ [
256
+ "top-level idempotencyKey",
257
+ steeringEntry("steering_accepted", steeringAudit(), { idempotencyKey: "needle-key" }),
258
+ ],
259
+ ["steering.idempotencyKey", steeringEntry("steering_accepted", steeringAudit({ idempotencyKey: "needle-key" }))],
260
+ [
261
+ "steering.promptSignature",
262
+ steeringEntry("steering_accepted", steeringAudit({ promptSignature: "needle-key" })),
263
+ ],
264
+ ] as const)("finds an accepted entry by %s", async (_name, target) => {
265
+ // given
266
+ const repoRoot = await makeRepo();
267
+ await appendLedger(repoRoot, steeringEntry("steering_accepted", steeringAudit({ idempotencyKey: "other-key" })));
268
+ await appendLedger(repoRoot, target);
269
+
270
+ // when
271
+ const found = await findAcceptedSteeringLedgerEntry(repoRoot, "needle-key");
272
+
273
+ // then
274
+ expect(found).toEqual(target);
275
+ });
276
+
277
+ it("skips a rejected entry carrying the same key", async () => {
278
+ // given
279
+ const repoRoot = await makeRepo();
280
+ await appendLedger(
281
+ repoRoot,
282
+ steeringEntry("steering_rejected", rejectedSteeringAudit({ idempotencyKey: "needle-key" })),
283
+ );
284
+
285
+ // when/then
286
+ await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
287
+ });
288
+
289
+ it("returns the accepted entry even when a rejected one with the same key precedes it", async () => {
290
+ // given
291
+ const repoRoot = await makeRepo();
292
+ const accepted = steeringEntry("steering_accepted", steeringAudit({ idempotencyKey: "needle-key" }));
293
+ await appendLedger(
294
+ repoRoot,
295
+ steeringEntry("steering_rejected", rejectedSteeringAudit({ idempotencyKey: "needle-key" })),
296
+ );
297
+ await appendLedger(repoRoot, accepted);
298
+
299
+ // when/then
300
+ await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toEqual(accepted);
301
+ });
302
+
303
+ it("returns undefined when the ledger file is missing", async () => {
304
+ // given
305
+ const repoRoot = await makeRepo();
306
+
307
+ // when/then
308
+ await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
309
+ });
310
+
311
+ it("ignores the key when it only appears in non-steering entries", async () => {
312
+ // given
313
+ const repoRoot = await makeRepo();
314
+ await appendLedger(repoRoot, { at: NOW, kind: "goal_completed", goalId: "G001", idempotencyKey: "needle-key" });
315
+ await appendLedger(repoRoot, { at: NOW, kind: "evidence_captured", goalId: "G001", evidence: "needle-key" });
316
+
317
+ // when/then
318
+ await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
319
+ });
320
+
321
+ it("ignores an accepted entry whose key only appears in evidence text", async () => {
322
+ // given
323
+ const repoRoot = await makeRepo();
324
+ await appendLedger(repoRoot, steeringEntry("steering_accepted", steeringAudit({ evidence: "needle-key" })));
325
+
326
+ // when/then
327
+ await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
328
+ });
329
+
330
+ it("finds the key inside a legacy entry embedding full before/after plans", async () => {
331
+ // given
332
+ const repoRoot = await makeRepo();
333
+ const bulkyGoals = Array.from({ length: 20 }, (_, index) =>
334
+ makeGoal({ id: `G${String(index + 1).padStart(3, "0")}`, objective: `evidence detail ${index} `.repeat(200) }),
335
+ );
336
+ const bulkyPlan = makePlan({ goals: bulkyGoals });
337
+ const legacyLine = JSON.stringify({
338
+ at: NOW,
339
+ kind: "steering_accepted",
340
+ goalId: "G001",
341
+ idempotencyKey: "needle-key",
342
+ before: bulkyPlan,
343
+ after: bulkyPlan,
344
+ steering: { ...steeringAudit({ idempotencyKey: "needle-key" }), before: bulkyPlan, after: bulkyPlan },
345
+ });
346
+ await mkdir(ulwLoopDir(repoRoot), { recursive: true });
347
+ await appendFile(ulwLoopLedgerPath(repoRoot), `${legacyLine}\n`, "utf8");
348
+
349
+ // when
350
+ const found = await findAcceptedSteeringLedgerEntry(repoRoot, "needle-key");
351
+
352
+ // then
353
+ expect(legacyLine.length).toBeGreaterThan(100_000);
354
+ expect(found?.kind).toBe("steering_accepted");
355
+ expect(found?.idempotencyKey).toBe("needle-key");
356
+ });
357
+ });
358
+
212
359
  describe("withUlwLoopMutationLock", () => {
213
360
  it("serializes concurrent invocations", async () => {
214
361
  // given
@@ -236,4 +383,115 @@ describe("withUlwLoopMutationLock", () => {
236
383
  expect(maxActive).toBe(1);
237
384
  expect(await readFile(counterPath, "utf8")).toBe("3");
238
385
  });
386
+
387
+ it("propagates the body rejection and keeps later calls working", async () => {
388
+ // given
389
+ const repoRoot = await makeRepo();
390
+
391
+ // when
392
+ const failure = withUlwLoopMutationLock(repoRoot, async () => {
393
+ throw new Error("body exploded");
394
+ });
395
+
396
+ // then
397
+ await expect(failure).rejects.toThrow("body exploded");
398
+ await expect(withUlwLoopMutationLock(repoRoot, async () => "recovered")).resolves.toBe("recovered");
399
+ });
400
+
401
+ it("still serializes a second batch after the first batch's gate settles", async () => {
402
+ // given
403
+ const repoRoot = await makeRepo();
404
+ let active = 0;
405
+ let maxActive = 0;
406
+ const body = async (): Promise<void> => {
407
+ active += 1;
408
+ maxActive = Math.max(maxActive, active);
409
+ await tick();
410
+ active -= 1;
411
+ };
412
+
413
+ // when
414
+ await Promise.all([1, 2, 3].map(() => withUlwLoopMutationLock(repoRoot, body)));
415
+ await tick();
416
+ await Promise.all([1, 2].map(() => withUlwLoopMutationLock(repoRoot, body)));
417
+
418
+ // then
419
+ expect(maxActive).toBe(1);
420
+ });
421
+
422
+ it("keeps an in-flight successor locked when an earlier gate settles", async () => {
423
+ // given
424
+ const repoRoot = await makeRepo();
425
+ const events: string[] = [];
426
+ let releaseSecond = false;
427
+
428
+ // when
429
+ const first = withUlwLoopMutationLock(repoRoot, async () => {
430
+ events.push("first");
431
+ });
432
+ const second = withUlwLoopMutationLock(repoRoot, async () => {
433
+ events.push("second:start");
434
+ while (!releaseSecond) await tick();
435
+ events.push("second:end");
436
+ });
437
+ await first;
438
+ await tick();
439
+ const third = withUlwLoopMutationLock(repoRoot, async () => {
440
+ events.push("third");
441
+ });
442
+ releaseSecond = true;
443
+ await Promise.all([second, third]);
444
+
445
+ // then
446
+ expect(events).toEqual(["first", "second:start", "second:end", "third"]);
447
+ });
448
+
449
+ it("does not serialize distinct repo roots against each other", async () => {
450
+ // given
451
+ const rootA = await makeRepo();
452
+ const rootB = await makeRepo();
453
+ const events: string[] = [];
454
+ let releaseA = false;
455
+
456
+ // when
457
+ const held = withUlwLoopMutationLock(rootA, async () => {
458
+ events.push("a:start");
459
+ while (!releaseA) await tick();
460
+ events.push("a:end");
461
+ });
462
+ await withUlwLoopMutationLock(rootB, async () => {
463
+ events.push("b");
464
+ });
465
+ releaseA = true;
466
+ await held;
467
+
468
+ // then
469
+ expect(events).toEqual(["a:start", "b", "a:end"]);
470
+ });
471
+
472
+ it("serializes per scope while distinct scopes stay independent", async () => {
473
+ // given
474
+ const repoRoot = await makeRepo();
475
+ const scoped = { sessionId: "session-a" };
476
+ const events: string[] = [];
477
+ let releaseScoped = false;
478
+
479
+ // when
480
+ const heldScoped = withUlwLoopMutationLock(repoRoot, scoped, async () => {
481
+ events.push("scoped-1:start");
482
+ while (!releaseScoped) await tick();
483
+ events.push("scoped-1:end");
484
+ });
485
+ const queuedScoped = withUlwLoopMutationLock(repoRoot, scoped, async () => {
486
+ events.push("scoped-2");
487
+ });
488
+ await withUlwLoopMutationLock(repoRoot, async () => {
489
+ events.push("root-scope");
490
+ });
491
+ releaseScoped = true;
492
+ await Promise.all([heldScoped, queuedScoped]);
493
+
494
+ // then
495
+ expect(events).toEqual(["scoped-1:start", "root-scope", "scoped-1:end", "scoped-2"]);
496
+ });
239
497
  });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "../src/steering-snapshot.js";
3
+ import type { UlwLoopItem, UlwLoopPlan } from "../src/types.js";
4
+
5
+ const NOW = "2026-05-23T00:00:00.000Z";
6
+
7
+ function goal(id: string, overrides: Partial<UlwLoopItem> = {}): UlwLoopItem {
8
+ return {
9
+ id,
10
+ title: `Goal ${id}`,
11
+ objective: `Objective for ${id}`,
12
+ status: "pending",
13
+ successCriteria: [],
14
+ attempt: 0,
15
+ createdAt: NOW,
16
+ updatedAt: NOW,
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ function plan(goals: UlwLoopItem[], overrides: Partial<UlwLoopPlan> = {}): UlwLoopPlan {
22
+ return {
23
+ version: 1,
24
+ createdAt: NOW,
25
+ updatedAt: NOW,
26
+ briefPath: ".omo/ulw-loop/brief.md",
27
+ goalsPath: ".omo/ulw-loop/goals.json",
28
+ ledgerPath: ".omo/ulw-loop/ledger.jsonl",
29
+ goals,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("changedGoalIdsBetween", () => {
35
+ it.each([
36
+ {
37
+ name: "added goal",
38
+ before: [goal("G001"), goal("G002")],
39
+ after: [goal("G001"), goal("G002"), goal("G003")],
40
+ expected: ["G003"],
41
+ },
42
+ {
43
+ name: "removed goal",
44
+ before: [goal("G001"), goal("G002")],
45
+ after: [goal("G001")],
46
+ expected: ["G002"],
47
+ },
48
+ {
49
+ name: "mutated goal",
50
+ before: [goal("G001"), goal("G002")],
51
+ after: [goal("G001"), goal("G002", { title: "Rewritten" })],
52
+ expected: ["G002"],
53
+ },
54
+ {
55
+ name: "reordered-only goals",
56
+ before: [goal("G001"), goal("G002"), goal("G003")],
57
+ after: [goal("G003"), goal("G001"), goal("G002")],
58
+ expected: [],
59
+ },
60
+ {
61
+ name: "identical plans",
62
+ before: [goal("G001"), goal("G002")],
63
+ after: [goal("G001"), goal("G002")],
64
+ expected: [],
65
+ },
66
+ {
67
+ name: "add + remove + mutate at once",
68
+ before: [goal("G001"), goal("G002"), goal("G003")],
69
+ after: [goal("G001", { status: "blocked" }), goal("G003"), goal("G004")],
70
+ expected: ["G001", "G002", "G004"],
71
+ },
72
+ ])("detects $name", ({ before, after, expected }) => {
73
+ // when
74
+ const changed = changedGoalIdsBetween(plan(before), plan(after));
75
+
76
+ // then
77
+ expect([...changed].sort()).toEqual(expected);
78
+ });
79
+ });
80
+
81
+ describe("buildSteeringPlanSnapshot", () => {
82
+ it("includes only changed goals while keeping full goalIds order and goalCount", () => {
83
+ // given
84
+ const goals = [goal("G001"), goal("G002"), goal("G003"), goal("G004")];
85
+ const source = plan(goals);
86
+
87
+ // when
88
+ const snapshot = buildSteeringPlanSnapshot(source, new Set(["G003"]));
89
+
90
+ // then
91
+ expect(snapshot.goals.map((item) => item.id)).toEqual(["G003"]);
92
+ expect(snapshot.goals[0]).toEqual(goals[2]);
93
+ expect(snapshot.goalIds).toEqual(["G001", "G002", "G003", "G004"]);
94
+ expect(snapshot.goalCount).toBe(4);
95
+ expect(snapshot.updatedAt).toBe(NOW);
96
+ });
97
+
98
+ it("omits the activeGoalId key entirely when the plan has none", () => {
99
+ // when
100
+ const snapshot = buildSteeringPlanSnapshot(plan([goal("G001")]), new Set(["G001"]));
101
+
102
+ // then
103
+ expect("activeGoalId" in snapshot).toBe(false);
104
+ });
105
+
106
+ it("carries activeGoalId when the plan has one", () => {
107
+ // when
108
+ const snapshot = buildSteeringPlanSnapshot(plan([goal("G001")], { activeGoalId: "G001" }), new Set());
109
+
110
+ // then
111
+ expect(snapshot.activeGoalId).toBe("G001");
112
+ });
113
+
114
+ it("keeps plan order for changed goals regardless of set insertion order", () => {
115
+ // given
116
+ const source = plan([goal("G001"), goal("G002"), goal("G003")]);
117
+
118
+ // when
119
+ const snapshot = buildSteeringPlanSnapshot(source, new Set(["G003", "G001"]));
120
+
121
+ // then
122
+ expect(snapshot.goals.map((item) => item.id)).toEqual(["G001", "G003"]);
123
+ });
124
+ });