oh-my-opencode 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 (128) hide show
  1. package/.agents/skills/codex-qa/SKILL.md +9 -7
  2. package/.agents/skills/codex-qa/references/logging-debug.md +6 -4
  3. package/.agents/skills/opencode-qa/SKILL.md +9 -9
  4. package/.agents/skills/opencode-qa/references/tui-tmux.md +10 -8
  5. package/dist/cli/codex-ulw-loop.d.ts +8 -0
  6. package/dist/cli/get-local-version/types.d.ts +1 -1
  7. package/dist/cli/index.js +260 -207
  8. package/dist/cli-node/index.js +260 -207
  9. package/dist/features/builtin-commands/templates/start-work.d.ts +1 -1
  10. package/dist/features/monitor/process.d.ts +17 -1
  11. package/dist/index.js +1048 -1001
  12. package/dist/skills/frontend/SKILL.md +9 -9
  13. package/dist/skills/frontend/references/design/README.md +7 -3
  14. package/dist/skills/frontend/references/design/_INDEX.md +1 -1
  15. package/dist/skills/frontend/references/design/design-system-architecture.md +24 -2
  16. package/dist/skills/frontend/references/designpowers/README.md +2 -2
  17. package/dist/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  18. package/dist/skills/start-work/SKILL.md +2 -2
  19. package/dist/skills/visual-qa/SKILL.md +13 -17
  20. package/docs/reference/web-terminal-visual-qa.md +39 -45
  21. package/package.json +31 -25
  22. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  23. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  26. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  29. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  30. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  31. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  33. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  34. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  35. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +2 -2
  37. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  38. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  39. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  40. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  41. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/ultrawork/directive.md +18 -10
  44. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  45. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  46. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +18 -10
  47. package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +11 -0
  48. package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +22 -1
  49. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +4 -0
  50. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +18 -10
  51. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -2
  52. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-steering.js +2 -1
  53. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +89 -27
  54. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +6 -0
  55. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +55 -9
  56. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.d.ts +15 -0
  57. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.js +33 -0
  58. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-types.d.ts +10 -3
  59. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +15 -11
  60. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
  61. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  62. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  63. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +7 -7
  64. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +17 -2
  65. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts +2 -1
  66. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +59 -11
  67. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-snapshot.ts +38 -0
  68. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-types.ts +11 -3
  69. package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +15 -7
  70. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +16 -0
  71. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +260 -2
  72. package/packages/omo-codex/plugin/components/ulw-loop/test/steering-snapshot.test.ts +124 -0
  73. package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +101 -2
  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-codegraph-init-guidance.json +1 -1
  78. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  79. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  80. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  81. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  82. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.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-checking-codegraph-bootstrap.json +1 -1
  87. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  88. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  89. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  90. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-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 +13 -13
  96. package/packages/omo-codex/plugin/package.json +1 -1
  97. package/packages/omo-codex/plugin/scripts/hook-status-message.mjs +10 -6
  98. package/packages/omo-codex/plugin/skills/frontend/SKILL.md +9 -9
  99. package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +7 -3
  100. package/packages/omo-codex/plugin/skills/frontend/references/design/_INDEX.md +1 -1
  101. package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +24 -2
  102. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/README.md +2 -2
  103. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  104. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -2
  105. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +18 -10
  106. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  107. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +7 -7
  108. package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +11 -0
  109. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +13 -17
  110. package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +4 -4
  111. package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +1 -1
  112. package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +22 -9
  113. package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +6 -6
  114. package/packages/omo-codex/plugin/test/ulw-plan-skill-contract.test.mjs +52 -0
  115. package/packages/omo-codex/scripts/install-dist/install-local.mjs +65 -39
  116. package/packages/omo-codex/scripts/install-lazycodex-version-stamp.test.mjs +2 -2
  117. package/packages/shared-skills/skills/frontend/SKILL.md +9 -9
  118. package/packages/shared-skills/skills/frontend/references/design/README.md +7 -3
  119. package/packages/shared-skills/skills/frontend/references/design/_INDEX.md +1 -1
  120. package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +24 -2
  121. package/packages/shared-skills/skills/frontend/references/designpowers/README.md +2 -2
  122. package/packages/shared-skills/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  123. package/packages/shared-skills/skills/start-work/SKILL.md +2 -2
  124. package/packages/shared-skills/skills/visual-qa/SKILL.md +13 -17
  125. package/script/qa/strip-ansi.mjs +10 -0
  126. package/script/qa/web-terminal-visual-qa.mjs +112 -195
  127. package/script/qa/xterm-live-terminal.mjs +180 -0
  128. package/script/qa/web-terminal-renderer.mjs +0 -218
@@ -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
+ });
@@ -1,8 +1,8 @@
1
- import { mkdtemp, readFile } from "node:fs/promises";
1
+ import { appendFile, mkdtemp, readFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
- import { ulwLoopGoalsPath } from "../src/paths.js";
5
+ import { ulwLoopGoalsPath, ulwLoopLedgerPath } from "../src/paths.js";
6
6
  import { readSteeringLedgerEntries, readUlwLoopPlan, writePlan } from "../src/plan-io.js";
7
7
  import {
8
8
  applySteeringMutation,
@@ -12,6 +12,7 @@ import {
12
12
  } from "../src/steering.js";
13
13
  import type {
14
14
  UlwLoopItem,
15
+ UlwLoopLedgerEntry,
15
16
  UlwLoopPlan,
16
17
  UlwLoopSteeringProposal,
17
18
  UlwLoopSuccessCriterion,
@@ -320,6 +321,104 @@ describe("steerUlwLoop", () => {
320
321
  expect(second.deduped).toBe(true);
321
322
  expect((await readUlwLoopPlan(repoRoot)).goals).toHaveLength(4);
322
323
  });
324
+
325
+ describe("compact steering ledger entries", () => {
326
+ const DETAIL = "evidence detail ".repeat(200);
327
+
328
+ function fatGoal(id: string): UlwLoopItem {
329
+ return goal({
330
+ id,
331
+ title: `Fat goal ${id}`,
332
+ objective: `Objective ${id}: ${DETAIL}`,
333
+ evidence: DETAIL,
334
+ successCriteria: [
335
+ criterion({ id: "C001", scenario: DETAIL, expectedEvidence: DETAIL }),
336
+ criterion({ id: "C002", scenario: DETAIL, expectedEvidence: DETAIL }),
337
+ ],
338
+ });
339
+ }
340
+
341
+ function fatPlan(): UlwLoopPlan {
342
+ return plan({
343
+ goals: Array.from({ length: 20 }, (_, index) => fatGoal(`G${String(index + 1).padStart(3, "0")}`)),
344
+ });
345
+ }
346
+
347
+ it("writes an O(changed goals) ledger entry instead of embedding the full plan", async () => {
348
+ // given
349
+ const seed = fatPlan();
350
+ const planBytes = JSON.stringify(seed).length;
351
+ const repoRoot = await repoWithPlan(seed);
352
+
353
+ // when
354
+ await steerUlwLoop(
355
+ repoRoot,
356
+ steering({ kind: "revise_pending_wording", targetGoalId: "G010", revisedTitle: "Sharper goal ten" }),
357
+ );
358
+
359
+ // then
360
+ const lines = (await readFile(ulwLoopLedgerPath(repoRoot), "utf8")).split("\n").filter(Boolean);
361
+ expect(lines).toHaveLength(1);
362
+ const line = lines[0] ?? "";
363
+ expect(line.length).toBeLessThan(planBytes / 4);
364
+ const entry: UlwLoopLedgerEntry = JSON.parse(line);
365
+ expect("before" in entry).toBe(false);
366
+ expect("after" in entry).toBe(false);
367
+ expect(entry.steering?.before?.goals.map((item) => item.id)).toEqual(["G010"]);
368
+ expect(entry.steering?.after?.goals.map((item) => item.id)).toEqual(["G010"]);
369
+ expect(entry.steering?.after?.goals[0]?.title).toBe("Sharper goal ten");
370
+ expect(entry.steering?.before?.goalCount).toBe(20);
371
+ expect(entry.steering?.before?.goalIds).toEqual(seed.goals.map((item) => item.id));
372
+ });
373
+ });
374
+
375
+ it("dedup against a legacy full-plan entry strips before/after from the returned audit", async () => {
376
+ // given
377
+ const seed = plan();
378
+ const repoRoot = await repoWithPlan(seed);
379
+ const legacyLine = JSON.stringify({
380
+ at: NOW,
381
+ kind: "steering_accepted",
382
+ goalId: "G001",
383
+ idempotencyKey: "legacy-key",
384
+ evidence: "observable blocker evidence",
385
+ message: "the plan must change to stay safe",
386
+ mutationKind: "revise_pending_wording",
387
+ steering: {
388
+ kind: "revise_pending_wording",
389
+ source: "cli",
390
+ targetGoalIds: ["G001"],
391
+ evidence: "observable blocker evidence",
392
+ rationale: "the plan must change to stay safe",
393
+ idempotencyKey: "legacy-key",
394
+ invariant: {
395
+ accepted: true,
396
+ structuralInvariantAccepted: true,
397
+ evidenceBackedNecessity: true,
398
+ noEasierCompletion: true,
399
+ rejectedReasons: [],
400
+ },
401
+ before: seed,
402
+ after: seed,
403
+ },
404
+ });
405
+ await appendFile(ulwLoopLedgerPath(repoRoot), `${legacyLine}\n`, "utf8");
406
+
407
+ // when
408
+ const result = await steerUlwLoop(repoRoot, steering({ idempotencyKey: "legacy-key" }));
409
+
410
+ // then
411
+ expect(result.deduped).toBe(true);
412
+ expect(result.accepted).toBe(true);
413
+ expect("before" in result.audit).toBe(false);
414
+ expect("after" in result.audit).toBe(false);
415
+ expect(result.audit).toMatchObject({
416
+ kind: "revise_pending_wording",
417
+ idempotencyKey: "legacy-key",
418
+ deduped: true,
419
+ });
420
+ expect((await readUlwLoopPlan(repoRoot)).goals).toEqual(seed.goals);
421
+ });
323
422
  });
324
423
 
325
424
  describe("parseUlwLoopSteeringDirective", () => {
@@ -7,7 +7,7 @@
7
7
  "type": "command",
8
8
  "command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook post-compact",
9
9
  "timeout": 5,
10
- "statusMessage": "(OmO) Resetting Git Bash MCP Reminder",
10
+ "statusMessage": "(OmO 4.16.1) Resetting Git Bash MCP Reminder",
11
11
  "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\components\\bootstrap\\scripts\\node-dispatch.ps1\" \"${PLUGIN_ROOT}\\components\\git-bash\\dist\\cli.js\" hook post-compact"
12
12
  }
13
13
  ],