oh-my-opencode 4.7.3 → 4.7.5

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 (31) hide show
  1. package/dist/cli/index.js +80 -77
  2. package/package.json +13 -12
  3. package/packages/lsp-tools-mcp/package.json +52 -0
  4. package/packages/omo-codex/plugin/README.md +23 -0
  5. package/packages/omo-codex/plugin/components/rules/README.md +2 -2
  6. package/packages/omo-codex/plugin/components/rules/skills/rules/SKILL.md +2 -4
  7. package/packages/omo-codex/plugin/components/rules/src/config.ts +1 -5
  8. package/packages/omo-codex/plugin/components/rules/src/rules/constants.ts +1 -7
  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 +2 -6
  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 +41 -2
  16. package/packages/omo-codex/plugin/components/rules/test/config.test.ts +22 -0
  17. package/packages/omo-codex/plugin/components/rules/test/engine.test.ts +1 -1
  18. package/packages/omo-codex/plugin/components/rules/test/finder.test.ts +2 -7
  19. package/packages/omo-codex/plugin/components/rules/test/formatter.test.ts +14 -14
  20. package/packages/omo-codex/plugin/components/rules/test/post-compact-test-fixture.ts +4 -2
  21. package/packages/omo-codex/plugin/components/rules/test/sources.test.ts +5 -4
  22. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +1 -1
  23. package/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts +5 -5
  24. package/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts +67 -3
  25. package/packages/omo-codex/plugin/components/ulw-loop/test/quality-gate.test.ts +51 -0
  26. package/packages/omo-codex/plugin/scripts/auto-update.mjs +24 -2
  27. package/packages/omo-codex/plugin/skills/rules/SKILL.md +2 -4
  28. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +1 -1
  29. package/packages/omo-codex/plugin/test/auto-update.test.mjs +16 -0
  30. package/packages/omo-codex/scripts/install/config.mjs +2 -0
  31. package/packages/omo-codex/scripts/install-config-autonomous-features.test.mjs +11 -6
@@ -24,7 +24,7 @@ describe("rules source selection", () => {
24
24
  // given
25
25
  const config: PiRulesConfig = {
26
26
  ...defaultConfig(),
27
- enabledSources: [".omo/rules", "AGENTS.md"],
27
+ enabledSources: [".omo/rules", "plugin-bundled"],
28
28
  };
29
29
 
30
30
  // when
@@ -33,13 +33,14 @@ describe("rules source selection", () => {
33
33
  if (disabledSources === undefined) return;
34
34
 
35
35
  // then
36
- for (const source of [".omo/rules", "AGENTS.md"]) {
36
+ for (const source of [".omo/rules", "plugin-bundled"]) {
37
37
  expect(disabledSources.has(source)).toBe(false);
38
38
  }
39
- expect(disabledSources.has("plugin-bundled")).toBe(true);
40
39
  expect(disabledSources.has("~/.claude/rules")).toBe(true);
41
40
  expect(disabledSources).toEqual(
42
- new Set([...SOURCE_PRIORITY.keys()].filter((source) => source !== ".omo/rules" && source !== "AGENTS.md")),
41
+ new Set(
42
+ [...SOURCE_PRIORITY.keys()].filter((source) => source !== ".omo/rules" && source !== "plugin-bundled"),
43
+ ),
43
44
  );
44
45
  });
45
46
  });
@@ -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;
@@ -81,6 +81,57 @@ describe("validateQualityGate", () => {
81
81
  expect(gate).toMatchObject({ criteriaCoverage: { totalCriteria: 9, passCount: 9 } });
82
82
  });
83
83
 
84
+ it("infers APPROVE/CLEAR when clean reviewer evidence omits structured fields", () => {
85
+ // given
86
+ const input = makeGate({
87
+ codeReview: {
88
+ evidence: "UNCONDITIONAL APPROVAL\nAll criteria and QA evidence are complete.",
89
+ },
90
+ });
91
+
92
+ // when
93
+ const gate = validateQualityGate(input);
94
+
95
+ // then
96
+ expect(gate.codeReview.recommendation).toBe("APPROVE");
97
+ expect(gate.codeReview.architectStatus).toBe("CLEAR");
98
+ expect(gate.codeReview.evidence).toBe("UNCONDITIONAL APPROVAL\nAll criteria and QA evidence are complete.");
99
+ });
100
+
101
+ it("infers APPROVE/CLEAR when clean reviewer evidence has blank structured fields", () => {
102
+ // given
103
+ const input = makeGate({
104
+ codeReview: {
105
+ recommendation: "",
106
+ architectStatus: " ",
107
+ evidence: "UNCONDITIONAL APPROVAL\nAll criteria and QA evidence are complete.",
108
+ },
109
+ });
110
+
111
+ // when
112
+ const gate = validateQualityGate(input);
113
+
114
+ // then
115
+ expect(gate.codeReview.recommendation).toBe("APPROVE");
116
+ expect(gate.codeReview.architectStatus).toBe("CLEAR");
117
+ });
118
+
119
+ it("throws when reviewer fields are omitted and evidence has no approval verdict", () => {
120
+ // given
121
+ const input = makeGate({
122
+ codeReview: {
123
+ evidence: "review completed without an explicit verdict",
124
+ },
125
+ });
126
+
127
+ // when
128
+ const error = getQualityGateError(input);
129
+
130
+ // then
131
+ expect(error.code).toBe("ULW_LOOP_QUALITY_GATE_INVALID");
132
+ expect(error.message).toContain("UNCONDITIONAL APPROVAL");
133
+ });
134
+
84
135
  it("throws UlwLoopError when aiSlopCleaner missing", () => {
85
136
  // when
86
137
  const error = getQualityGateError(makeGate({ aiSlopCleaner: undefined }));
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn, spawnSync } from "node:child_process";
4
- import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { appendFile, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
@@ -40,14 +40,19 @@ export async function runAutoUpdateCheck({ env = process.env, now = Date.now() }
40
40
  if (!plan.shouldRun) return { started: false, reason: plan.reason };
41
41
 
42
42
  const lock = await acquireLock(resolveLockPath(env, statePath), now, env);
43
- if (lock === null) return { started: false, reason: "locked" };
43
+ if (lock === null) {
44
+ await appendUpdateLog(env, now, "locked");
45
+ return { started: false, reason: "locked" };
46
+ }
44
47
  try {
45
48
  await writeState(statePath, { lastCheckedAt: now });
49
+ await appendUpdateLog(env, now, "started", { command: plan.command, args: plan.args });
46
50
  if (env.LAZYCODEX_AUTO_UPDATE_WAIT === "1") {
47
51
  const result = spawnSync(plan.command, plan.args, {
48
52
  env: plan.env,
49
53
  stdio: "ignore",
50
54
  });
55
+ await appendUpdateLog(env, now, "finished", { status: result.status ?? 0 });
51
56
  return { started: true, status: result.status ?? 0 };
52
57
  }
53
58
 
@@ -94,6 +99,12 @@ function resolveStatePath(env) {
94
99
  return join(dataRoot, "auto-update.json");
95
100
  }
96
101
 
102
+ function resolveLogPath(env) {
103
+ if (env.LAZYCODEX_AUTO_UPDATE_LOG_PATH?.trim()) return env.LAZYCODEX_AUTO_UPDATE_LOG_PATH;
104
+ const dataRoot = env.PLUGIN_DATA?.trim() || join(homedir(), ".local", "share", "lazycodex");
105
+ return join(dataRoot, "auto-update.log");
106
+ }
107
+
97
108
  function resolveLockPath(env, statePath) {
98
109
  if (env.LAZYCODEX_AUTO_UPDATE_LOCK_PATH?.trim()) return env.LAZYCODEX_AUTO_UPDATE_LOCK_PATH;
99
110
  return `${statePath}.lock`;
@@ -145,6 +156,17 @@ async function writeState(statePath, state) {
145
156
  await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
146
157
  }
147
158
 
159
+ async function appendUpdateLog(env, now, event, details = {}) {
160
+ const logPath = resolveLogPath(env);
161
+ await mkdir(dirname(logPath), { recursive: true });
162
+ const entry = {
163
+ timestamp: new Date(now).toISOString(),
164
+ event,
165
+ ...details,
166
+ };
167
+ await appendFile(logPath, `${JSON.stringify(entry)}\n`);
168
+ }
169
+
148
170
  function parsePositiveInteger(value, fallback) {
149
171
  if (value === undefined || value === "") return fallback;
150
172
  const parsed = Number.parseInt(value, 10);
@@ -14,10 +14,8 @@ Dynamic `PostToolUse` output is injected as additional context and is deduplicat
14
14
 
15
15
  Supported project sources:
16
16
 
17
- - `AGENTS.md`
18
- - `CLAUDE.md`
19
17
  - `CONTEXT.md`
20
- - `.sisyphus/rules/**/*.md`
18
+ - `.omo/rules/**/*.md`
21
19
  - `.claude/rules/**/*.md`
22
20
  - `.cursor/rules/**/*.md`
23
21
  - `.github/instructions/**/*.md`
@@ -29,6 +27,6 @@ Supported environment knobs:
29
27
  - `CODEX_RULES_MODE=both|static|dynamic|off`
30
28
  - `CODEX_RULES_MAX_RULE_CHARS=<number>`
31
29
  - `CODEX_RULES_MAX_RESULT_CHARS=<number>`
32
- - `CODEX_RULES_ENABLED_SOURCES=AGENTS.md,.sisyphus/rules`
30
+ - `CODEX_RULES_ENABLED_SOURCES=CONTEXT.md,.omo/rules`
33
31
 
34
32
  The legacy `PI_RULES_*` variables are accepted as fallbacks for users migrating from `pi-rules`.
@@ -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:
@@ -43,6 +43,7 @@ test("#given recent state #when resolving plan #then update is throttled", () =>
43
43
  test("#given test command override #when running check #then records state and launches command", async () => {
44
44
  const root = await mkdtemp(join(tmpdir(), "lazycodex-auto-update-"));
45
45
  const logPath = join(root, "spawn.log");
46
+ const updateLogPath = join(root, "auto-update.log");
46
47
  const statePath = join(root, "state.json");
47
48
  const codexHome = join(root, "codex-home");
48
49
 
@@ -51,6 +52,7 @@ test("#given test command override #when running check #then records state and l
51
52
  CODEX_HOME: codexHome,
52
53
  LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
53
54
  LAZYCODEX_AUTO_UPDATE_STATE_PATH: statePath,
55
+ LAZYCODEX_AUTO_UPDATE_LOG_PATH: updateLogPath,
54
56
  LAZYCODEX_AUTO_UPDATE_INTERVAL_MS: "0",
55
57
  LAZYCODEX_AUTO_UPDATE_COMMAND: process.execPath,
56
58
  LAZYCODEX_AUTO_UPDATE_ARGS_JSON: JSON.stringify(["-e", `require("node:fs").writeFileSync(${JSON.stringify(logPath)}, "ok")`]),
@@ -62,6 +64,20 @@ test("#given test command override #when running check #then records state and l
62
64
  assert.equal(result.started, true);
63
65
  assert.equal(JSON.parse(await readFile(statePath, "utf8")).lastCheckedAt, 123_456);
64
66
  assert.equal(await readFile(logPath, "utf8"), "ok");
67
+ const updateLog = (await readFile(updateLogPath, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
68
+ assert.deepEqual(updateLog, [
69
+ {
70
+ timestamp: "1970-01-01T00:02:03.456Z",
71
+ event: "started",
72
+ command: process.execPath,
73
+ args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(logPath)}, "ok")`],
74
+ },
75
+ {
76
+ timestamp: "1970-01-01T00:02:03.456Z",
77
+ event: "finished",
78
+ status: 0,
79
+ },
80
+ ]);
65
81
  assert.match(await readFile(join(codexHome, "config.toml"), "utf8"), /model = "gpt-5\.5"/);
66
82
  });
67
83
 
@@ -44,6 +44,8 @@ export async function updateCodexConfig({
44
44
  config = removeStaleManagedAgentBlocks(config, new Set(agentConfigs.map((agentConfig) => agentConfig.name)));
45
45
  config = ensureFeatureEnabled(config, "plugins");
46
46
  config = ensureFeatureEnabled(config, "plugin_hooks");
47
+ config = ensureFeatureEnabled(config, "multi_agent");
48
+ config = ensureFeatureEnabled(config, "child_agents_md");
47
49
  config = ensureCodexReasoningConfig(config, await readCodexModelCatalog(repoRoot));
48
50
  config = ensureCodexMultiAgentV2Config(config);
49
51
  if (autonomousPermissions === true) config = ensureAutonomousPermissions(config);
@@ -6,7 +6,8 @@ import test from "node:test";
6
6
 
7
7
  import { updateCodexConfig } from "./install/config.mjs";
8
8
 
9
- const AUTONOMOUS_FEATURES = ["multi_agent", "child_agents_md", "unified_exec", "goals"];
9
+ const ALWAYS_ON_FEATURES = ["plugins", "plugin_hooks", "multi_agent", "child_agents_md"];
10
+ const AUTONOMOUS_PERMISSION_FEATURES = ["unified_exec", "goals"];
10
11
 
11
12
  test("#given autonomous permissions requested #when script installer updates config #then enables Codex autonomy feature flags", async () => {
12
13
  // given
@@ -39,12 +40,15 @@ test("#given autonomous permissions requested #when script installer updates con
39
40
  // then
40
41
  const content = await readFile(configPath, "utf8");
41
42
  assert.match(content, /network_access = "enabled"/);
42
- for (const featureName of AUTONOMOUS_FEATURES) {
43
+ for (const featureName of ALWAYS_ON_FEATURES) {
44
+ assert.match(content, new RegExp(`${featureName} = true`));
45
+ }
46
+ for (const featureName of AUTONOMOUS_PERMISSION_FEATURES) {
43
47
  assert.match(content, new RegExp(`${featureName} = true`));
44
48
  }
45
49
  });
46
50
 
47
- test("#given autonomous permissions disabled #when script installer updates config #then preserves autonomy feature opt-outs", async () => {
51
+ test("#given autonomous permissions disabled #when script installer updates config #then keeps native Codex feature flags enabled", async () => {
48
52
  // given
49
53
  const root = await mkdtemp(join(tmpdir(), "omo-codex-script-config-autonomous-features-disabled-"));
50
54
  const configPath = join(root, "config.toml");
@@ -75,9 +79,10 @@ test("#given autonomous permissions disabled #when script installer updates conf
75
79
  // then
76
80
  const content = await readFile(configPath, "utf8");
77
81
  assert.match(content, /network_access = "disabled"/);
78
- for (const featureName of AUTONOMOUS_FEATURES) {
82
+ for (const featureName of ALWAYS_ON_FEATURES) {
83
+ assert.match(content, new RegExp(`${featureName} = true`));
84
+ }
85
+ for (const featureName of AUTONOMOUS_PERMISSION_FEATURES) {
79
86
  assert.match(content, new RegExp(`${featureName} = false`));
80
87
  }
81
- assert.match(content, /plugins = true/);
82
- assert.match(content, /plugin_hooks = true/);
83
88
  });