oh-my-opencode 4.16.3 → 4.17.0

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 (177) hide show
  1. package/.agents/command/omomomo.md +1 -1
  2. package/.agents/skills/hyperplan/SKILL.md +3 -3
  3. package/.agents/skills/omomomo/SKILL.md +1 -1
  4. package/.opencode/command/omomomo.md +1 -1
  5. package/.opencode/skills/hyperplan/SKILL.md +3 -3
  6. package/.opencode/skills/pre-publish-review/SKILL.md +3 -0
  7. package/README.ja.md +4 -4
  8. package/README.ko.md +4 -4
  9. package/README.md +2 -2
  10. package/README.ru.md +4 -4
  11. package/README.zh-cn.md +4 -4
  12. package/dist/cli/index.js +179 -63
  13. package/dist/cli-node/index.js +179 -63
  14. package/dist/features/team-mode/test-support/async-test-helpers.d.ts +2 -2
  15. package/dist/index.js +121 -58
  16. package/dist/plugin/chat-message/types.d.ts +4 -0
  17. package/dist/plugin/command-execute-before.d.ts +1 -0
  18. package/dist/plugin/stop-continuation.d.ts +17 -0
  19. package/dist/skills/remove-ai-slops/SKILL.md +2 -2
  20. package/dist/skills/start-work/SKILL.md +5 -2
  21. package/dist/skills/ulw-plan/scripts/scaffold-plan.mjs +2 -2
  22. package/dist/tui.js +71 -29
  23. package/package.json +15 -14
  24. package/packages/git-bash-mcp/package.json +27 -0
  25. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +3 -1
  26. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +84 -19
  27. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  28. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  29. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  30. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  31. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  33. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  34. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/AGENTS.md +2 -2
  35. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/dist/cli.js +6 -2
  36. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/dist/codex-hook.js +6 -2
  37. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +2 -2
  38. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  39. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/src/codex-hook.ts +6 -2
  40. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/test/cli.test.ts +1 -1
  41. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/test/codex-hook.test.ts +67 -2
  42. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  43. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  44. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +0 -13
  45. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +1 -1
  46. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  47. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  48. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  49. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  50. package/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts +1 -79
  51. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  52. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  53. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  54. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  55. package/packages/omo-codex/plugin/components/ultrawork/agents/explorer.toml +2 -2
  56. package/packages/omo-codex/plugin/components/ultrawork/agents/lazycodex-code-reviewer.toml +2 -2
  57. package/packages/omo-codex/plugin/components/ultrawork/agents/lazycodex-gate-reviewer.toml +6 -6
  58. package/packages/omo-codex/plugin/components/ultrawork/agents/lazycodex-qa-executor.toml +5 -5
  59. package/packages/omo-codex/plugin/components/ultrawork/agents/lazycodex-worker-high.toml +26 -0
  60. package/packages/omo-codex/plugin/components/ultrawork/agents/{lazycodex-executor.toml → lazycodex-worker-low.toml} +6 -4
  61. package/packages/omo-codex/plugin/components/ultrawork/agents/lazycodex-worker-medium.toml +26 -0
  62. package/packages/omo-codex/plugin/components/ultrawork/agents/librarian.toml +2 -2
  63. package/packages/omo-codex/plugin/components/ultrawork/agents/plan.toml +4 -4
  64. package/packages/omo-codex/plugin/components/ultrawork/directive.md +55 -28
  65. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  66. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  67. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +55 -28
  68. package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/scripts/scaffold-plan.mjs +2 -2
  69. package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +22 -0
  70. package/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts +0 -68
  71. package/packages/omo-codex/plugin/components/ulw-loop/AGENTS.md +1 -1
  72. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +2 -0
  73. package/packages/omo-codex/plugin/components/ulw-loop/README.md +3 -1
  74. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +55 -28
  75. package/packages/omo-codex/plugin/components/ulw-loop/dist/checkpoint.js +6 -1
  76. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-subcommands.d.ts +1 -1
  77. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-subcommands.js +13 -2
  78. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +405 -25
  79. package/packages/omo-codex/plugin/components/ulw-loop/dist/codex-goal-instruction.js +12 -1
  80. package/packages/omo-codex/plugin/components/ulw-loop/dist/domain-types.d.ts +4 -2
  81. package/packages/omo-codex/plugin/components/ulw-loop/dist/paths.d.ts +7 -0
  82. package/packages/omo-codex/plugin/components/ulw-loop/dist/paths.js +16 -1
  83. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.js +1 -0
  84. package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate-verdicts.d.ts +6 -0
  85. package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate-verdicts.js +20 -0
  86. package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate.d.ts +1 -0
  87. package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate.js +12 -9
  88. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.d.ts +3 -0
  89. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +148 -0
  90. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.d.ts +2 -0
  91. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +209 -0
  92. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +25 -2
  93. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  94. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +14 -15
  95. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +29 -27
  96. package/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts +6 -1
  97. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-subcommands.ts +14 -3
  98. package/packages/omo-codex/plugin/components/ulw-loop/src/cli.ts +10 -0
  99. package/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts +12 -1
  100. package/packages/omo-codex/plugin/components/ulw-loop/src/domain-types.ts +4 -2
  101. package/packages/omo-codex/plugin/components/ulw-loop/src/paths.ts +27 -1
  102. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts +1 -0
  103. package/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate-verdicts.ts +23 -0
  104. package/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts +16 -9
  105. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +138 -0
  106. package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +208 -0
  107. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +12 -0
  108. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-entrypoint.test.ts +4 -1
  109. package/packages/omo-codex/plugin/components/ulw-loop/test/codex-goal-instruction.test.ts +8 -35
  110. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/quality-gate-builder.ts +24 -13
  111. package/packages/omo-codex/plugin/components/ulw-loop/test/package-smoke.test.ts +5 -2
  112. package/packages/omo-codex/plugin/components/ulw-loop/test/paths.test.ts +43 -8
  113. package/packages/omo-codex/plugin/components/ulw-loop/test/quality-gate.test.ts +55 -2
  114. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +228 -0
  115. package/packages/omo-codex/plugin/components/ulw-loop/test/stop-resume-hook.test.ts +193 -0
  116. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  118. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  119. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  120. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  121. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  122. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  123. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  124. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  125. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +18 -0
  126. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  127. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  128. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  129. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  130. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  131. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  132. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  133. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +17 -0
  134. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  135. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +2 -2
  136. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  137. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  138. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  139. package/packages/omo-codex/plugin/model-catalog.json +16 -7
  140. package/packages/omo-codex/plugin/package-lock.json +13 -13
  141. package/packages/omo-codex/plugin/package.json +1 -1
  142. package/packages/omo-codex/plugin/scripts/migrate-codex-config/catalog.mjs +16 -7
  143. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +8 -2
  144. package/packages/omo-codex/plugin/skills/init-deep/SKILL.md +2 -2
  145. package/packages/omo-codex/plugin/skills/refactor/SKILL.md +2 -2
  146. package/packages/omo-codex/plugin/skills/remove-ai-slops/SKILL.md +4 -4
  147. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +8 -2
  148. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +5 -2
  149. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +55 -28
  150. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +14 -15
  151. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +29 -27
  152. package/packages/omo-codex/plugin/skills/ulw-plan/scripts/scaffold-plan.mjs +2 -2
  153. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +2 -2
  154. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +2 -2
  155. package/packages/omo-codex/plugin/test/aggregate-agents.test.mjs +76 -16
  156. package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +23 -2
  157. package/packages/omo-codex/plugin/test/aggregate-manifest.test.mjs +1 -1
  158. package/packages/omo-codex/plugin/test/aggregate-model-catalog.test.mjs +4 -4
  159. package/packages/omo-codex/plugin/test/auto-update.test.mjs +4 -4
  160. package/packages/omo-codex/plugin/test/component-hook-contract-cases.mjs +2 -2
  161. package/packages/omo-codex/plugin/test/lcx-bug-skills.test.mjs +4 -101
  162. package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +14 -14
  163. package/packages/omo-codex/plugin/test/sync-skills-orchestration.test.mjs +11 -0
  164. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
  165. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +4 -2
  166. package/packages/omo-codex/plugin/test/ulw-plan-skill-contract.test.mjs +9 -40
  167. package/packages/omo-codex/plugin/test/ulw-research-skill-contract.test.mjs +4 -277
  168. package/packages/omo-codex/scripts/install-config-reasoning.test.mjs +5 -5
  169. package/packages/omo-codex/scripts/install-config.test.mjs +22 -3
  170. package/packages/omo-codex/scripts/install-dist/install-local.mjs +91 -21
  171. package/packages/shared-skills/skills/remove-ai-slops/SKILL.md +2 -2
  172. package/packages/shared-skills/skills/start-work/SKILL.md +5 -2
  173. package/packages/shared-skills/skills/ulw-plan/scripts/scaffold-plan.mjs +2 -2
  174. package/dist/skills/visual-qa/scripts/skill-prompt-contract.test.ts +0 -296
  175. package/packages/omo-codex/plugin/components/ulw-loop/test/skill-contract.test.ts +0 -70
  176. package/packages/omo-codex/plugin/test/ulw-research-epistemic-contract.test.mjs +0 -98
  177. package/packages/shared-skills/skills/visual-qa/scripts/skill-prompt-contract.test.ts +0 -296
@@ -0,0 +1,208 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ import { normalizeUlwLoopSessionId, ulwLoopDir } from "./paths.js";
5
+ import type { UlwLoopItem, UlwLoopPlan } from "./types.js";
6
+
7
+ // Turn-death recovery only: Codex emits Stop when a turn ends, so a run that
8
+ // dies mid-turn (crash, kill, context blowup before any Stop) never reaches
9
+ // this handler. Mid-turn stalls are out of scope by design.
10
+
11
+ const RESUME_CAP = 2;
12
+
13
+ // Mirrors start-work-continuation's context-pressure bail-out: injecting a
14
+ // resume directive into an already-overflowing context makes things worse.
15
+ const CONTEXT_PRESSURE_MARKERS = [
16
+ "context compacted",
17
+ "context_length_exceeded",
18
+ "skill descriptions were shortened",
19
+ "context_too_large",
20
+ "codex ran out of room in the model's context window",
21
+ "your input exceeds the context window",
22
+ "long threads and multiple compactions",
23
+ ] as const;
24
+
25
+ interface StopPayload {
26
+ readonly session_id: string;
27
+ readonly cwd: string;
28
+ readonly transcript_path: string;
29
+ readonly stop_hook_active: boolean;
30
+ }
31
+
32
+ export function runStopResumeHook(input: unknown): string {
33
+ const payload = parseStopPayload(input);
34
+ if (payload === null || payload.stop_hook_active) return "";
35
+ if (transcriptShowsContextPressure(payload.transcript_path)) return "";
36
+ if (boulderContinuationWillFire(payload.cwd, payload.session_id)) return "";
37
+ const stateDir = ulwLoopDir(payload.cwd, { sessionId: payload.session_id });
38
+ const plan = readPlan(join(stateDir, "goals.json"));
39
+ if (plan === null || plan.aggregateCompletion?.status === "complete") return "";
40
+ const goal = resumableGoal(plan);
41
+ if (goal === undefined) return "";
42
+ if (!consumeResumeBudget(stateDir, goal.id)) return "";
43
+ const output: { decision: "block"; reason: string } = {
44
+ decision: "block",
45
+ reason: renderResumeDirective(plan, goal, payload.session_id),
46
+ };
47
+ return JSON.stringify(output);
48
+ }
49
+
50
+ export async function runStopResumeHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise<void> {
51
+ try {
52
+ const chunks: Buffer[] = [];
53
+ for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
54
+ const output = runStopResumeHook(JSON.parse(Buffer.concat(chunks).toString("utf8")));
55
+ if (output.length > 0) stdout.write(output);
56
+ } catch (error) {
57
+ if (error instanceof Error) return;
58
+ }
59
+ }
60
+
61
+ function resumableGoal(plan: UlwLoopPlan): UlwLoopItem | undefined {
62
+ const active = plan.goals.find((goal) => goal.id === plan.activeGoalId);
63
+ if (active !== undefined && isResumableStatus(active.status)) return active;
64
+ return plan.goals.find((goal) => isResumableStatus(goal.status));
65
+ }
66
+
67
+ function isResumableStatus(status: UlwLoopItem["status"]): boolean {
68
+ return status === "pending" || status === "in_progress";
69
+ }
70
+
71
+ // Two-strike cap keyed on ledger movement: an unchanged ledger.jsonl line
72
+ // count across resumes means the loop is not progressing. The stuck marker is
73
+ // a separate file — a ledger append would change the count and self-reset.
74
+ function consumeResumeBudget(stateDir: string, goalId: string): boolean {
75
+ const ledgerLineCount = countLedgerLines(join(stateDir, "ledger.jsonl"));
76
+ const counterPath = join(stateDir, `auto-resume-${goalId}.json`);
77
+ const previous = readCounter(counterPath);
78
+ const count = previous !== null && previous.ledgerLineCount === ledgerLineCount ? previous.count : 0;
79
+ if (count >= RESUME_CAP) {
80
+ writeFileSync(join(stateDir, `auto-resume-${goalId}.stuck`), `no ledger progress after ${count} resumes\n`);
81
+ return false;
82
+ }
83
+ writeFileSync(counterPath, JSON.stringify({ count: count + 1, ledgerLineCount }));
84
+ return true;
85
+ }
86
+
87
+ function renderResumeDirective(plan: UlwLoopPlan, goal: UlwLoopItem, sessionId: string): string {
88
+ const normalized = normalizeUlwLoopSessionId(sessionId);
89
+ const option =
90
+ normalized !== null && plan.goalsPath.includes(`/${normalized}/`) ? ` --session-id ${normalized}` : "";
91
+ return [
92
+ `The ulw-loop run in this session still has unfinished goals (next: ${goal.id} — ${goal.title}).`,
93
+ "The turn ended before the loop completed. Resume it now:",
94
+ `1. Run \`omo ulw-loop status${option} --json\` to reload the plan, the active goal, and currentAttemptDir.`,
95
+ "2. Continue the active goal's remaining success criteria, recording evidence with record-evidence.",
96
+ `3. Checkpoint through \`omo ulw-loop checkpoint${option}\` when the goal's criteria are proven.`,
97
+ "If the loop is genuinely blocked on the user, checkpoint the goal as blocked with the reason instead.",
98
+ ].join("\n");
99
+ }
100
+
101
+ function readPlan(goalsPath: string): UlwLoopPlan | null {
102
+ try {
103
+ return JSON.parse(readFileSync(goalsPath, "utf8")) as UlwLoopPlan;
104
+ } catch (error) {
105
+ if (error instanceof Error) return null;
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ function countLedgerLines(ledgerPath: string): number {
111
+ try {
112
+ return readFileSync(ledgerPath, "utf8").split("\n").filter(Boolean).length;
113
+ } catch (error) {
114
+ if (error instanceof Error) return 0;
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ function readCounter(counterPath: string): { count: number; ledgerLineCount: number } | null {
120
+ try {
121
+ if (!existsSync(counterPath)) return null;
122
+ const parsed = JSON.parse(readFileSync(counterPath, "utf8")) as Record<string, unknown>;
123
+ if (typeof parsed["count"] !== "number" || typeof parsed["ledgerLineCount"] !== "number") return null;
124
+ return { count: parsed["count"], ledgerLineCount: parsed["ledgerLineCount"] };
125
+ } catch (error) {
126
+ if (error instanceof Error) return null;
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ // Local ~10-LOC approximation of start-work-continuation's boulder check (no
132
+ // cross-component import allowed): any continuable work for this session means
133
+ // that hook owns the Stop event, so this one stays silent.
134
+ function boulderContinuationWillFire(cwd: string, sessionId: string): boolean {
135
+ try {
136
+ const raw = JSON.parse(readFileSync(join(cwd, ".omo", "boulder.json"), "utf8")) as Record<string, unknown>;
137
+ const works = raw["works"];
138
+ // The flat legacy shape has no works map: the top level is the single work.
139
+ const entries = typeof works === "object" && works !== null ? Object.values(works) : [raw];
140
+ return entries.some((work) => {
141
+ if (typeof work !== "object" || work === null) return false;
142
+ const entry = work as Record<string, unknown>;
143
+ const sessionIds = Array.isArray(entry["session_ids"]) ? entry["session_ids"] : [];
144
+ const continuable = entry["status"] === "active" || entry["status"] === "paused";
145
+ return continuable && sessionIds.includes(`codex:${sessionId}`) && boulderPlanHasRemainingTask(cwd, entry);
146
+ });
147
+ } catch (error) {
148
+ if (error instanceof Error) return false;
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ function transcriptShowsContextPressure(transcriptPath: string): boolean {
154
+ try {
155
+ const transcript = readFileSync(transcriptPath, "utf8").toLowerCase();
156
+ return CONTEXT_PRESSURE_MARKERS.some((marker) => transcript.includes(marker));
157
+ } catch (error) {
158
+ if (error instanceof Error) return false;
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ // start-work-continuation only fires while its plan checklist has remaining
164
+ // items; a boulder work whose plan is exhausted (or unreadable) leaves the
165
+ // Stop event to this hook.
166
+ function boulderPlanHasRemainingTask(cwd: string, entry: Record<string, unknown>): boolean {
167
+ const activePlan = entry["active_plan"];
168
+ if (typeof activePlan !== "string" || activePlan.trim().length === 0) return false;
169
+ const planPath = isAbsolute(activePlan) ? activePlan : join(cwd, activePlan);
170
+ const worktree = entry["worktree_path"];
171
+ const candidates =
172
+ typeof worktree === "string" && worktree.trim().length > 0 && !isAbsolute(activePlan)
173
+ ? [join(isAbsolute(worktree) ? worktree : join(cwd, worktree), activePlan), planPath]
174
+ : [planPath];
175
+ for (const candidate of candidates) {
176
+ try {
177
+ return readFileSync(candidate, "utf8")
178
+ .split(/\r?\n/)
179
+ .some((line) => line.startsWith("- [ ] "));
180
+ } catch (error) {
181
+ if (!(error instanceof Error)) throw error;
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+
187
+ function parseStopPayload(value: unknown): StopPayload | null {
188
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
189
+ const record = value as Record<string, unknown>;
190
+ const optionalMessage = record["last_assistant_message"];
191
+ const valid =
192
+ record["hook_event_name"] === "Stop" &&
193
+ typeof record["session_id"] === "string" &&
194
+ typeof record["turn_id"] === "string" &&
195
+ typeof record["transcript_path"] === "string" &&
196
+ typeof record["cwd"] === "string" &&
197
+ typeof record["model"] === "string" &&
198
+ typeof record["permission_mode"] === "string" &&
199
+ typeof record["stop_hook_active"] === "boolean" &&
200
+ (optionalMessage === undefined || typeof optionalMessage === "string");
201
+ if (!valid) return null;
202
+ return {
203
+ session_id: record["session_id"] as string,
204
+ cwd: record["cwd"] as string,
205
+ transcript_path: record["transcript_path"] as string,
206
+ stop_hook_active: record["stop_hook_active"] as boolean,
207
+ };
208
+ }
@@ -103,6 +103,8 @@ describe("ulwLoopCommand create-goals", () => {
103
103
 
104
104
  it("#given completed default aggregate #when creating another default plan #then guides to a fresh session", async () => {
105
105
  await createPlan("- Finished");
106
+ expect(await ulwLoopCommand(["complete-goals"])).toBe(0);
107
+ resetOutput();
106
108
  for (const criterionId of ["C001", "C002", "C003"]) await passCriterion("G001-finished", criterionId);
107
109
  expect(
108
110
  await ulwLoopCommand([
@@ -128,6 +130,16 @@ describe("ulwLoopCommand create-goals", () => {
128
130
  expect(err.join("")).toContain("--force only");
129
131
  });
130
132
 
133
+ it("#given an active goal on a v2 plan #when reading status --json #then exposes the current attempt dir", async () => {
134
+ await createPlan();
135
+ expect(await ulwLoopCommand(["complete-goals"])).toBe(0);
136
+ resetOutput();
137
+
138
+ expect(await ulwLoopCommand(["status", "--json"])).toBe(0);
139
+
140
+ expect(stdoutJson()).toMatchObject({ currentAttemptDir: ".omo/evidence/ulw/session/G001-goal-a/a1" });
141
+ });
142
+
131
143
  it("#given two session ids #when creating goals #then writes isolated session-scoped plans", async () => {
132
144
  expect(await ulwLoopCommand(["create-goals", "--session-id", "session-A", "--brief", "- Alpha", "--json"])).toBe(
133
145
  0,
@@ -57,7 +57,10 @@ async function runCli(args: readonly string[], input = ""): Promise<CliResult> {
57
57
  }
58
58
 
59
59
  beforeAll(async () => {
60
- const build = await runProcess("npm", ["run", "build"], componentRoot);
60
+ const build =
61
+ process.platform === "win32"
62
+ ? await runProcess("cmd.exe", ["/c", "npm", "run", "build"], componentRoot)
63
+ : await runProcess("npm", ["run", "build"], componentRoot);
61
64
  expect(build.code, `npm run build failed:\n${build.stderr}`).toBe(0);
62
65
  }, 120_000);
63
66
 
@@ -58,53 +58,28 @@ describe("buildCodexGoalInstruction aggregate mode", () => {
58
58
  expect(text).toContain(".omo/ulw-loop/goals.json");
59
59
  });
60
60
 
61
- it("given aggregate mode when rendering create_goal payload then omits numeric limits", () => {
62
- const { json, text } = buildCodexGoalInstruction({
61
+ it("given aggregate mode when rendering create_goal payload then payload is the aggregate objective only", () => {
62
+ const { json } = buildCodexGoalInstruction({
63
63
  plan: makePlan({ codexGoalMode: "aggregate" }),
64
64
  goal: makeGoal(),
65
65
  });
66
66
  expect(json).toEqual({
67
67
  objective: ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE,
68
68
  });
69
- expect(text).toContain("objective only");
70
- expect(text).not.toContain('"status"');
71
- expect(text).toContain("Goals are unlimited");
72
- expect(text).not.toMatch(/token[_-]?budget/i);
73
69
  });
74
70
 
75
- it("instructs not to call update_goal mid-aggregate when not final", () => {
76
- const { text } = buildCodexGoalInstruction({
77
- plan: makePlan({ codexGoalMode: "aggregate" }),
78
- goal: makeGoal(),
79
- isFinal: false,
80
- });
81
- expect(text).toContain("do not call update_goal mid-aggregate");
82
- expect(text).toContain("checkpoint this OMO ledger story");
83
- expect(text).toContain("update_goal is reserved for the final story after the mandatory quality gate passes");
84
- });
85
-
86
- it("#given a non-final aggregate story #when rendering instructions #then defers the current final quality gate", () => {
71
+ it("#given a non-final aggregate story #when rendering instructions #then omits the final quality gate tokens", () => {
87
72
  const { text } = buildCodexGoalInstruction({
88
73
  plan: makePlan({ codexGoalMode: "aggregate" }),
89
74
  goal: makeGoal(),
90
75
  isFinal: false,
91
76
  });
92
77
 
93
- expect(text).toMatch(/not the final .*do not run .*quality gate/i);
94
- expect(text).toContain("checkpoint this OMO ledger story");
95
- expect(text).toContain("continue the remaining stories");
96
- });
97
-
98
- it("includes quality gate instruction when isFinal", () => {
99
- const { text } = buildCodexGoalInstruction({
100
- plan: makePlan({ codexGoalMode: "aggregate" }),
101
- goal: makeGoal(),
102
- isFinal: true,
103
- });
104
- expect(text).toMatch(/quality gate/i);
78
+ for (const role of FINAL_REVIEW_ROLES) expect(text).not.toContain(role);
79
+ expect(text).not.toContain("record-review-blockers");
105
80
  });
106
81
 
107
- it("#given a final aggregate story #when rendering instructions #then requires the current LazyCodex quality gate", () => {
82
+ it("#given a final aggregate story #when rendering instructions #then includes the quality gate roles, fields, and commands", () => {
108
83
  const { text } = buildCodexGoalInstruction({
109
84
  plan: makePlan({ codexGoalMode: "aggregate" }),
110
85
  goal: makeGoal(),
@@ -113,10 +88,8 @@ describe("buildCodexGoalInstruction aggregate mode", () => {
113
88
 
114
89
  expectTextToContainAll(text, FINAL_REVIEW_ROLES);
115
90
  expectTextToContainAll(text, QUALITY_GATE_SECTIONS);
116
- expect(text).toMatch(/targeted verification/i);
117
- expect(text).toMatch(/artifact path.*non-zero size/i);
118
- expectTextToContainAll(text, ["original brief", "desired user-visible outcome", "userOutcomeReview"]);
119
- expect(text).toMatch(/not clean.*do not call update_goal/i);
91
+ expectTextToContainAll(text, ["originalIntent", "desiredOutcome", "userOutcomeReview"]);
92
+ expect(text).toContain("update_goal");
120
93
  expect(text).toContain("record-review-blockers");
121
94
  expect(text).toContain("checkpoint");
122
95
  });
@@ -1,29 +1,40 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
- export const QA_DIR = ".omo/ulw-loop/qa";
4
+ // v2 evidence layout: artifacts must live inside the goal's current attempt dir
5
+ export function qaDirFor(goalId: string, attempt = 1, sessionId = "session"): string {
6
+ return `.omo/evidence/ulw/${sessionId}/${goalId}/a${attempt}`;
7
+ }
8
+
9
+ export const QA_DIR = qaDirFor("G001-finished");
5
10
  export const CODE_REVIEW_PATH = `${QA_DIR}/code-review.md`;
6
11
  export const GATE_REVIEW_PATH = `${QA_DIR}/gate-review.md`;
7
12
  export const CLI_PASS_PATH = `${QA_DIR}/cli-pass.txt`;
8
13
  export const REJECTION_LOG_PATH = `${QA_DIR}/rejection.txt`;
9
14
  export const MISSING_ARTIFACT_PATH = `${QA_DIR}/missing.txt`;
10
15
 
11
- export async function writeQualityGateArtifacts(repoRoot: string): Promise<void> {
12
- await mkdir(join(repoRoot, QA_DIR), { recursive: true });
13
- await writeFile(join(repoRoot, CODE_REVIEW_PATH), "code review approved\n", "utf8");
14
- await writeFile(join(repoRoot, GATE_REVIEW_PATH), "gate review approved\n", "utf8");
15
- await writeFile(join(repoRoot, CLI_PASS_PATH), "cli scenario passed\n", "utf8");
16
- await writeFile(join(repoRoot, REJECTION_LOG_PATH), "invalid gate rejected\n", "utf8");
16
+ export async function writeQualityGateArtifacts(repoRoot: string, qaDir = QA_DIR): Promise<void> {
17
+ await mkdir(join(repoRoot, qaDir), { recursive: true });
18
+ await writeFile(join(repoRoot, `${qaDir}/code-review.md`), "code review approved\n", "utf8");
19
+ await writeFile(join(repoRoot, `${qaDir}/gate-review.md`), "gate review approved\n", "utf8");
20
+ await writeFile(join(repoRoot, `${qaDir}/cli-pass.txt`), "cli scenario passed\n", "utf8");
21
+ await writeFile(join(repoRoot, `${qaDir}/rejection.txt`), "invalid gate rejected\n", "utf8");
17
22
  }
18
23
 
19
- export async function qualityGateJson(repoRoot: string, cliArtifactPath = CLI_PASS_PATH): Promise<string> {
20
- await writeQualityGateArtifacts(repoRoot);
24
+ export async function qualityGateJson(
25
+ repoRoot: string,
26
+ cliArtifactPath?: string,
27
+ goalId = "G001-finished",
28
+ ): Promise<string> {
29
+ const qaDir = qaDirFor(goalId);
30
+ await writeQualityGateArtifacts(repoRoot, qaDir);
31
+ const cliPath = cliArtifactPath ?? `${qaDir}/cli-pass.txt`;
21
32
  return JSON.stringify({
22
33
  codeReview: {
23
34
  by: "lazycodex-code-reviewer",
24
35
  recommendation: "APPROVE",
25
36
  codeQualityStatus: "CLEAR",
26
- reportPath: CODE_REVIEW_PATH,
37
+ reportPath: `${qaDir}/code-review.md`,
27
38
  evidence: "Reviewed implementation and tests; no blockers remain.",
28
39
  blockers: [],
29
40
  },
@@ -56,20 +67,20 @@ export async function qualityGateJson(repoRoot: string, cliArtifactPath = CLI_PA
56
67
  id: "artifact-cli-pass",
57
68
  kind: "cli-transcript",
58
69
  description: "CLI transcript for valid final checkpoint.",
59
- path: cliArtifactPath,
70
+ path: cliPath,
60
71
  },
61
72
  {
62
73
  id: "artifact-cli-reject",
63
74
  kind: "log",
64
75
  description: "Log proving invalid final checkpoint rejection.",
65
- path: REJECTION_LOG_PATH,
76
+ path: `${qaDir}/rejection.txt`,
66
77
  },
67
78
  ],
68
79
  },
69
80
  gateReview: {
70
81
  by: "lazycodex-gate-reviewer",
71
82
  recommendation: "APPROVE",
72
- reportPath: GATE_REVIEW_PATH,
83
+ reportPath: `${qaDir}/gate-review.md`,
73
84
  evidence: "Verified all criteria and artifact evidence.",
74
85
  blockers: [],
75
86
  },
@@ -141,7 +141,9 @@ describe("skills/ulw-loop/SKILL.md", () => {
141
141
  expect(text).toContain('- "ulw-loop"');
142
142
  });
143
143
 
144
- it("#given PATH omo lacks ulw-loop #when bootstrap runs #then falls back to cached ulw-loop CLI", async () => {
144
+ it.skipIf(process.platform === "win32")(
145
+ "#given PATH omo lacks ulw-loop #when bootstrap runs #then falls back to cached ulw-loop CLI",
146
+ async () => {
145
147
  const text = await readText("skills/ulw-loop/references/full-workflow.md");
146
148
  const bootstrap = bootstrapScriptFrom(text);
147
149
  const root = await mkdtemp(join(tmpdir(), "omo-ulw-loop-bootstrap-"));
@@ -191,7 +193,8 @@ describe("source LOC budget", () => {
191
193
  it("every source file stays at or under 250 pure LOC", async () => {
192
194
  const files = [
193
195
  "src/types.ts", "src/paths.ts", "src/plan-io.ts", "src/plan-crud.ts", "src/goal-status.ts",
194
- "src/evidence.ts", "src/quality-gate.ts", "src/checkpoint.ts", "src/review-blockers.ts",
196
+ "src/evidence.ts", "src/quality-gate.ts", "src/quality-gate-verdicts.ts", "src/checkpoint.ts", "src/review-blockers.ts",
197
+ "src/stop-resume-hook.ts", "src/spawn-guard.ts",
195
198
  "src/steering.ts", "src/codex-goal-instruction.ts", "src/codex-goal-snapshot.ts", "src/codex-hook.ts",
196
199
  "src/cli.ts", "src/cli-arg-parser.ts", "src/cli-output.ts", "src/cli-steering.ts", "src/cli-commands.ts",
197
200
  ];
@@ -1,6 +1,9 @@
1
+ import { join, posix, win32 } from "node:path";
2
+
1
3
  import { describe, expect, it } from "vitest";
2
4
 
3
5
  import {
6
+ isWithinAttemptDir,
4
7
  normalizeUlwLoopSessionId,
5
8
  repoRelative,
6
9
  ulwLoopBriefPath,
@@ -12,28 +15,28 @@ import {
12
15
  describe("ulwLoopDir(repo)", () => {
13
16
  it("returns repo + '/.omo/ulw-loop'", () => {
14
17
  // when/then
15
- expect(ulwLoopDir("/repo")).toBe("/repo/.omo/ulw-loop");
18
+ expect(ulwLoopDir("/repo")).toBe(join("/repo", ".omo", "ulw-loop"));
16
19
  });
17
20
 
18
21
  it("#given a session id #when resolving the loop dir #then scopes artifacts under that session", () => {
19
22
  // when/then
20
- expect(ulwLoopDir("/repo", { sessionId: "sess_abc" })).toBe("/repo/.omo/ulw-loop/sess_abc");
23
+ expect(ulwLoopDir("/repo", { sessionId: "sess_abc" })).toBe(join("/repo", ".omo", "ulw-loop", "sess_abc"));
21
24
  });
22
25
  });
23
26
 
24
27
  describe("ulw-loop*Path helpers", () => {
25
28
  it("compose artifact filenames under ulwLoopDir", () => {
26
29
  // when/then
27
- expect(ulwLoopBriefPath("/r")).toBe("/r/.omo/ulw-loop/brief.md");
28
- expect(ulwLoopGoalsPath("/r")).toBe("/r/.omo/ulw-loop/goals.json");
29
- expect(ulwLoopLedgerPath("/r")).toBe("/r/.omo/ulw-loop/ledger.jsonl");
30
+ expect(ulwLoopBriefPath("/r")).toBe(join("/r", ".omo", "ulw-loop", "brief.md"));
31
+ expect(ulwLoopGoalsPath("/r")).toBe(join("/r", ".omo", "ulw-loop", "goals.json"));
32
+ expect(ulwLoopLedgerPath("/r")).toBe(join("/r", ".omo", "ulw-loop", "ledger.jsonl"));
30
33
  });
31
34
 
32
35
  it("#given a session id #when composing artifact filenames #then returns session-scoped paths", () => {
33
36
  // when/then
34
- expect(ulwLoopBriefPath("/r", { sessionId: "session-A" })).toBe("/r/.omo/ulw-loop/session-A/brief.md");
35
- expect(ulwLoopGoalsPath("/r", { sessionId: "session-A" })).toBe("/r/.omo/ulw-loop/session-A/goals.json");
36
- expect(ulwLoopLedgerPath("/r", { sessionId: "session-A" })).toBe("/r/.omo/ulw-loop/session-A/ledger.jsonl");
37
+ expect(ulwLoopBriefPath("/r", { sessionId: "session-A" })).toBe(join("/r", ".omo", "ulw-loop", "session-A", "brief.md"));
38
+ expect(ulwLoopGoalsPath("/r", { sessionId: "session-A" })).toBe(join("/r", ".omo", "ulw-loop", "session-A", "goals.json"));
39
+ expect(ulwLoopLedgerPath("/r", { sessionId: "session-A" })).toBe(join("/r", ".omo", "ulw-loop", "session-A", "ledger.jsonl"));
37
40
  });
38
41
  });
39
42
 
@@ -60,3 +63,35 @@ describe("repoRelative", () => {
60
63
  expect(repoRelative("/elsewhere/file", "/repo")).toBe("/elsewhere/file");
61
64
  });
62
65
  });
66
+
67
+ describe("isWithinAttemptDir", () => {
68
+ const posixRoot = "/repo/.omo/evidence/ulw/s1/g1/a1";
69
+ const win32Root = "C:\\repo\\.omo\\evidence\\ulw\\s1\\g1\\a1";
70
+
71
+ it("#given a child artifact #when checked on posix #then it is contained", () => {
72
+ expect(isWithinAttemptDir(`${posixRoot}/cli-pass.txt`, posixRoot, posix)).toBe(true);
73
+ });
74
+
75
+ it("#given a child artifact #when checked with win32 separators #then it is contained", () => {
76
+ expect(isWithinAttemptDir(`${win32Root}\\cli-pass.txt`, win32Root, win32)).toBe(true);
77
+ });
78
+
79
+ it("#given the attempt root itself #when checked #then it is contained", () => {
80
+ expect(isWithinAttemptDir(posixRoot, posixRoot, posix)).toBe(true);
81
+ expect(isWithinAttemptDir(win32Root, win32Root, win32)).toBe(true);
82
+ });
83
+
84
+ it("#given a sibling dir sharing the prefix #when checked #then it is outside", () => {
85
+ expect(isWithinAttemptDir("/repo/.omo/evidence/ulw/s1/g1/a1x/f.txt", posixRoot, posix)).toBe(false);
86
+ expect(isWithinAttemptDir(`${win32Root}x\\f.txt`, win32Root, win32)).toBe(false);
87
+ });
88
+
89
+ it("#given a prior-attempt artifact #when checked #then it is outside", () => {
90
+ expect(isWithinAttemptDir("/repo/.omo/evidence/ulw/s1/g1/a0/f.txt", posixRoot, posix)).toBe(false);
91
+ expect(isWithinAttemptDir("C:\\repo\\.omo\\evidence\\ulw\\s1\\g1\\a0\\f.txt", win32Root, win32)).toBe(false);
92
+ });
93
+
94
+ it("#given a different-drive path #when checked on win32 #then it is outside", () => {
95
+ expect(isWithinAttemptDir("D:\\elsewhere\\f.txt", win32Root, win32)).toBe(false);
96
+ });
97
+ });
@@ -224,8 +224,8 @@ describe("validateQualityGate", () => {
224
224
  }),
225
225
  );
226
226
 
227
- // then
228
- expect(error.message).toContain("not_applicable");
227
+ // then — a reasonless not_applicable now fails on the missing reason field
228
+ expect(error.message).toContain("reason");
229
229
  });
230
230
 
231
231
  it("#given criteria coverage misses required criteria #when validated #then it is rejected", () => {
@@ -250,3 +250,56 @@ describe("validateQualityGate", () => {
250
250
  expect(error.message).toContain("criteriaCoverage.userOutcomeReview");
251
251
  });
252
252
  });
253
+
254
+ describe("quality gate middle states (WATCH / reasoned not_applicable)", () => {
255
+ it("#given codeQualityStatus WATCH with APPROVE #when validating #then the gate accepts", () => {
256
+ const gate = structuredClone(VALID_GATE);
257
+ (gate.codeReview as { codeQualityStatus: string }).codeQualityStatus = "WATCH";
258
+ expect(() => validateQualityGate(gate)).not.toThrow();
259
+ });
260
+
261
+ it("#given codeQualityStatus BLOCK #when validating #then the gate rejects", () => {
262
+ const gate = structuredClone(VALID_GATE);
263
+ (gate.codeReview as { codeQualityStatus: string }).codeQualityStatus = "BLOCK";
264
+ expect(() => validateQualityGate(gate)).toThrow(/codeQualityStatus/);
265
+ });
266
+
267
+ it("#given a reasoned not_applicable adversarial case #when validating #then the gate accepts", () => {
268
+ const gate = structuredClone(VALID_GATE);
269
+ (gate.manualQa.adversarialCases[0] as { verdict: string; reason?: string }).verdict = "not_applicable";
270
+ (gate.manualQa.adversarialCases[0] as { verdict: string; reason?: string }).reason = "doc-only change";
271
+ expect(() => validateQualityGate(gate)).not.toThrow();
272
+ });
273
+
274
+ it("#given a reasonless not_applicable adversarial case #when validating #then the gate rejects", () => {
275
+ const gate = structuredClone(VALID_GATE);
276
+ (gate.manualQa.adversarialCases[0] as { verdict: string }).verdict = "not_applicable";
277
+ expect(() => validateQualityGate(gate)).toThrow(/reason/);
278
+ });
279
+
280
+ it("#given a not_applicable surface evidence verdict #when validating #then the gate still rejects", () => {
281
+ const gate = structuredClone(VALID_GATE);
282
+ (gate.manualQa.surfaceEvidence[0] as { verdict: string }).verdict = "not_applicable";
283
+ expect(() => validateQualityGate(gate)).toThrow(/not_applicable/);
284
+ });
285
+ });
286
+
287
+ describe("validateQualityGate attempt containment", () => {
288
+ const ATTEMPT_OPTS = { ...FS_OPTS, currentAttemptDir: "test/fixtures/artifacts" } as const;
289
+
290
+ it("#given artifacts inside the current attempt dir #when validating #then the gate accepts", () => {
291
+ expect(() => validateQualityGate(makeGate(), ATTEMPT_OPTS)).not.toThrow();
292
+ });
293
+
294
+ it("#given an artifact outside the current attempt dir #when validating #then the gate rejects naming the path", () => {
295
+ const opts = { ...FS_OPTS, currentAttemptDir: "test/fixtures/elsewhere" } as const;
296
+ expect(() => validateQualityGate(makeGate(), opts)).toThrow(
297
+ /\(test\/fixtures\/artifacts\/cli-pass\.txt\) must point to an artifact from the current attempt \(test\/fixtures\/elsewhere\)/,
298
+ );
299
+ });
300
+
301
+ it("#given a sibling dir sharing the attempt dir prefix #when validating #then the gate still rejects", () => {
302
+ const opts = { ...FS_OPTS, currentAttemptDir: "test/fixtures/artifact" } as const;
303
+ expect(() => validateQualityGate(makeGate(), opts)).toThrow(/current attempt/);
304
+ });
305
+ });