oh-my-openagent 4.7.1 → 4.7.3

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.
package/dist/cli/index.js CHANGED
@@ -61669,7 +61669,7 @@ var {
61669
61669
  // package.json
61670
61670
  var package_default = {
61671
61671
  name: "oh-my-openagent",
61672
- version: "4.7.1",
61672
+ version: "4.7.3",
61673
61673
  description: "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
61674
61674
  main: "./dist/index.js",
61675
61675
  types: "dist/index.d.ts",
@@ -61804,17 +61804,17 @@ var package_default = {
61804
61804
  zod: "^4.4.3"
61805
61805
  },
61806
61806
  optionalDependencies: {
61807
- "oh-my-openagent-darwin-arm64": "4.7.1",
61808
- "oh-my-openagent-darwin-x64": "4.7.1",
61809
- "oh-my-openagent-darwin-x64-baseline": "4.7.1",
61810
- "oh-my-openagent-linux-arm64": "4.7.1",
61811
- "oh-my-openagent-linux-arm64-musl": "4.7.1",
61812
- "oh-my-openagent-linux-x64": "4.7.1",
61813
- "oh-my-openagent-linux-x64-baseline": "4.7.1",
61814
- "oh-my-openagent-linux-x64-musl": "4.7.1",
61815
- "oh-my-openagent-linux-x64-musl-baseline": "4.7.1",
61816
- "oh-my-openagent-windows-x64": "4.7.1",
61817
- "oh-my-openagent-windows-x64-baseline": "4.7.1"
61807
+ "oh-my-openagent-darwin-arm64": "4.7.3",
61808
+ "oh-my-openagent-darwin-x64": "4.7.3",
61809
+ "oh-my-openagent-darwin-x64-baseline": "4.7.3",
61810
+ "oh-my-openagent-linux-arm64": "4.7.3",
61811
+ "oh-my-openagent-linux-arm64-musl": "4.7.3",
61812
+ "oh-my-openagent-linux-x64": "4.7.3",
61813
+ "oh-my-openagent-linux-x64-baseline": "4.7.3",
61814
+ "oh-my-openagent-linux-x64-musl": "4.7.3",
61815
+ "oh-my-openagent-linux-x64-musl-baseline": "4.7.3",
61816
+ "oh-my-openagent-windows-x64": "4.7.3",
61817
+ "oh-my-openagent-windows-x64-baseline": "4.7.3"
61818
61818
  },
61819
61819
  overrides: {
61820
61820
  hono: "^4.12.18",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-openagent",
3
- "version": "4.7.1",
3
+ "version": "4.7.3",
4
4
  "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
5
5
  "main": "./dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -135,17 +135,17 @@
135
135
  "zod": "^4.4.3"
136
136
  },
137
137
  "optionalDependencies": {
138
- "oh-my-openagent-darwin-arm64": "4.7.1",
139
- "oh-my-openagent-darwin-x64": "4.7.1",
140
- "oh-my-openagent-darwin-x64-baseline": "4.7.1",
141
- "oh-my-openagent-linux-arm64": "4.7.1",
142
- "oh-my-openagent-linux-arm64-musl": "4.7.1",
143
- "oh-my-openagent-linux-x64": "4.7.1",
144
- "oh-my-openagent-linux-x64-baseline": "4.7.1",
145
- "oh-my-openagent-linux-x64-musl": "4.7.1",
146
- "oh-my-openagent-linux-x64-musl-baseline": "4.7.1",
147
- "oh-my-openagent-windows-x64": "4.7.1",
148
- "oh-my-openagent-windows-x64-baseline": "4.7.1"
138
+ "oh-my-openagent-darwin-arm64": "4.7.3",
139
+ "oh-my-openagent-darwin-x64": "4.7.3",
140
+ "oh-my-openagent-darwin-x64-baseline": "4.7.3",
141
+ "oh-my-openagent-linux-arm64": "4.7.3",
142
+ "oh-my-openagent-linux-arm64-musl": "4.7.3",
143
+ "oh-my-openagent-linux-x64": "4.7.3",
144
+ "oh-my-openagent-linux-x64-baseline": "4.7.3",
145
+ "oh-my-openagent-linux-x64-musl": "4.7.3",
146
+ "oh-my-openagent-linux-x64-musl-baseline": "4.7.3",
147
+ "oh-my-openagent-windows-x64": "4.7.3",
148
+ "oh-my-openagent-windows-x64-baseline": "4.7.3"
149
149
  },
150
150
  "overrides": {
151
151
  "hono": "^4.12.18",
@@ -26,7 +26,7 @@ Project-level sources:
26
26
  - `.github/instructions/**/*.md`
27
27
  - `.github/copilot-instructions.md`
28
28
 
29
- User-home sources are also supported by the ported engine when available.
29
+ User-home sources are also supported by the ported engine when available. `AGENTS.md` is not part of `auto` source selection because Codex already loads it as native project instructions, so re-injecting it through hooks duplicates context; opt into it explicitly with `CODEX_RULES_ENABLED_SOURCES` if you need hook-level migration behavior. Claude user-home sources (`~/.claude/rules`, `~/.claude/CLAUDE.md`) are also excluded from `auto` because they usually contain Claude Code runtime instructions rather than Codex rules; opt into them explicitly when you want that migration behavior.
30
30
 
31
31
  Markdown rule files may use frontmatter such as:
32
32
 
@@ -73,7 +73,7 @@ Use `CODEX_RULES_*` environment variables:
73
73
  | `CODEX_RULES_MODE` | `both`, `static`, `dynamic`, `off` | `both` |
74
74
  | `CODEX_RULES_MAX_RULE_CHARS` | positive integer | `12000` |
75
75
  | `CODEX_RULES_MAX_RESULT_CHARS` | positive integer | `40000` |
76
- | `CODEX_RULES_ENABLED_SOURCES` | comma-separated source names | `auto` |
76
+ | `CODEX_RULES_ENABLED_SOURCES` | comma-separated source names or `auto` | `auto` (excludes `AGENTS.md`, `~/.claude/rules`, `~/.claude/CLAUDE.md`) |
77
77
 
78
78
  For migration from `pi-rules`, equivalent `PI_RULES_*` variables are accepted as fallbacks.
79
79
 
@@ -1,11 +1,11 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { isSameOrChildPath, toPosixPath, uniqueStrings } from "./path-utils.js";
4
- import { SOURCE_PRIORITY } from "./rules/constants.js";
5
4
  import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
6
5
  import { hashContent } from "./rules/matcher.js";
7
6
  import { sortCandidates } from "./rules/ordering.js";
8
7
  import { findProjectRoot } from "./rules/project-root.js";
8
+ import { disabledSourcesFromConfig } from "./rules/sources.js";
9
9
  import type { PiRulesConfig, RuleCandidate } from "./rules/types.js";
10
10
 
11
11
  export interface DynamicTargetFingerprint {
@@ -19,7 +19,7 @@ export function fingerprintDynamicTargets(
19
19
  targetPaths: ReadonlyArray<string>,
20
20
  config: PiRulesConfig,
21
21
  ): DynamicTargetFingerprint[] {
22
- const disabledSources = disabledSourcesFor(config);
22
+ const disabledSources = disabledSourcesFromConfig(config);
23
23
  const discoveryCache = createRuleDiscoveryCache();
24
24
  const cwdProjectRoot = findProjectRoot(cwd);
25
25
  const fingerprints: DynamicTargetFingerprint[] = [];
@@ -84,15 +84,6 @@ function fileFingerprint(filePath: string): string {
84
84
  }
85
85
  }
86
86
 
87
- function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
88
- if (config.enabledSources === "auto") {
89
- return undefined;
90
- }
91
-
92
- const enabledSources = new Set(config.enabledSources);
93
- return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
94
- }
95
-
96
87
  function dynamicTargetCacheKey(targetPath: string): string {
97
88
  return toPosixPath(resolve(targetPath));
98
89
  }
@@ -15,13 +15,13 @@ import {
15
15
  DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
16
16
  DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
17
17
  PROJECT_SINGLE_FILES,
18
- SOURCE_PRIORITY,
19
18
  } from "./constants.js";
20
19
  import { createRuleDiscoveryCache, type RuleDiscoveryCache } from "./finder.js";
21
20
  import { formatDynamicBlock, formatStaticBlock } from "./formatter.js";
22
21
  import { hashContent, matchRule } from "./matcher.js";
23
22
  import { sortCandidates } from "./ordering.js";
24
23
  import { parseRule } from "./parser.js";
24
+ import { disabledSourcesFromConfig } from "./sources.js";
25
25
  import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";
26
26
 
27
27
  interface LoadedRuleContent {
@@ -97,7 +97,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
97
97
  projectRoot,
98
98
  targetFile: null,
99
99
  };
100
- const disabledSources = disabledSourcesFor(config);
100
+ const disabledSources = disabledSourcesFromConfig(config);
101
101
  if (disabledSources !== undefined) {
102
102
  findOptions.disabledSources = disabledSources;
103
103
  }
@@ -121,7 +121,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
121
121
  const seenRules = new Set<string>();
122
122
  const loadedRuleContent = new Map<string, LoadedRuleContent | null>();
123
123
  const projectMembership = new Map<string, boolean>();
124
- const disabledSources = disabledSourcesFor(config);
124
+ const disabledSources = disabledSourcesFromConfig(config);
125
125
  const discoveryCache = createRuleDiscoveryCache();
126
126
  const candidateDiscoveryCache: CandidateDiscoveryCache = new Map();
127
127
  const cwdProjectRoot = deps.findProjectRoot(cwd);
@@ -442,15 +442,6 @@ function staticMatchReason(rule: LoadedRule): MatchReason | null {
442
442
  return null;
443
443
  }
444
444
 
445
- function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
446
- if (config.enabledSources === "auto") {
447
- return undefined;
448
- }
449
-
450
- const enabledSources = new Set(config.enabledSources);
451
- return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
452
- }
453
-
454
445
  function isDedupedRootSingleFile(candidate: RuleCandidate, rootSingleFileSelected: boolean): boolean {
455
446
  return rootSingleFileSelected && isRootSingleFile(candidate);
456
447
  }
@@ -0,0 +1,17 @@
1
+ import { SOURCE_PRIORITY } from "./constants.js";
2
+ import type { PiRulesConfig, RuleSource } from "./types.js";
3
+
4
+ export const DEFAULT_AUTO_DISABLED_SOURCES: readonly RuleSource[] = [
5
+ "AGENTS.md",
6
+ "~/.claude/rules",
7
+ "~/.claude/CLAUDE.md",
8
+ ];
9
+
10
+ export function disabledSourcesFromConfig(config: PiRulesConfig): ReadonlySet<string> | undefined {
11
+ if (config.enabledSources === "auto") {
12
+ return new Set(DEFAULT_AUTO_DISABLED_SOURCES);
13
+ }
14
+
15
+ const enabledSources = new Set(config.enabledSources);
16
+ return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
17
+ }
@@ -211,6 +211,21 @@ describe("codex rules hooks", () => {
211
211
  expect(parsed.hookSpecificOutput?.additionalContext).toContain("Always wear safety goggles");
212
212
  });
213
213
 
214
+ it("#given default auto sources #when SessionStart runs #then native Codex AGENTS.md is not duplicated", async () => {
215
+ // given
216
+ const { root, pluginData } = makeTempProject();
217
+
218
+ // when
219
+ const output = await runSessionStartHook(sessionStartInput(root), {
220
+ pluginDataRoot: pluginData,
221
+ });
222
+
223
+ // then
224
+ const parsed = parseHookOutput(output);
225
+ expect(parsed.hookSpecificOutput?.additionalContext).toContain("## Project Instructions");
226
+ expect(parsed.hookSpecificOutput?.additionalContext).not.toContain("Always wear safety goggles");
227
+ });
228
+
214
229
  it("#given static context already injected #when UserPromptSubmit runs #then it emits no duplicate context", async () => {
215
230
  // given
216
231
  const { root, pluginData } = makeTempProject();
@@ -351,6 +366,23 @@ describe("codex rules hooks", () => {
351
366
  expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
352
367
  });
353
368
 
369
+ it("#given default auto sources #when excluded AGENTS.md changes #then PostToolUse fingerprint stays stable", async () => {
370
+ // given
371
+ const { root, pluginData } = makeTempProject();
372
+ const filePath = path.join(root, "src", "app.ts");
373
+ const input = postToolUseInput(root, filePath);
374
+ await runPostToolUseHook(input, { pluginDataRoot: pluginData });
375
+ const cachedState = readSessionCache(pluginData);
376
+ writeFileSync(path.join(root, "AGENTS.md"), "Native Codex instructions changed outside codex-rules auto.");
377
+
378
+ // when
379
+ const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData });
380
+
381
+ // then
382
+ expect(output).toBe("");
383
+ expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
384
+ });
385
+
354
386
  it("#given dynamic context remains in transcript but cache is missing #when PostToolUse repeats #then it emits no duplicate context", async () => {
355
387
  // given
356
388
  const { root, pluginData } = makeTempProject();
@@ -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 Codex-native and Claude-home sources are explicitly enabled #when loading static rules #then they are not disabled", () => {
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
+ });
@@ -0,0 +1,45 @@
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", "AGENTS.md"],
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", "AGENTS.md"]) {
37
+ expect(disabledSources.has(source)).toBe(false);
38
+ }
39
+ expect(disabledSources.has("plugin-bundled")).toBe(true);
40
+ expect(disabledSources.has("~/.claude/rules")).toBe(true);
41
+ expect(disabledSources).toEqual(
42
+ new Set([...SOURCE_PRIORITY.keys()].filter((source) => source !== ".omo/rules" && source !== "AGENTS.md")),
43
+ );
44
+ });
45
+ });
@@ -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);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -169,7 +169,9 @@ async function configPaths({ env, cwd }) {
169
169
  const codexHome = resolve(env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
170
170
  const paths = new Set([join(codexHome, "config.toml")]);
171
171
  for (const projectConfig of projectConfigPaths({ cwd, stopAt: homedir() })) {
172
- if (await pathExists(projectConfig)) paths.add(projectConfig);
172
+ if (!(await isRegularFile(projectConfig))) continue;
173
+ if (!(await isRegularDirectory(dirname(projectConfig)))) continue;
174
+ paths.add(projectConfig);
173
175
  }
174
176
  return [...paths];
175
177
  }
@@ -216,10 +218,18 @@ async function writeState(statePath, state) {
216
218
  await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
217
219
  }
218
220
 
219
- async function pathExists(path) {
221
+ async function isRegularFile(path) {
220
222
  try {
221
- await stat(path);
222
- return true;
223
+ return (await lstat(path)).isFile();
224
+ } catch (error) {
225
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ async function isRegularDirectory(path) {
231
+ try {
232
+ return (await lstat(path)).isDirectory();
223
233
  } catch (error) {
224
234
  if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
225
235
  throw error;
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import test from "node:test";
@@ -32,6 +32,66 @@ test("#given stale root reasoning config #when ensuring config #then replaces st
32
32
  assert.match(result, /\[features\]/);
33
33
  });
34
34
 
35
+ test("#given project .codex is a symlink #when migrating #then project config is skipped", async (t) => {
36
+ if (!(await canCreateSymlink("dir"))) t.skip("symbolic links are unavailable in this environment");
37
+
38
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-config-symlink-dir-"));
39
+ const codexHome = join(root, "codex-home");
40
+ const project = join(root, "project");
41
+ const projectNested = join(project, "nested");
42
+ const projectCodexDirectory = join(root, "project-codex-real");
43
+ const projectConfigTarget = join(projectCodexDirectory, "config.toml");
44
+ const projectConfig = join(project, ".codex", "config.toml");
45
+
46
+ await mkdir(codexHome, { recursive: true });
47
+ await mkdir(projectCodexDirectory, { recursive: true });
48
+ await mkdir(dirname(projectConfigTarget), { recursive: true });
49
+ await mkdir(projectNested, { recursive: true });
50
+ await writeFile(join(codexHome, "config.toml"), 'model = "gpt-5.2"\n');
51
+ await writeFile(projectConfigTarget, 'model = "gpt-5.2"\nmodel_context_window = 272000\n');
52
+ await rm(join(project, ".codex"), { recursive: true, force: true });
53
+ await symlink(projectCodexDirectory, join(project, ".codex"), "dir");
54
+
55
+ const result = await migrateCodexConfig({
56
+ env: {
57
+ CODEX_HOME: codexHome,
58
+ LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
59
+ },
60
+ cwd: projectNested,
61
+ });
62
+
63
+ assert.deepEqual(result.changed, [join(codexHome, "config.toml")]);
64
+ assert.match(await readFile(projectConfig, "utf8"), /model = "gpt-5\.2"/);
65
+ });
66
+
67
+ test("#given project config.toml is a symlink #when migrating #then project config is skipped", async (t) => {
68
+ if (!(await canCreateSymlink("file"))) t.skip("symbolic links are unavailable in this environment");
69
+
70
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-config-symlink-file-"));
71
+ const codexHome = join(root, "codex-home");
72
+ const project = join(root, "project");
73
+ const projectConfigDirectory = join(project, ".codex");
74
+ const projectConfig = join(projectConfigDirectory, "config.toml");
75
+ const realConfigSource = join(root, "shared-config.toml");
76
+
77
+ await mkdir(codexHome, { recursive: true });
78
+ await mkdir(projectConfigDirectory, { recursive: true });
79
+ await writeFile(join(codexHome, "config.toml"), 'model = "gpt-5.2"\n');
80
+ await writeFile(realConfigSource, 'model = "gpt-5.2"\nmodel_context_window = 272000\n');
81
+ await symlink(realConfigSource, projectConfig, "file");
82
+
83
+ const result = await migrateCodexConfig({
84
+ env: {
85
+ CODEX_HOME: codexHome,
86
+ LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
87
+ },
88
+ cwd: project,
89
+ });
90
+
91
+ assert.deepEqual(result.changed, [join(codexHome, "config.toml")]);
92
+ assert.match(await readFile(realConfigSource, "utf8"), /model = "gpt-5\.2"/);
93
+ });
94
+
35
95
  test("#given global and project-local stale Codex configs #when migrating #then both configs are forced to current defaults", async () => {
36
96
  const root = await mkdtemp(join(tmpdir(), "lazycodex-config-migration-"));
37
97
  const codexHome = join(root, "codex-home");
@@ -144,3 +204,29 @@ test("#given managed catalog state #when catalog version advances #then only pre
144
204
  assert.match(content, /model = "gpt-5\.5"/);
145
205
  assert.match(content, /model_context_window = 400000/);
146
206
  });
207
+
208
+ async function canCreateSymlink(type) {
209
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-symlink-capability-"));
210
+ const target = join(root, "target");
211
+ const link = join(root, "link");
212
+
213
+ try {
214
+ if (type === "dir") {
215
+ await mkdir(target, { recursive: true });
216
+ await symlink(target, link, "dir");
217
+ } else {
218
+ await writeFile(target, "");
219
+ await symlink(target, link, "file");
220
+ }
221
+
222
+ await rm(link);
223
+ await rm(target, { recursive: true, force: true });
224
+ await rm(root, { recursive: true, force: true });
225
+ return true;
226
+ } catch (error) {
227
+ await rm(root, { recursive: true, force: true });
228
+ if (!(error instanceof Error)) throw error;
229
+ if (error.code === "EPERM" || error.code === "EEXIST") return false;
230
+ return false;
231
+ }
232
+ }