oh-my-openagent 4.7.2 → 4.7.4

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 (35) hide show
  1. package/dist/cli/index.js +14 -12
  2. package/package.json +12 -12
  3. package/packages/omo-codex/plugin/components/rules/README.md +4 -4
  4. package/packages/omo-codex/plugin/components/rules/skills/rules/SKILL.md +2 -4
  5. package/packages/omo-codex/plugin/components/rules/src/config.ts +1 -5
  6. package/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts +2 -11
  7. package/packages/omo-codex/plugin/components/rules/src/rules/constants.ts +1 -7
  8. package/packages/omo-codex/plugin/components/rules/src/rules/engine.ts +3 -12
  9. package/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts +0 -5
  10. package/packages/omo-codex/plugin/components/rules/src/rules/sources.ts +13 -0
  11. package/packages/omo-codex/plugin/components/rules/src/rules/types.ts +2 -6
  12. package/packages/omo-codex/plugin/components/rules/test/agent-doc-sources.test.ts +119 -0
  13. package/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts +4 -2
  14. package/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts +4 -2
  15. package/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts +72 -1
  16. package/packages/omo-codex/plugin/components/rules/test/config.test.ts +22 -0
  17. package/packages/omo-codex/plugin/components/rules/test/dynamic-target-fingerprints.test.ts +71 -0
  18. package/packages/omo-codex/plugin/components/rules/test/engine.test.ts +52 -0
  19. package/packages/omo-codex/plugin/components/rules/test/finder.test.ts +2 -7
  20. package/packages/omo-codex/plugin/components/rules/test/formatter.test.ts +14 -14
  21. package/packages/omo-codex/plugin/components/rules/test/post-compact-test-fixture.ts +4 -2
  22. package/packages/omo-codex/plugin/components/rules/test/sources.test.ts +46 -0
  23. package/packages/omo-codex/plugin/components/ultrawork/README.md +2 -0
  24. package/packages/omo-codex/plugin/components/ultrawork/src/codex-hook.ts +62 -0
  25. package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +51 -0
  26. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +1 -1
  27. package/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts +5 -5
  28. package/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts +67 -3
  29. package/packages/omo-codex/plugin/components/ulw-loop/test/quality-gate.test.ts +51 -0
  30. package/packages/omo-codex/plugin/scripts/migrate-codex-config.mjs +15 -5
  31. package/packages/omo-codex/plugin/skills/rules/SKILL.md +2 -4
  32. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +1 -1
  33. package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +87 -1
  34. package/packages/omo-codex/scripts/install/config.mjs +2 -0
  35. package/packages/omo-codex/scripts/install-config-autonomous-features.test.mjs +11 -6
@@ -0,0 +1,71 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { fingerprintDynamicTargets } from "../src/dynamic-target-fingerprints.js";
7
+ import { defaultConfig } from "../src/rules/engine.js";
8
+
9
+ const tempDirectories: string[] = [];
10
+
11
+ afterEach(() => {
12
+ for (const directory of tempDirectories.splice(0)) {
13
+ rmSync(directory, { recursive: true, force: true });
14
+ }
15
+ });
16
+
17
+ describe("codex rules dynamic target fingerprints", () => {
18
+ it("#given auto source mode #when an excluded source file changes #then cache key stays stable", () => {
19
+ // given
20
+ const { cwd, targetPath } = makeProjectWithDefaultSources();
21
+ const config = defaultConfig();
22
+
23
+ // when
24
+ const initial = fingerprintDynamicTargets(cwd, [targetPath], config);
25
+ writeFileSync(
26
+ path.join(cwd, "AGENTS.md"),
27
+ [
28
+ "Always use the exact code style.",
29
+ "Updated guidance.",
30
+ "",
31
+ "This file should be excluded from auto mode.",
32
+ ].join("\n"),
33
+ );
34
+ const afterChange = fingerprintDynamicTargets(cwd, [targetPath], config);
35
+ writeFileSync(
36
+ path.join(cwd, ".github", "instructions", "workflow.md"),
37
+ ["---", "description: Workflow rules", 'globs: ["**/*.ts"]', "---", "Prefer explicit return types."].join(
38
+ "\n",
39
+ ),
40
+ );
41
+ const afterEnabledChange = fingerprintDynamicTargets(cwd, [targetPath], config);
42
+ const initialFingerprint = initial[0]?.fingerprint;
43
+
44
+ // then
45
+ expect(afterChange).toHaveLength(1);
46
+ expect(afterEnabledChange).toHaveLength(1);
47
+ expect(initialFingerprint).toBeDefined();
48
+ expect(afterChange[0]?.fingerprint).toBe(initialFingerprint);
49
+ expect(afterEnabledChange[0]?.fingerprint).not.toBe(initialFingerprint);
50
+ });
51
+ });
52
+
53
+ function makeProjectWithDefaultSources(): { cwd: string; targetPath: string } {
54
+ const root = mkdtempSync(path.join(tmpdir(), "codex-rules-fingerprint-"));
55
+ const projectRoot = path.join(root, "repo");
56
+ const instructionPath = path.join(projectRoot, ".github", "instructions");
57
+ const targetPath = path.join(projectRoot, "src", "app.ts");
58
+ tempDirectories.push(root);
59
+
60
+ mkdirSync(instructionPath, { recursive: true });
61
+ mkdirSync(path.dirname(targetPath), { recursive: true });
62
+ writeFileSync(path.join(projectRoot, "package.json"), "{}");
63
+ writeFileSync(path.join(projectRoot, "AGENTS.md"), "Default project AGENTS file.");
64
+ writeFileSync(path.join(projectRoot, ".github", "copilot-instructions.md"), "Legacy copilot instruction");
65
+ writeFileSync(
66
+ path.join(instructionPath, "workflow.md"),
67
+ ["---", "description: Workflow rules", 'globs: ["**/*.ts"]', "---", "Keep async/await explicit."].join("\n"),
68
+ );
69
+ writeFileSync(targetPath, "export const answer = 42;\n");
70
+ return { cwd: projectRoot, targetPath };
71
+ }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
+ import { configFromEnvironment } from "../src/config.js";
4
5
  import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
5
6
  import { matchRule as defaultMatchRule } from "../src/rules/matcher.js";
6
7
  import type { RuleCandidate } from "../src/rules/types.js";
@@ -190,3 +191,54 @@ describe("rule engine dynamic matching", () => {
190
191
  expect(matchCalls).toBe(2);
191
192
  });
192
193
  });
194
+
195
+ describe("rule engine default source selection", () => {
196
+ it("#given auto source selection #when loading static rules #then Codex-native and Claude-home sources are disabled by default", () => {
197
+ // given
198
+ let capturedDisabledSources: ReadonlySet<string> | undefined;
199
+ const deps = {
200
+ findProjectRoot: () => projectRoot,
201
+ findCandidates: (options) => {
202
+ capturedDisabledSources = options.disabledSources;
203
+ return [];
204
+ },
205
+ readFile: () => null,
206
+ } satisfies EngineDeps;
207
+ const engine = createEngine(defaultConfig(), deps);
208
+
209
+ // when
210
+ engine.loadStaticRules(projectRoot);
211
+
212
+ // then
213
+ expect(capturedDisabledSources?.has("AGENTS.md")).toBe(true);
214
+ expect(capturedDisabledSources?.has("~/.claude/rules")).toBe(true);
215
+ expect(capturedDisabledSources?.has("~/.claude/CLAUDE.md")).toBe(true);
216
+ expect(capturedDisabledSources?.has("CLAUDE.md")).toBe(false);
217
+ });
218
+
219
+ it("#given removed agent-doc sources and a real source are requested #when loading static rules #then only real sources are enabled", () => {
220
+ // given
221
+ let capturedDisabledSources: ReadonlySet<string> | undefined;
222
+ const deps = {
223
+ findProjectRoot: () => projectRoot,
224
+ findCandidates: (options) => {
225
+ capturedDisabledSources = options.disabledSources;
226
+ return [];
227
+ },
228
+ readFile: () => null,
229
+ } satisfies EngineDeps;
230
+ const engine = createEngine(
231
+ configFromEnvironment({ CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,~/.claude/CLAUDE.md,plugin-bundled" }),
232
+ deps,
233
+ );
234
+
235
+ // when
236
+ engine.loadStaticRules(projectRoot);
237
+
238
+ // then
239
+ expect(capturedDisabledSources?.has("AGENTS.md")).toBe(false);
240
+ expect(capturedDisabledSources?.has("~/.claude/CLAUDE.md")).toBe(false);
241
+ expect(capturedDisabledSources?.has("plugin-bundled")).toBe(false);
242
+ expect(capturedDisabledSources?.has(".omo/rules")).toBe(true);
243
+ });
244
+ });
@@ -24,6 +24,7 @@ function makeProject(): { projectRoot: string; homeRoot: string; targetPath: str
24
24
  mkdirSync(join(homeRoot, ".config", "opencode"), { recursive: true });
25
25
  writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "fixture" }));
26
26
  writeFileSync(join(projectRoot, "AGENTS.md"), "Project rule\n");
27
+ writeFileSync(join(projectRoot, "CLAUDE.md"), "Claude rule\n");
27
28
  writeFileSync(join(projectRoot, "src", ".omo", "rules", "local.md"), "Local rule\n");
28
29
  writeFileSync(join(projectRoot, ".omo", "rules", "root.md"), "Root rule\n");
29
30
  writeFileSync(join(homeRoot, ".opencode", "rules", "global.md"), "Global rule\n");
@@ -54,9 +55,7 @@ describe("findRuleCandidates", () => {
54
55
  expect(candidates.map(candidateSummary)).toEqual([
55
56
  ".omo/rules:0:src/.omo/rules/local.md",
56
57
  ".omo/rules:1:.omo/rules/root.md",
57
- "AGENTS.md:1:AGENTS.md",
58
58
  "~/.opencode/rules:9999:.opencode/rules/global.md",
59
- "~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
60
59
  ]);
61
60
  });
62
61
 
@@ -73,10 +72,7 @@ describe("findRuleCandidates", () => {
73
72
  });
74
73
 
75
74
  // then
76
- expect(candidates.map(candidateSummary)).toEqual([
77
- "AGENTS.md:1:AGENTS.md",
78
- "~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
79
- ]);
75
+ expect(candidates.map(candidateSummary)).toEqual([]);
80
76
  });
81
77
 
82
78
  it("#given skip user home #when finding candidates #then only project rules are returned", () => {
@@ -96,7 +92,6 @@ describe("findRuleCandidates", () => {
96
92
  expect(candidates.map(candidateSummary)).toEqual([
97
93
  ".omo/rules:0:src/.omo/rules/local.md",
98
94
  ".omo/rules:1:.omo/rules/root.md",
99
- "AGENTS.md:1:AGENTS.md",
100
95
  ]);
101
96
  });
102
97
  });
@@ -12,8 +12,8 @@ describe("rules formatter hook context", () => {
12
12
  it("#given multiline dynamic rules #when formatting PostToolUse context #then labels and bodies render on separate lines", () => {
13
13
  // given
14
14
  const rule = loadedRule({
15
- path: "/repo/packages/AGENTS.md",
16
- relativePath: "packages/AGENTS.md",
15
+ path: "/repo/packages/CONTEXT.md",
16
+ relativePath: "packages/CONTEXT.md",
17
17
  body: ["# packages", "", "## OVERVIEW", "23 sibling packages.", "", "## CONVENTIONS", "Use npm."].join("\n"),
18
18
  });
19
19
 
@@ -29,7 +29,7 @@ describe("rules formatter hook context", () => {
29
29
  [
30
30
  "Additional project instructions matched for packages/omo-codex/plugin/components/ulw-loop/src/paths.ts:",
31
31
  "",
32
- "Instructions from: /repo/packages/AGENTS.md",
32
+ "Instructions from: /repo/packages/CONTEXT.md",
33
33
  "",
34
34
  "# packages",
35
35
  "",
@@ -45,8 +45,8 @@ describe("rules formatter hook context", () => {
45
45
  it("#given static rules #when formatting SessionStart context #then it avoids leading blank lines", () => {
46
46
  // given
47
47
  const rule = loadedRule({
48
- path: "/repo/AGENTS.md",
49
- relativePath: "AGENTS.md",
48
+ path: "/repo/CONTEXT.md",
49
+ relativePath: "CONTEXT.md",
50
50
  body: "Keep generated hook context readable.",
51
51
  });
52
52
 
@@ -58,7 +58,7 @@ describe("rules formatter hook context", () => {
58
58
  [
59
59
  "## Project Instructions",
60
60
  "",
61
- "Instructions from: /repo/AGENTS.md",
61
+ "Instructions from: /repo/CONTEXT.md",
62
62
  "",
63
63
  "Keep generated hook context readable.",
64
64
  ].join("\n"),
@@ -82,13 +82,13 @@ describe("rules formatter hook context", () => {
82
82
  it("#given duplicate static rules with different line endings #when formatting context #then it renders one copy", () => {
83
83
  // given
84
84
  const lfRule = loadedRule({
85
- path: "/repo/AGENTS.md",
86
- relativePath: "AGENTS.md",
85
+ path: "/repo/CONTEXT.md",
86
+ relativePath: "CONTEXT.md",
87
87
  body: "Shared rule\nKeep one copy.",
88
88
  });
89
89
  const crlfRule = loadedRule({
90
- path: "/repo/packages/AGENTS.md",
91
- relativePath: "packages/AGENTS.md",
90
+ path: "/repo/packages/CONTEXT.md",
91
+ relativePath: "packages/CONTEXT.md",
92
92
  body: "Shared rule\r\nKeep one copy.",
93
93
  });
94
94
 
@@ -97,7 +97,7 @@ describe("rules formatter hook context", () => {
97
97
 
98
98
  // then
99
99
  expect(occurrenceCount(block, "Shared rule\nKeep one copy.")).toBe(1);
100
- expect(block).not.toContain("/repo/packages/AGENTS.md");
100
+ expect(block).not.toContain("/repo/packages/CONTEXT.md");
101
101
  });
102
102
 
103
103
  it("#given multiple oversized rules #when formatting under a tight result budget #then every rule receives a fair truncated share with a read-full guide", () => {
@@ -145,9 +145,9 @@ function loadedRule(input: {
145
145
  readonly source?: RuleSource;
146
146
  readonly matchReason?: MatchReason;
147
147
  }): LoadedRule {
148
- const path = input.path ?? "/repo/AGENTS.md";
149
- const relativePath = input.relativePath ?? "AGENTS.md";
150
- const source = input.source ?? "AGENTS.md";
148
+ const path = input.path ?? "/repo/CONTEXT.md";
149
+ const relativePath = input.relativePath ?? "CONTEXT.md";
150
+ const source = input.source ?? "CONTEXT.md";
151
151
  return {
152
152
  path,
153
153
  realPath: path,
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { CodexPostCompactInput, CodexSessionStartInput, CodexUserPromptSubmitInput } from "../src/codex-hook.js";
6
6
 
7
7
  export const PROJECT_RULES_ENV = {
8
- CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
8
+ CODEX_RULES_ENABLED_SOURCES: "CONTEXT.md,.omo/rules",
9
9
  CODEX_RULES_MAX_RESULT_CHARS: "50000",
10
10
  CODEX_RULES_MAX_RULE_CHARS: "30000",
11
11
  };
@@ -30,7 +30,9 @@ export function makeOversizedProject(prefix = "budget"): { root: string; pluginD
30
30
  const pluginData = mkdtempSync(path.join(tmpdir(), `codex-rules-post-compact-${prefix}-data-`));
31
31
  tempDirectories.push(root, pluginData);
32
32
  writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
33
- writeFileSync(path.join(root, "AGENTS.md"), `Project rule\n${"A".repeat(30_000)}`);
33
+ writeFileSync(path.join(root, "AGENTS.md"), "Project AGENTS.md should stay Codex-native.");
34
+ writeFileSync(path.join(root, "CLAUDE.md"), "Project CLAUDE.md should stay outside rules hook context.");
35
+ writeFileSync(path.join(root, "CONTEXT.md"), `Project rule\n${"A".repeat(30_000)}`);
34
36
  mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
35
37
  writeFileSync(
36
38
  path.join(root, ".omo", "rules", "typescript.md"),
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { SOURCE_PRIORITY } from "../src/rules/constants.js";
4
+ import { defaultConfig } from "../src/rules/engine.js";
5
+ import { disabledSourcesFromConfig } from "../src/rules/sources.js";
6
+ import type { PiRulesConfig } from "../src/rules/types.js";
7
+
8
+ describe("rules source selection", () => {
9
+ it("#given default config #when disabled sources are derived #then opt-out sources stay disabled", () => {
10
+ // given
11
+ const config = defaultConfig();
12
+ const expected = new Set(["AGENTS.md", "~/.claude/rules", "~/.claude/CLAUDE.md"]);
13
+
14
+ // when
15
+ const disabledSources = disabledSourcesFromConfig(config);
16
+ expect(disabledSources).toBeDefined();
17
+ if (disabledSources === undefined) return;
18
+
19
+ // then
20
+ expect(disabledSources).toEqual(expected);
21
+ });
22
+
23
+ it("#given explicit enabled sources #when disabled source set is derived #then all other known sources are omitted", () => {
24
+ // given
25
+ const config: PiRulesConfig = {
26
+ ...defaultConfig(),
27
+ enabledSources: [".omo/rules", "plugin-bundled"],
28
+ };
29
+
30
+ // when
31
+ const disabledSources = disabledSourcesFromConfig(config);
32
+ expect(disabledSources).toBeDefined();
33
+ if (disabledSources === undefined) return;
34
+
35
+ // then
36
+ for (const source of [".omo/rules", "plugin-bundled"]) {
37
+ expect(disabledSources.has(source)).toBe(false);
38
+ }
39
+ expect(disabledSources.has("~/.claude/rules")).toBe(true);
40
+ expect(disabledSources).toEqual(
41
+ new Set(
42
+ [...SOURCE_PRIORITY.keys()].filter((source) => source !== ".omo/rules" && source !== "plugin-bundled"),
43
+ ),
44
+ );
45
+ });
46
+ });
@@ -35,6 +35,8 @@ node ${PLUGIN_ROOT}/dist/cli.js hook user-prompt-submit
35
35
 
36
36
  Codex passes the prompt payload on stdin. When the pattern `\b(?:ultrawork|ulw)\b` (case-insensitive) matches, the hook writes the directive to stdout — Codex injects non-JSON stdout as `additional_context` for the next turn. Otherwise the hook writes nothing and exits 0. Malformed input also exits 0 to never block the turn.
37
37
 
38
+ If a prior `UserPromptSubmit` hook output in transcript JSONL already contains `<ultrawork-mode>`, the hook suppresses itself so the same directive is not injected repeatedly. Plain transcript text containing `<ultrawork-mode>` is ignored unless it comes from hook output.
39
+
38
40
  Bundled agent role TOMLs in `agents/` ship to `CODEX_HOME/agents/` at install time, not via a runtime hook. The installer creates a symlink on Linux / macOS and a file copy on Windows (because symlinks require admin privileges or Developer Mode). For the public marketplace, the source is the stable installed-marketplace snapshot, not the versioned plugin cache, so agent role configs remain valid when Codex replaces `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/` during auto-update. Both code paths overwrite stale files and write a `.installed-agents.json` manifest next to the source root for clean uninstall tracking.
39
41
 
40
42
  ## Smoke test
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
3
3
  import { ULTRAWORK_DIRECTIVE } from "./directive.js";
4
4
 
5
5
  const ULTRAWORK_PATTERN = /\b(?:ultrawork|ulw)\b/i;
6
+ const ULTRAWORK_DIRECTIVE_MARKER = "<ultrawork-mode>";
7
+ const TRANSCRIPT_SEARCH_BYTES = 512_000;
6
8
  const CONTEXT_PRESSURE_MARKERS = [
7
9
  "context compacted",
8
10
  "context_length_exceeded",
@@ -29,10 +31,54 @@ interface UserPromptSubmitHookOutput {
29
31
  export function runUserPromptSubmitHook(input: unknown): string {
30
32
  if (!isCodexUserPromptSubmitInput(input)) return "";
31
33
  if (isContextPressureRecoveryPrompt(input.prompt)) return "";
34
+ if (hasUltraworkDirectiveAlreadyInTranscript(input.transcript_path)) return "";
32
35
  if (isContextPressureTranscript(input.transcript_path)) return "";
33
36
  return isUltraworkPrompt(input.prompt) ? formatAdditionalContextOutput(ULTRAWORK_DIRECTIVE) : "";
34
37
  }
35
38
 
39
+ function hasUltraworkDirectiveAlreadyInTranscript(transcriptPath: string | null | undefined): boolean {
40
+ if (transcriptPath === undefined || transcriptPath === null) return false;
41
+ try {
42
+ const rawTranscript = readTranscriptTail(transcriptPath);
43
+ for (const line of rawTranscript.split(/\r?\n/)) {
44
+ const parsed = parseJsonLine(line);
45
+ if (parsed === null) {
46
+ continue;
47
+ }
48
+
49
+ if (!isRecord(parsed)) {
50
+ continue;
51
+ }
52
+
53
+ const hookSpecificOutput = parsed["hookSpecificOutput"];
54
+ if (!isRecord(hookSpecificOutput)) {
55
+ continue;
56
+ }
57
+
58
+ if (hookSpecificOutput["hookEventName"] !== "UserPromptSubmit") {
59
+ continue;
60
+ }
61
+
62
+ if (
63
+ typeof hookSpecificOutput["additionalContext"] === "string" &&
64
+ hookSpecificOutput["additionalContext"].includes(ULTRAWORK_DIRECTIVE_MARKER)
65
+ ) {
66
+ return true;
67
+ }
68
+ }
69
+ } catch (error) {
70
+ if (error instanceof Error) return false;
71
+ throw error;
72
+ }
73
+
74
+ return false;
75
+ }
76
+
77
+ function readTranscriptTail(transcriptPath: string): string {
78
+ const rawTranscript = readFileSync(transcriptPath);
79
+ return rawTranscript.subarray(Math.max(0, rawTranscript.byteLength - TRANSCRIPT_SEARCH_BYTES)).toString("utf8");
80
+ }
81
+
36
82
  export function isUltraworkPrompt(prompt: string): boolean {
37
83
  return ULTRAWORK_PATTERN.test(prompt);
38
84
  }
@@ -68,6 +114,22 @@ function normalizeAdditionalContext(additionalContext: string): string {
68
114
  return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
69
115
  }
70
116
 
117
+ function parseJsonLine(line: string): unknown | null {
118
+ if (line.trim().length === 0) {
119
+ return null;
120
+ }
121
+
122
+ try {
123
+ const parsed: unknown = JSON.parse(line);
124
+ return parsed;
125
+ } catch (error) {
126
+ if (error instanceof Error) {
127
+ return null;
128
+ }
129
+ throw error;
130
+ }
131
+ }
132
+
71
133
  function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput {
72
134
  return (
73
135
  isRecord(value) &&
@@ -31,6 +31,49 @@ describe("codex ultrawork hook", () => {
31
31
  expect(parsed.hookSpecificOutput.additionalContext).toMatch(/First user-visible line this turn MUST be exactly:/);
32
32
  });
33
33
 
34
+ it("#given transcript already contains ultrawork directive #when hook sees ultrawork prompt #then it does not repeat directive", () => {
35
+ // given
36
+ const payload = {
37
+ hook_event_name: "UserPromptSubmit",
38
+ prompt: "please ulw this change",
39
+ transcript_path: writeTranscript(
40
+ JSON.stringify({
41
+ hookSpecificOutput: {
42
+ hookEventName: "UserPromptSubmit",
43
+ additionalContext: "<ultrawork-mode>\nexisting directive",
44
+ },
45
+ }),
46
+ ),
47
+ };
48
+
49
+ // when
50
+ const output = runUserPromptSubmitHook(payload);
51
+
52
+ // then
53
+ expect(output).toBe("");
54
+ });
55
+
56
+ it("#given transcript only mentions ultrawork marker in user content #when hook sees first ultrawork prompt #then it emits directive", () => {
57
+ // given
58
+ const payload = {
59
+ hook_event_name: "UserPromptSubmit",
60
+ prompt: "please ulw this change",
61
+ transcript_path: writeTranscript(
62
+ JSON.stringify({
63
+ role: "user",
64
+ content: "Please inspect text containing <ultrawork-mode> but do not activate yet.",
65
+ }),
66
+ ),
67
+ };
68
+
69
+ // when
70
+ const output = runUserPromptSubmitHook(payload);
71
+ const parsed = parseHookOutput(output);
72
+
73
+ // then
74
+ expect(parsed.hookSpecificOutput.additionalContext).toMatch(/^<ultrawork-mode>/);
75
+ });
76
+
34
77
  it("#given identifier-like ulw #when hook runs #then does not emit directive", () => {
35
78
  // given
36
79
  const payload = {
@@ -244,6 +287,14 @@ function writeContextPressureTranscript(): string {
244
287
  return transcriptPath;
245
288
  }
246
289
 
290
+ function writeTranscript(...lines: string[]): string {
291
+ const root = mkdtempSync(path.join(tmpdir(), "codex-ultrawork-transcript-"));
292
+ tempDirectories.push(root);
293
+ const transcriptPath = path.join(root, "transcript.jsonl");
294
+ writeFileSync(transcriptPath, `${lines.join("\n")}\n`);
295
+ return transcriptPath;
296
+ }
297
+
247
298
  function writeCodexContextWindowTranscript(): string {
248
299
  const root = mkdtempSync(path.join(tmpdir(), "codex-ultrawork-context-window-"));
249
300
  tempDirectories.push(root);
@@ -172,7 +172,7 @@ Trigger only when one goal remains and all its criteria are passing.
172
172
  1. Run targeted verification for changed behavior.
173
173
  2. Run `ai-slop-cleaner` on changed files. If no relevant edits exist, record a passed no-op cleaner report.
174
174
  3. Rerun verification after cleanup.
175
- 4. Run `$code-review`.
175
+ 4. Judge the change size. Spawn the `codex-ultrawork-reviewer` agent (`spawn_agent(agent_type="codex-ultrawork-reviewer", fork_turns="none", ...)`; fall back to `agent_type="worker"` with a scoped reviewer assignment if unavailable) only when the work is large or risky (multi-file, cross-cutting, new architecture, security/data surfaces, or you are unsure it is sound); for a small, local, low-risk change, do the review yourself and record `codeReview` with `evidence` starting `UNCONDITIONAL APPROVAL` plus a one-line justification of why the change was small enough to self-review.
176
176
  5. Clean review means `codeReview.recommendation == "APPROVE"` and `codeReview.architectStatus == "CLEAR"`.
177
177
  6. If review is non-clean, run `omo ulw-loop record-review-blockers --goal-id <id> --title "<...>" --objective "<...>" --evidence "<review findings>" --codex-goal-json <snapshot> --json`.
178
178
  7. If clean, checkpoint final completion:
@@ -100,18 +100,18 @@ function formatCriterionLine(criterion: UlwLoopSuccessCriterion): string {
100
100
 
101
101
  function finalSection(plan: UlwLoopPlan, goal: UlwLoopItem, isFinal: boolean, aggregate: boolean): string {
102
102
  if (!isFinal)
103
- return "- This is not the final ulw-loop story; do not run the final ai-slop-cleaner/$code-review gate yet.";
103
+ return "- This is not the final ulw-loop story; do not run the final ai-slop-cleaner/code-review gate yet.";
104
104
  const option = sessionOption(plan);
105
105
  const blockerCommand = `omo ulw-loop record-review-blockers${option} --goal-id ${goal.id} --title "Resolve final code-review blockers" --objective "<blocker-resolution objective>" --evidence "<review findings>" --codex-goal-json "<active get_goal JSON or path>"`;
106
106
  const checkpointCommand = `omo ulw-loop checkpoint${option} --goal-id ${goal.id} --status complete --evidence "<tests/files/PR evidence>" --codex-goal-json "<fresh complete get_goal JSON or path>" --quality-gate-json "<quality gate JSON or path>"`;
107
107
  return joinLines([
108
108
  "Final story — run mandatory quality gate before update_goal:",
109
- "- Run ai-slop-cleaner on changed files even when it is a no-op, rerun verification, then run $code-review.",
110
- "- If final $code-review is not APPROVE with architect status CLEAR, do not call update_goal. Record blocker work first:",
109
+ "- Run ai-slop-cleaner on changed files even when it is a no-op, rerun verification, then run the code review (spawn_agent(agent_type=\"codex-ultrawork-reviewer\", fork_turns=\"none\", ...); fall back to agent_type=\"worker\" with a scoped reviewer assignment if unavailable).",
110
+ "- If the final review is not APPROVE with architect status CLEAR, do not call update_goal. Record blocker work first:",
111
111
  ` ${blockerCommand}`,
112
112
  aggregate
113
- ? '- If final $code-review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint the aggregate story:'
114
- : '- If final $code-review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint:',
113
+ ? '- If the final review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint the aggregate story:'
114
+ : '- If the final review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint:',
115
115
  ` ${checkpointCommand}`,
116
116
  ]);
117
117
  }
@@ -12,6 +12,7 @@ const GHCR_PATTERN =
12
12
  /\b(ghcr|github container registry|read packages|imagepullsecret|package api|anonymous|container image)\b/;
13
13
  const GHCR_401_PATTERN = /\b(401|unauthorized|anonymous pull|authentication required)\b/;
14
14
  const GHCR_403_PATTERN = /\b(403|forbidden|read packages|package api)\b/;
15
+ const UNCONDITIONAL_APPROVAL_PATTERN = /\bUNCONDITIONAL\s+APPROVAL\b/i;
15
16
 
16
17
  function invalid(message: string, field: string): never {
17
18
  throw new UlwLoopError(message, "ULW_LOOP_QUALITY_GATE_INVALID", { details: { field } });
@@ -42,6 +43,58 @@ function stringArray(value: unknown, field: string): string[] {
42
43
  return value.map((item) => nonEmptyString(item, field));
43
44
  }
44
45
 
46
+ function normalizeReviewerField({
47
+ value,
48
+ field,
49
+ expectedValue,
50
+ evidenceApproved,
51
+ }: {
52
+ value: unknown;
53
+ field: string;
54
+ expectedValue: "APPROVE";
55
+ evidenceApproved: boolean;
56
+ }): "APPROVE";
57
+ function normalizeReviewerField({
58
+ value,
59
+ field,
60
+ expectedValue,
61
+ evidenceApproved,
62
+ }: {
63
+ value: unknown;
64
+ field: string;
65
+ expectedValue: "CLEAR";
66
+ evidenceApproved: boolean;
67
+ }): "CLEAR";
68
+ function normalizeReviewerField({
69
+ value,
70
+ field,
71
+ expectedValue,
72
+ evidenceApproved,
73
+ }: {
74
+ value: unknown;
75
+ field: string;
76
+ expectedValue: "APPROVE" | "CLEAR";
77
+ evidenceApproved: boolean;
78
+ }): "APPROVE" | "CLEAR" {
79
+ if (typeof value === "string") {
80
+ const trimmed = value.trim();
81
+ if (trimmed === "") {
82
+ if (evidenceApproved) return expectedValue;
83
+ invalid(
84
+ `${field} must be ${expectedValue} or codeReview.evidence should include UNCONDITIONAL APPROVAL.`,
85
+ field,
86
+ );
87
+ }
88
+ if (trimmed === expectedValue) return expectedValue;
89
+ invalid(`${field} must be ${expectedValue}.`, field);
90
+ }
91
+ if (value === undefined) {
92
+ if (evidenceApproved) return expectedValue;
93
+ invalid(`${field} must be ${expectedValue} or codeReview.evidence should include UNCONDITIONAL APPROVAL.`, field);
94
+ }
95
+ invalid(`${field} must be ${expectedValue}.`, field);
96
+ }
97
+
45
98
  export function validateQualityGate(input: unknown): UlwLoopQualityGate {
46
99
  const gate = section(input, "qualityGate");
47
100
  const cleaner = section(gate["aiSlopCleaner"], "aiSlopCleaner");
@@ -50,8 +103,6 @@ export function validateQualityGate(input: unknown): UlwLoopQualityGate {
50
103
  const coverage = section(gate["criteriaCoverage"], "criteriaCoverage");
51
104
  if (cleaner["status"] !== "passed") invalid("aiSlopCleaner.status must be passed.", "aiSlopCleaner.status");
52
105
  if (verification["status"] !== "passed") invalid("verification.status must be passed.", "verification.status");
53
- if (review["recommendation"] !== "APPROVE") invalid("recommendation must be APPROVE.", "codeReview.recommendation");
54
- if (review["architectStatus"] !== "CLEAR") invalid("architectStatus must be CLEAR.", "codeReview.architectStatus");
55
106
  const totalCriteria = numberField(coverage["totalCriteria"], "criteriaCoverage.totalCriteria");
56
107
  const passCount = numberField(coverage["passCount"], "criteriaCoverage.passCount");
57
108
  if (passCount < totalCriteria)
@@ -61,10 +112,23 @@ export function validateQualityGate(input: unknown): UlwLoopQualityGate {
61
112
  const cleanerEvidence = nonEmptyString(cleaner["evidence"], "aiSlopCleaner.evidence");
62
113
  const verificationEvidence = nonEmptyString(verification["evidence"], "verification.evidence");
63
114
  const reviewEvidence = nonEmptyString(review["evidence"], "codeReview.evidence");
115
+ const approvalEvidence = UNCONDITIONAL_APPROVAL_PATTERN.test(reviewEvidence);
116
+ const recommendation = normalizeReviewerField({
117
+ value: review["recommendation"],
118
+ field: "codeReview.recommendation",
119
+ expectedValue: "APPROVE",
120
+ evidenceApproved: approvalEvidence,
121
+ });
122
+ const architectStatus = normalizeReviewerField({
123
+ value: review["architectStatus"],
124
+ field: "codeReview.architectStatus",
125
+ expectedValue: "CLEAR",
126
+ evidenceApproved: approvalEvidence,
127
+ });
64
128
  const result: UlwLoopQualityGate = {
65
129
  aiSlopCleaner: { status: "passed", evidence: cleanerEvidence },
66
130
  verification: { status: "passed", commands, evidence: verificationEvidence },
67
- codeReview: { recommendation: "APPROVE", architectStatus: "CLEAR", evidence: reviewEvidence },
131
+ codeReview: { recommendation, architectStatus, evidence: reviewEvidence },
68
132
  };
69
133
  Object.assign(result, { criteriaCoverage: { totalCriteria, passCount, adversarialClassesCovered: covered } });
70
134
  return result;