oh-my-opencode 4.6.0 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/bin/version-mismatch.js +47 -0
  2. package/bin/version-mismatch.test.ts +120 -0
  3. package/dist/cli/codex-ulw-loop.d.ts +12 -0
  4. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +2 -0
  5. package/dist/cli/index.js +5999 -5542
  6. package/dist/cli/install-codex/codex-config-reasoning.d.ts +2 -1
  7. package/dist/cli/install-codex/codex-model-catalog.d.ts +13 -0
  8. package/dist/features/background-agent/concurrency.d.ts +1 -0
  9. package/dist/features/background-agent/process-cleanup.d.ts +6 -0
  10. package/dist/features/claude-code-session-state/state.d.ts +1 -0
  11. package/dist/features/opencode-skill-loader/index.d.ts +1 -0
  12. package/dist/features/opencode-skill-loader/opencode-config-skills-reader.d.ts +5 -0
  13. package/dist/features/tmux-subagent/attachable-session-status.d.ts +1 -1
  14. package/dist/features/tmux-subagent/session-status-parser.d.ts +1 -0
  15. package/dist/hooks/comment-checker/cli.d.ts +1 -0
  16. package/dist/hooks/tasks-todowrite-disabler/constants.d.ts +1 -1
  17. package/dist/index.js +4250 -3776
  18. package/dist/shared/command-executor/execute-hook-command.d.ts +2 -0
  19. package/dist/tools/skill/description-formatter.d.ts +5 -1
  20. package/dist/tools/skill/types.d.ts +1 -0
  21. package/package.json +13 -14
  22. package/packages/ast-grep-mcp/dist/cli.js +53 -9
  23. package/packages/lsp-tools-mcp/dist/lsp/process.js +1 -1
  24. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +13 -0
  25. package/packages/omo-codex/plugin/components/lsp/src/cli.ts +6 -2
  26. package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +13 -2
  27. package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +30 -79
  28. package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +116 -0
  29. package/packages/omo-codex/plugin/components/lsp/src/mutated-file-paths.ts +88 -0
  30. package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +206 -0
  31. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +5 -3
  32. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus.md +6 -4
  33. package/packages/omo-codex/plugin/components/rules/src/codex-hook-options.ts +1 -0
  34. package/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts +0 -2
  35. package/packages/omo-codex/plugin/components/rules/src/rules/finder.ts +15 -2
  36. package/packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts +4 -1
  37. package/packages/omo-codex/plugin/components/rules/test/windows-git-bash-bundled-rule.test.ts +28 -5
  38. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +1 -1
  39. package/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md +1 -1
  40. package/packages/omo-codex/plugin/components/ultrawork/README.md +1 -1
  41. package/packages/omo-codex/plugin/components/ultrawork/agents/codex-ultrawork-reviewer.toml +3 -1
  42. package/packages/omo-codex/plugin/components/ultrawork/agents/plan.toml +7 -7
  43. package/packages/omo-codex/plugin/components/ultrawork/directive.md +1 -1
  44. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +5 -4
  45. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +4 -3
  46. package/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts +12 -1
  47. package/packages/omo-codex/plugin/components/ulw-loop/test/checkpoint.test.ts +19 -1
  48. package/packages/omo-codex/plugin/hooks/hooks.json +24 -2
  49. package/packages/omo-codex/plugin/model-catalog.json +49 -0
  50. package/packages/omo-codex/plugin/scripts/auto-update.mjs +159 -0
  51. package/packages/omo-codex/plugin/scripts/migrate-codex-config.mjs +269 -0
  52. package/packages/omo-codex/plugin/scripts/sync-hook-status-messages.mjs +4 -9
  53. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +6 -6
  54. package/packages/omo-codex/plugin/skills/init-deep/SKILL.md +6 -6
  55. package/packages/omo-codex/plugin/skills/lcx-report-bug/SKILL.md +127 -0
  56. package/packages/omo-codex/plugin/skills/lcx-report-bug/agents/openai.yaml +9 -0
  57. package/packages/omo-codex/plugin/skills/refactor/SKILL.md +6 -6
  58. package/packages/omo-codex/plugin/skills/remove-ai-slops/SKILL.md +6 -6
  59. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +7 -7
  60. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +6 -6
  61. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +5 -4
  62. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +4 -3
  63. package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +17 -17
  64. package/packages/omo-codex/plugin/test/aggregate.test.mjs +188 -19
  65. package/packages/omo-codex/plugin/test/auto-update.test.mjs +129 -0
  66. package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +7 -27
  67. package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +146 -0
  68. package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +27 -1
  69. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +22 -0
  70. package/packages/omo-codex/scripts/install/cli-args.mjs +1 -1
  71. package/packages/omo-codex/scripts/install/config.mjs +2 -15
  72. package/packages/omo-codex/scripts/install/delegated-command.mjs +1 -1
  73. package/packages/omo-codex/scripts/install/legacy-bins.mjs +1 -0
  74. package/packages/omo-codex/scripts/install/model-catalog.mjs +66 -0
  75. package/packages/omo-codex/scripts/install/permissions.mjs +11 -0
  76. package/packages/omo-codex/scripts/install/reasoning-config.mjs +65 -7
  77. package/packages/omo-codex/scripts/install-bin-links.test.mjs +23 -0
  78. package/packages/omo-codex/scripts/install-config-autonomous-features.test.mjs +83 -0
  79. package/packages/omo-codex/scripts/install-config-reasoning.test.mjs +82 -3
  80. package/packages/omo-codex/scripts/install-config.test.mjs +5 -6
  81. package/packages/omo-codex/scripts/install-local-entrypoint.test.mjs +30 -2
  82. package/packages/omo-codex/scripts/install-local.mjs +1 -1
  83. package/packages/omo-codex/scripts/install-local.test.mjs +3 -1
  84. package/packages/shared-skills/skills/lcx-report-bug/SKILL.md +127 -0
  85. package/packages/shared-skills/skills/lcx-report-bug/agents/openai.yaml +9 -0
  86. package/packages/shared-skills/skills/review-work/SKILL.md +7 -7
  87. package/packages/shared-skills/skills/start-work/SKILL.md +6 -6
  88. package/packages/shared-skills/skills/ulw-plan/SKILL.md +11 -11
  89. package/postinstall.mjs +36 -3
  90. package/dist/cli/install-codex/codex-config-mcp.d.ts +0 -1
@@ -5,17 +5,30 @@ import test from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
8
+ const mcpPackageManifestPaths = ["../../lsp-tools-mcp/package.json", "../../ast-grep-mcp/package.json", "../../git-bash-mcp/package.json"];
9
+ const mcpPackageManifestExists = await Promise.all(mcpPackageManifestPaths.map(exists));
8
10
 
9
11
  async function readJson(relativePath) {
10
12
  return JSON.parse(await readFile(join(root, relativePath), "utf8"));
11
13
  }
12
14
 
15
+ async function exists(relativePath) {
16
+ try {
17
+ await stat(join(root, relativePath));
18
+ return true;
19
+ } catch (error) {
20
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
21
+ throw error;
22
+ }
23
+ }
24
+
13
25
  async function readComponentHookManifests() {
14
26
  const components = await readdir(join(root, "components"), { withFileTypes: true });
15
27
  const manifests = [];
16
28
  for (const entry of components) {
17
29
  if (!entry.isDirectory()) continue;
18
30
  const source = join("components", entry.name, "hooks", "hooks.json");
31
+ if (!(await exists(source))) continue;
19
32
  manifests.push({ source, hooks: await readJson(source) });
20
33
  }
21
34
  return manifests.sort((left, right) => left.source.localeCompare(right.source));
@@ -57,6 +70,18 @@ function findSpawnAgentTypes(content) {
57
70
  return [...agentTypes].sort();
58
71
  }
59
72
 
73
+ function findRoleSpecificSpawnsWithoutForkTurnsNone(content) {
74
+ const missingForkTurns = [];
75
+ const regex = /spawn_agent\(agent_type="([^"]+)"[^)]*\)/g;
76
+ for (const match of content.matchAll(regex)) {
77
+ const call = match[0];
78
+ if (!call.includes('fork_turns="none"')) {
79
+ missingForkTurns.push(call);
80
+ }
81
+ }
82
+ return missingForkTurns;
83
+ }
84
+
60
85
  test("#given aggregate plugin manifest #when inspected #then it owns the omo namespace", async () => {
61
86
  // given
62
87
  const manifest = await readJson(".codex-plugin/plugin.json");
@@ -99,6 +124,7 @@ test("#given isolated components #when hooks are inspected #then commands stay i
99
124
  "components/telemetry/dist/cli.js",
100
125
  "components/ulw-loop/dist/cli.js",
101
126
  "components/ultrawork/dist/cli.js",
127
+ "scripts/auto-update.mjs",
102
128
  ];
103
129
 
104
130
  // then
@@ -106,6 +132,23 @@ test("#given isolated components #when hooks are inspected #then commands stay i
106
132
  assert.match(text, new RegExp(marker.replaceAll("/", "\\/")));
107
133
  }
108
134
  assert.doesNotMatch(text, /codex-(comment-checker|lsp|rules|telemetry|ulw-loop|ultrawork)@/);
135
+ assert.equal(await exists("scripts/migrate-codex-config.mjs"), true);
136
+ });
137
+
138
+ test("#given aggregate PostCompact hooks #when hooks are inspected #then LSP diagnostics cache reset is registered", async () => {
139
+ // given
140
+ const hooks = await readJson("hooks/hooks.json");
141
+
142
+ // when
143
+ const lspPostCompactHooks = collectCommandHooks(hooks, "hooks/hooks.json").filter(
144
+ (hook) =>
145
+ hook.eventName === "PostCompact" &&
146
+ hook.handler.command === 'node "${PLUGIN_ROOT}/components/lsp/dist/cli.js" hook post-compact',
147
+ );
148
+
149
+ // then
150
+ assert.equal(lspPostCompactHooks.length, 1);
151
+ assert.equal(lspPostCompactHooks[0]?.handler.statusMessage, "LazyCodex(0.1.0): Resetting LSP Diagnostics Cache");
109
152
  });
110
153
 
111
154
  test("#given aggregate hook commands #when inspected #then every command exposes a Codex status message", async () => {
@@ -172,6 +215,24 @@ test("#given aggregate OMO plugin is enabled #when hooks are inspected #then she
172
215
  assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^Bash$", "^create_goal$"]);
173
216
  });
174
217
 
218
+ test("#given aggregate SessionStart hooks #when inspected #then LazyCodex auto-update is registered", async () => {
219
+ // given
220
+ const hooks = await readJson("hooks/hooks.json");
221
+ const text = JSON.stringify(hooks);
222
+
223
+ // when
224
+ const sessionStartCommands = collectCommandHooks(hooks, "hooks/hooks.json")
225
+ .filter(({ eventName }) => eventName === "SessionStart")
226
+ .map(({ handler }) => handler.command);
227
+ const autoUpdateGroup = hooks.hooks.SessionStart.find((group) => JSON.stringify(group).includes("scripts/auto-update.mjs"));
228
+
229
+ // then
230
+ assert.equal(autoUpdateGroup?.matcher, "^startup$");
231
+ assert.match(text, /scripts\/auto-update\.mjs/);
232
+ assert.match(text, /Checking Auto Update/);
233
+ assert(sessionStartCommands.some((command) => command.includes("scripts/auto-update.mjs")));
234
+ });
235
+
175
236
  test("#given aggregate MCP config #when inspected #then code MCPs reference package runtimes without package names", async () => {
176
237
  // given
177
238
  const packageJson = await readJson("package.json");
@@ -208,25 +269,29 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack
208
269
  assert.deepEqual(componentLocalMcpSources, []);
209
270
  });
210
271
 
211
- test("#given package-level MCP CLIs #when package metadata is inspected #then bin names use the omo prefix", async () => {
212
- // given
213
- const lspPackageJson = await readJson("../../lsp-tools-mcp/package.json");
214
- const astGrepPackageJson = await readJson("../../ast-grep-mcp/package.json");
215
- const gitBashPackageJson = await readJson("../../git-bash-mcp/package.json");
216
-
217
- // when
218
- const binNames = [
219
- ...Object.keys(lspPackageJson.bin ?? {}),
220
- ...Object.keys(astGrepPackageJson.bin ?? {}),
221
- ...Object.keys(gitBashPackageJson.bin ?? {}),
222
- ].sort();
272
+ test(
273
+ "#given package-level MCP CLIs #when package metadata is inspected #then bin names use the omo prefix",
274
+ { skip: mcpPackageManifestExists.some((exists) => !exists) },
275
+ async () => {
276
+ // given
277
+ const [lspPackageJson, astGrepPackageJson, gitBashPackageJson] = await Promise.all(
278
+ mcpPackageManifestPaths.map((path) => readJson(path)),
279
+ );
223
280
 
224
- // then
225
- assert.deepEqual(binNames, ["omo-ast-grep", "omo-git-bash", "omo-lsp"]);
226
- for (const name of binNames) {
227
- assert.match(name, /^omo-/);
228
- }
229
- });
281
+ // when
282
+ const binNames = [
283
+ ...Object.keys(lspPackageJson.bin ?? {}),
284
+ ...Object.keys(astGrepPackageJson.bin ?? {}),
285
+ ...Object.keys(gitBashPackageJson.bin ?? {}),
286
+ ].sort();
287
+
288
+ // then
289
+ assert.deepEqual(binNames, ["omo-ast-grep", "omo-git-bash", "omo-lsp"]);
290
+ for (const name of binNames) {
291
+ assert.match(name, /^omo-/);
292
+ }
293
+ },
294
+ );
230
295
 
231
296
  test("#given aggregate plugin build script #when inspected #then hook status and telemetry sync run before workspace builds", async () => {
232
297
  // given
@@ -261,7 +326,13 @@ test("#given component directories #when scanned #then only intentional resource
261
326
  const expectedComponentManifests = new Map([["rules", { hooks: "./hooks/hooks.json" }]]);
262
327
 
263
328
  // when
264
- const componentNames = components.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
329
+ const componentNames = [];
330
+ for (const entry of components) {
331
+ if (!entry.isDirectory()) continue;
332
+ if (!(await exists(join("components", entry.name, "package.json")))) continue;
333
+ componentNames.push(entry.name);
334
+ }
335
+ componentNames.sort();
265
336
 
266
337
  // then
267
338
  assert.deepEqual(componentNames, [
@@ -315,6 +386,65 @@ test("#given bundled Codex agents #when components/ultrawork/agents directory is
315
386
  }
316
387
  });
317
388
 
389
+ test("#given planner agent prompt #when inspected #then generated artifacts stay under .omo", async () => {
390
+ const prompt = await readFile(join(root, "components", "ultrawork", "agents", "plan.toml"), "utf8");
391
+
392
+ assert.match(prompt, /\.omo\/plans\/<slug>\.md/);
393
+ assert.match(prompt, /\.omo\/evidence\/task-<N>-<slug>\.<ext>/);
394
+ assert.doesNotMatch(prompt, /(?<!\.omo\/)plans\/<slug>\.md/);
395
+ assert.doesNotMatch(prompt, /(?<!\.omo\/)evidence\/task-/);
396
+ });
397
+
398
+ test("#given reviewer agent prompt #when inspected #then default model is ChatGPT-account compatible", async () => {
399
+ const prompt = await readFile(
400
+ join(root, "components", "ultrawork", "agents", "codex-ultrawork-reviewer.toml"),
401
+ "utf8",
402
+ );
403
+
404
+ assert.match(prompt, /^model\s*=\s*"gpt-5\.5"$/m);
405
+ assert.match(prompt, /^model_reasoning_effort\s*=\s*"xhigh"$/m);
406
+ assert.doesNotMatch(prompt, /^model\s*=\s*"gpt-5\.2"$/m);
407
+ assert.match(prompt, /ChatGPT account/);
408
+ });
409
+
410
+ test("#given bundled model catalog #when inspected #then default verifier and worker roles are pinned", async () => {
411
+ const catalog = JSON.parse(await readFile(join(root, "model-catalog.json"), "utf8"));
412
+
413
+ assert.equal(catalog.current.model, "gpt-5.5");
414
+ assert.equal(catalog.current.model_context_window, 400000);
415
+ assert.equal(catalog.current.model_reasoning_effort, "high");
416
+ assert.equal(catalog.current.plan_mode_reasoning_effort, "xhigh");
417
+ assert.deepEqual(catalog.roles.default, catalog.current);
418
+ assert.deepEqual(catalog.roles.verifier, {
419
+ model: "gpt-5.5",
420
+ model_reasoning_effort: "xhigh",
421
+ });
422
+ assert.deepEqual(catalog.roles.worker, {
423
+ model: "gpt-5.4",
424
+ model_reasoning_effort: "high",
425
+ });
426
+ });
427
+
428
+ test("#given Codex-facing orchestration surfaces #when inspected #then retired ChatGPT-account model names are not recommended", async () => {
429
+ const promptFiles = [
430
+ join(root, "skills", "ulw-loop", "references", "full-workflow.md"),
431
+ join(root, "components", "ulw-loop", "skills", "ulw-loop", "references", "full-workflow.md"),
432
+ join(root, "components", "ultrawork", "README.md"),
433
+ join(root, "components", "ultrawork", "CHANGELOG.md"),
434
+ join(root, "components", "rules", "src", "post-compact-budget.ts"),
435
+ ];
436
+
437
+ const staleReferences = [];
438
+ for (const promptPath of promptFiles) {
439
+ const content = await readFile(promptPath, "utf8");
440
+ if (/gpt-5\.(?:2|3-codex)/i.test(content)) {
441
+ staleReferences.push(`${basename(dirname(promptPath))}/${basename(promptPath)}`);
442
+ }
443
+ }
444
+
445
+ assert.deepEqual(staleReferences, []);
446
+ });
447
+
318
448
  test("#given synced skills with Codex compatibility guidance #when a bundled agent_type is referenced #then a matching TOML is bundled", async () => {
319
449
  const skillsDir = join(root, "skills");
320
450
  const skillEntries = await readdir(skillsDir, { withFileTypes: true });
@@ -343,3 +473,42 @@ test("#given synced skills with Codex compatibility guidance #when a bundled age
343
473
  assert.equal(basename(tomlPath), `${agentType}.toml`);
344
474
  }
345
475
  });
476
+
477
+ test('#given synced skills and bundled rules #when role-specific agents are spawned #then they set fork_turns="none"', async () => {
478
+ const skillsDir = join(root, "skills");
479
+ const skillEntries = await readdir(skillsDir, { withFileTypes: true });
480
+ const promptFiles = skillEntries
481
+ .filter((entry) => entry.isDirectory())
482
+ .map((entry) => join(skillsDir, entry.name, "SKILL.md"));
483
+ promptFiles.push(join(root, "components", "rules", "bundled-rules", "hephaestus.md"));
484
+
485
+ const missingForkTurns = [];
486
+ for (const promptPath of promptFiles) {
487
+ const content = await readFile(promptPath, "utf8");
488
+ for (const call of findRoleSpecificSpawnsWithoutForkTurnsNone(content)) {
489
+ missingForkTurns.push(`${basename(dirname(promptPath))}/${basename(promptPath)}: ${call}`);
490
+ }
491
+ }
492
+
493
+ assert.deepEqual(missingForkTurns, []);
494
+ });
495
+
496
+ test("#given long-running orchestration prompts #when waiting on child agents #then parent liveness is surfaced", async () => {
497
+ const promptFiles = [
498
+ join(root, "skills", "ulw-loop", "SKILL.md"),
499
+ join(root, "skills", "ulw-loop", "references", "full-workflow.md"),
500
+ join(root, "skills", "review-work", "SKILL.md"),
501
+ join(root, "skills", "start-work", "SKILL.md"),
502
+ join(root, "components", "rules", "bundled-rules", "hephaestus.md"),
503
+ ];
504
+
505
+ const missingLivenessGuidance = [];
506
+ for (const promptPath of promptFiles) {
507
+ const content = await readFile(promptPath, "utf8");
508
+ if (!content.includes("active subagent count") || !content.includes("last heartbeat")) {
509
+ missingLivenessGuidance.push(`${basename(dirname(promptPath))}/${basename(promptPath)}`);
510
+ }
511
+ }
512
+
513
+ assert.deepEqual(missingLivenessGuidance, []);
514
+ });
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { resolveAutoUpdatePlan, runAutoUpdateCheck } from "../scripts/auto-update.mjs";
8
+
9
+ test("#given auto update is disabled #when resolving plan #then no command is scheduled", () => {
10
+ const plan = resolveAutoUpdatePlan({
11
+ env: { LAZYCODEX_AUTO_UPDATE_DISABLED: "1" },
12
+ now: 1_000,
13
+ lastCheckedAt: 0,
14
+ });
15
+
16
+ assert.equal(plan.shouldRun, false);
17
+ assert.equal(plan.reason, "disabled");
18
+ });
19
+
20
+ test("#given stale state #when resolving plan #then installer update command is scheduled", () => {
21
+ const plan = resolveAutoUpdatePlan({
22
+ env: {},
23
+ now: 90_000_000,
24
+ lastCheckedAt: 0,
25
+ });
26
+
27
+ assert.equal(plan.shouldRun, true);
28
+ assert.deepEqual(plan.command, "npx");
29
+ assert.deepEqual(plan.args, ["--yes", "lazycodex-ai@latest", "install", "--no-tui", "--skip-auth"]);
30
+ });
31
+
32
+ test("#given recent state #when resolving plan #then update is throttled", () => {
33
+ const plan = resolveAutoUpdatePlan({
34
+ env: {},
35
+ now: 90_000_000,
36
+ lastCheckedAt: 89_999_000,
37
+ });
38
+
39
+ assert.equal(plan.shouldRun, false);
40
+ assert.equal(plan.reason, "throttled");
41
+ });
42
+
43
+ test("#given test command override #when running check #then records state and launches command", async () => {
44
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-auto-update-"));
45
+ const logPath = join(root, "spawn.log");
46
+ const statePath = join(root, "state.json");
47
+ const codexHome = join(root, "codex-home");
48
+
49
+ const result = await runAutoUpdateCheck({
50
+ env: {
51
+ CODEX_HOME: codexHome,
52
+ LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
53
+ LAZYCODEX_AUTO_UPDATE_STATE_PATH: statePath,
54
+ LAZYCODEX_AUTO_UPDATE_INTERVAL_MS: "0",
55
+ LAZYCODEX_AUTO_UPDATE_COMMAND: process.execPath,
56
+ LAZYCODEX_AUTO_UPDATE_ARGS_JSON: JSON.stringify(["-e", `require("node:fs").writeFileSync(${JSON.stringify(logPath)}, "ok")`]),
57
+ LAZYCODEX_AUTO_UPDATE_WAIT: "1",
58
+ },
59
+ now: 123_456,
60
+ });
61
+
62
+ assert.equal(result.started, true);
63
+ assert.equal(JSON.parse(await readFile(statePath, "utf8")).lastCheckedAt, 123_456);
64
+ assert.equal(await readFile(logPath, "utf8"), "ok");
65
+ assert.match(await readFile(join(codexHome, "config.toml"), "utf8"), /model = "gpt-5\.5"/);
66
+ });
67
+
68
+ test("#given active lock #when running check #then skips concurrent update", async () => {
69
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-auto-update-lock-"));
70
+ const statePath = join(root, "state.json");
71
+ const lockPath = join(root, "state.json.lock");
72
+ const codexHome = join(root, "codex-home");
73
+ await writeFile(lockPath, "locked\n");
74
+
75
+ const result = await runAutoUpdateCheck({
76
+ env: {
77
+ CODEX_HOME: codexHome,
78
+ LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
79
+ LAZYCODEX_AUTO_UPDATE_STATE_PATH: statePath,
80
+ LAZYCODEX_AUTO_UPDATE_LOCK_PATH: lockPath,
81
+ LAZYCODEX_AUTO_UPDATE_INTERVAL_MS: "0",
82
+ LAZYCODEX_AUTO_UPDATE_LOCK_STALE_MS: "600000",
83
+ },
84
+ now: 123_456,
85
+ });
86
+
87
+ assert.equal(result.started, false);
88
+ assert.equal(result.reason, "locked");
89
+ assert.match(await readFile(join(codexHome, "config.toml"), "utf8"), /model_context_window = 400000/);
90
+ });
91
+
92
+ test("#given throttled updater and stale Codex config #when running check #then config migration still runs", async () => {
93
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-auto-update-migration-"));
94
+ const statePath = join(root, "state.json");
95
+ const codexHome = join(root, "codex-home");
96
+ await writeFile(statePath, JSON.stringify({ lastCheckedAt: 99_999 }, null, 2));
97
+ await mkdir(codexHome, { recursive: true });
98
+ await writeFile(
99
+ join(codexHome, "config.toml"),
100
+ [
101
+ 'model = "gpt-5.2"',
102
+ "model_context_window = 272000",
103
+ 'model_reasoning_effort = "low"',
104
+ 'plan_mode_reasoning_effort = "medium"',
105
+ "",
106
+ "[features]",
107
+ "plugins = true",
108
+ "",
109
+ ].join("\n"),
110
+ );
111
+
112
+ const result = await runAutoUpdateCheck({
113
+ env: {
114
+ CODEX_HOME: codexHome,
115
+ LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
116
+ LAZYCODEX_AUTO_UPDATE_STATE_PATH: statePath,
117
+ },
118
+ now: 100_000,
119
+ });
120
+
121
+ const content = await readFile(join(codexHome, "config.toml"), "utf8");
122
+ assert.equal(result.started, false);
123
+ assert.equal(result.reason, "throttled");
124
+ assert.match(content, /model = "gpt-5\.5"/);
125
+ assert.match(content, /model_context_window = 400000/);
126
+ assert.match(content, /model_reasoning_effort = "high"/);
127
+ assert.match(content, /plan_mode_reasoning_effort = "xhigh"/);
128
+ assert.doesNotMatch(content, /gpt-5\.2/);
129
+ });
@@ -15,6 +15,7 @@ const root = dirname(dirname(fileURLToPath(import.meta.url)));
15
15
  const AGGREGATE_EXPECTED_LABELS = new Map([
16
16
  ["hooks/hooks.json:SessionStart:0:0", "Loading Project Rules"],
17
17
  ["hooks/hooks.json:SessionStart:1:0", "Recording Session Telemetry"],
18
+ ["hooks/hooks.json:SessionStart:2:0", "Checking Auto Update"],
18
19
  ["hooks/hooks.json:UserPromptSubmit:0:0", "Loading Project Rules"],
19
20
  ["hooks/hooks.json:UserPromptSubmit:1:0", "Checking Ultrawork Trigger"],
20
21
  ["hooks/hooks.json:UserPromptSubmit:2:0", "Checking Ulw-Loop Steering"],
@@ -23,6 +24,7 @@ const AGGREGATE_EXPECTED_LABELS = new Map([
23
24
  ["hooks/hooks.json:PostToolUse:0:1", "Checking LSP Diagnostics"],
24
25
  ["hooks/hooks.json:PostToolUse:1:0", "Matching Project Rules"],
25
26
  ["hooks/hooks.json:PostCompact:0:0", "Resetting Project Rule Cache"],
27
+ ["hooks/hooks.json:PostCompact:2:0", "Resetting LSP Diagnostics Cache"],
26
28
  ["hooks/hooks.json:Stop:0:0", "Checking Start-Work Continuation"],
27
29
  ["hooks/hooks.json:SubagentStop:0:0", "Checking Start-Work Continuation"],
28
30
  ]);
@@ -30,6 +32,7 @@ const AGGREGATE_EXPECTED_LABELS = new Map([
30
32
  const COMPONENT_EXPECTED_LABELS = new Map([
31
33
  ["components/comment-checker/hooks/hooks.json:PostToolUse:0:0", "Checking Comments"],
32
34
  ["components/lsp/hooks/hooks.json:PostToolUse:0:0", "Checking LSP Diagnostics"],
35
+ ["components/lsp/hooks/hooks.json:PostCompact:0:0", "Resetting LSP Diagnostics Cache"],
33
36
  ["components/rules/hooks/hooks.json:SessionStart:0:0", "Loading Project Rules"],
34
37
  ["components/rules/hooks/hooks.json:UserPromptSubmit:0:0", "Loading Project Rules"],
35
38
  ["components/rules/hooks/hooks.json:PostToolUse:0:0", "Matching Project Rules"],
@@ -69,25 +72,6 @@ async function readComponentHookManifests() {
69
72
  return manifests.sort((left, right) => left.source.localeCompare(right.source));
70
73
  }
71
74
 
72
- async function readComponentVersions() {
73
- const components = await readdir(join(root, "components"), { withFileTypes: true });
74
- const versions = new Map();
75
- for (const entry of components) {
76
- if (!entry.isDirectory()) continue;
77
- const packageJson = await readJson(join("components", entry.name, "package.json"));
78
- versions.set(entry.name, packageJson.version);
79
- }
80
- return versions;
81
- }
82
-
83
- function hookOwnerVersion(hook, aggregateVersion, componentVersions) {
84
- const command = hook.command;
85
- for (const [componentName, version] of componentVersions.entries()) {
86
- if (command.includes(`/components/${componentName}/dist/cli.js`)) return version;
87
- }
88
- return aggregateVersion;
89
- }
90
-
91
75
  function collectCommandHooks(hooks, source, version) {
92
76
  const commandHooks = [];
93
77
  const normalizedSource = source.replaceAll("\\", "/");
@@ -147,15 +131,15 @@ test("#given loose legacy status label #when normalizing #then removes OMO wordi
147
131
 
148
132
  test("#given aggregate comment-checker hook #when status is inspected #then it uses LazyCodex comments label", async () => {
149
133
  // given
134
+ const aggregateVersion = (await readJson(".codex-plugin/plugin.json")).version;
150
135
  const aggregateHooks = await readJson("hooks/hooks.json");
151
- const componentVersions = await readComponentVersions();
152
136
 
153
137
  // when
154
- const hooks = collectCommandHooks(aggregateHooks, "hooks/hooks.json", "0.1.0");
138
+ const hooks = collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion);
155
139
  const commentCheckerHook = hooks.find((hook) => hook.id === "hooks/hooks.json:PostToolUse:0:0");
156
140
 
157
141
  // then
158
- assert.equal(commentCheckerHook?.statusMessage, formatLazyCodexHookStatusMessage(componentVersions.get("comment-checker"), "Checking Comments"));
142
+ assert.equal(commentCheckerHook?.statusMessage, formatLazyCodexHookStatusMessage(aggregateVersion, "Checking Comments"));
159
143
  assert.doesNotMatch(JSON.stringify(aggregateHooks), /checking\s+OMO\s+comments/i);
160
144
  });
161
145
 
@@ -164,14 +148,10 @@ test("#given aggregate and component hooks #when status messages are inspected #
164
148
  const aggregateVersion = (await readJson(".codex-plugin/plugin.json")).version;
165
149
  const aggregateHooks = await readJson("hooks/hooks.json");
166
150
  const componentManifests = await readComponentHookManifests();
167
- const componentVersions = await readComponentVersions();
168
151
 
169
152
  // when
170
153
  const commandHooks = [
171
- ...collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion).map((hook) => ({
172
- ...hook,
173
- version: hookOwnerVersion(hook, aggregateVersion, componentVersions),
174
- })),
154
+ ...collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion),
175
155
  ...componentManifests.flatMap((manifest) => collectCommandHooks(manifest.hooks, manifest.source, manifest.version)),
176
156
  ];
177
157
  const expectedLabels = new Map([...AGGREGATE_EXPECTED_LABELS, ...COMPONENT_EXPECTED_LABELS]);
@@ -0,0 +1,146 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { ensureCodexReasoningConfig, migrateCodexConfig } from "../scripts/migrate-codex-config.mjs";
8
+
9
+ test("#given stale root reasoning config #when ensuring config #then replaces stale values without duplicate keys", () => {
10
+ const result = ensureCodexReasoningConfig(
11
+ [
12
+ 'model = "gpt-5.2"',
13
+ "model_context_window = 272000",
14
+ 'model_reasoning_effort = "low"',
15
+ 'plan_mode_reasoning_effort = "medium"',
16
+ "",
17
+ "[features]",
18
+ "plugins = true",
19
+ "",
20
+ ].join("\n"),
21
+ );
22
+
23
+ assert.equal(result.match(/^model\s*=/gm)?.length, 1);
24
+ assert.equal(result.match(/^model_context_window\s*=/gm)?.length, 1);
25
+ assert.equal(result.match(/^model_reasoning_effort\s*=/gm)?.length, 1);
26
+ assert.equal(result.match(/^plan_mode_reasoning_effort\s*=/gm)?.length, 1);
27
+ assert.match(result, /model = "gpt-5\.5"/);
28
+ assert.match(result, /model_context_window = 400000/);
29
+ assert.match(result, /model_reasoning_effort = "high"/);
30
+ assert.match(result, /plan_mode_reasoning_effort = "xhigh"/);
31
+ assert.doesNotMatch(result, /gpt-5\.2/);
32
+ assert.match(result, /\[features\]/);
33
+ });
34
+
35
+ test("#given global and project-local stale Codex configs #when migrating #then both configs are forced to current defaults", async () => {
36
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-config-migration-"));
37
+ const codexHome = join(root, "codex-home");
38
+ const project = join(root, "project", "nested");
39
+ const projectConfig = join(root, "project", ".codex", "config.toml");
40
+ await mkdir(codexHome, { recursive: true });
41
+ await mkdir(dirname(projectConfig), { recursive: true });
42
+ await writeFile(join(codexHome, "config.toml"), 'model = "gpt-5.2"\n');
43
+ await writeFile(projectConfig, 'model = "gpt-5.2"\nmodel_context_window = 272000\n');
44
+
45
+ const result = await migrateCodexConfig({
46
+ env: { CODEX_HOME: codexHome, LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json") },
47
+ cwd: project,
48
+ });
49
+
50
+ assert.deepEqual(result.changed.sort(), [join(codexHome, "config.toml"), projectConfig].sort());
51
+ assert.match(await readFile(join(codexHome, "config.toml"), "utf8"), /model = "gpt-5\.5"/);
52
+ assert.match(await readFile(projectConfig, "utf8"), /model_context_window = 400000/);
53
+ });
54
+
55
+ test("#given user-customized Codex model config #when migrating #then user values are preserved", async () => {
56
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-config-custom-"));
57
+ const codexHome = join(root, "codex-home");
58
+ await mkdir(codexHome, { recursive: true });
59
+ await writeFile(
60
+ join(codexHome, "config.toml"),
61
+ [
62
+ 'model = "gpt-5.4"',
63
+ "model_context_window = 123456",
64
+ 'model_reasoning_effort = "medium"',
65
+ 'plan_mode_reasoning_effort = "medium"',
66
+ "",
67
+ ].join("\n"),
68
+ );
69
+
70
+ const result = await migrateCodexConfig({
71
+ env: { CODEX_HOME: codexHome, LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json") },
72
+ cwd: root,
73
+ });
74
+
75
+ const content = await readFile(join(codexHome, "config.toml"), "utf8");
76
+ assert.deepEqual(result.changed, []);
77
+ assert.match(content, /model = "gpt-5\.4"/);
78
+ assert.match(content, /model_context_window = 123456/);
79
+ assert.match(content, /model_reasoning_effort = "medium"/);
80
+ assert.match(content, /plan_mode_reasoning_effort = "medium"/);
81
+ });
82
+
83
+ test("#given managed catalog state #when catalog version advances #then only previously managed config is updated", async () => {
84
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-config-catalog-state-"));
85
+ const codexHome = join(root, "codex-home");
86
+ const catalogPath = join(root, "catalog.json");
87
+ const statePath = join(root, "model-state.json");
88
+ await mkdir(codexHome, { recursive: true });
89
+ await writeFile(
90
+ catalogPath,
91
+ JSON.stringify(
92
+ {
93
+ version: "test.v1",
94
+ current: {
95
+ model: "gpt-5.4",
96
+ model_context_window: 1000000,
97
+ model_reasoning_effort: "high",
98
+ plan_mode_reasoning_effort: "xhigh",
99
+ },
100
+ managedProfiles: [],
101
+ },
102
+ null,
103
+ 2,
104
+ ),
105
+ );
106
+
107
+ const first = await migrateCodexConfig({
108
+ env: {
109
+ CODEX_HOME: codexHome,
110
+ LAZYCODEX_MODEL_CATALOG_PATH: catalogPath,
111
+ LAZYCODEX_MODEL_CATALOG_STATE_PATH: statePath,
112
+ },
113
+ cwd: root,
114
+ });
115
+ await writeFile(
116
+ catalogPath,
117
+ JSON.stringify(
118
+ {
119
+ version: "test.v2",
120
+ current: {
121
+ model: "gpt-5.5",
122
+ model_context_window: 400000,
123
+ model_reasoning_effort: "high",
124
+ plan_mode_reasoning_effort: "xhigh",
125
+ },
126
+ managedProfiles: [],
127
+ },
128
+ null,
129
+ 2,
130
+ ),
131
+ );
132
+ const second = await migrateCodexConfig({
133
+ env: {
134
+ CODEX_HOME: codexHome,
135
+ LAZYCODEX_MODEL_CATALOG_PATH: catalogPath,
136
+ LAZYCODEX_MODEL_CATALOG_STATE_PATH: statePath,
137
+ },
138
+ cwd: root,
139
+ });
140
+
141
+ const content = await readFile(join(codexHome, "config.toml"), "utf8");
142
+ assert.deepEqual(first.changed, [join(codexHome, "config.toml")]);
143
+ assert.deepEqual(second.changed, [join(codexHome, "config.toml")]);
144
+ assert.match(content, /model = "gpt-5\.5"/);
145
+ assert.match(content, /model_context_window = 400000/);
146
+ });