oh-my-openagent 4.7.2 → 4.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +14 -12
- package/package.json +12 -12
- package/packages/omo-codex/plugin/components/rules/README.md +4 -4
- package/packages/omo-codex/plugin/components/rules/skills/rules/SKILL.md +2 -4
- package/packages/omo-codex/plugin/components/rules/src/config.ts +1 -5
- package/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts +2 -11
- package/packages/omo-codex/plugin/components/rules/src/rules/constants.ts +1 -7
- package/packages/omo-codex/plugin/components/rules/src/rules/engine.ts +3 -12
- package/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts +0 -5
- package/packages/omo-codex/plugin/components/rules/src/rules/sources.ts +13 -0
- package/packages/omo-codex/plugin/components/rules/src/rules/types.ts +2 -6
- package/packages/omo-codex/plugin/components/rules/test/agent-doc-sources.test.ts +119 -0
- package/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-budget.test.ts +4 -2
- package/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts +4 -2
- package/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts +72 -1
- package/packages/omo-codex/plugin/components/rules/test/config.test.ts +22 -0
- package/packages/omo-codex/plugin/components/rules/test/dynamic-target-fingerprints.test.ts +71 -0
- package/packages/omo-codex/plugin/components/rules/test/engine.test.ts +52 -0
- package/packages/omo-codex/plugin/components/rules/test/finder.test.ts +2 -7
- package/packages/omo-codex/plugin/components/rules/test/formatter.test.ts +14 -14
- package/packages/omo-codex/plugin/components/rules/test/post-compact-test-fixture.ts +4 -2
- package/packages/omo-codex/plugin/components/rules/test/sources.test.ts +46 -0
- package/packages/omo-codex/plugin/components/ultrawork/README.md +2 -0
- package/packages/omo-codex/plugin/components/ultrawork/src/codex-hook.ts +62 -0
- package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +51 -0
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts +67 -3
- package/packages/omo-codex/plugin/components/ulw-loop/test/quality-gate.test.ts +51 -0
- package/packages/omo-codex/plugin/scripts/migrate-codex-config.mjs +15 -5
- package/packages/omo-codex/plugin/skills/rules/SKILL.md +2 -4
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +1 -1
- package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +87 -1
- package/packages/omo-codex/scripts/install/config.mjs +2 -0
- package/packages/omo-codex/scripts/install-config-autonomous-features.test.mjs +11 -6
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { fingerprintDynamicTargets } from "../src/dynamic-target-fingerprints.js";
|
|
7
|
+
import { defaultConfig } from "../src/rules/engine.js";
|
|
8
|
+
|
|
9
|
+
const tempDirectories: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
13
|
+
rmSync(directory, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("codex rules dynamic target fingerprints", () => {
|
|
18
|
+
it("#given auto source mode #when an excluded source file changes #then cache key stays stable", () => {
|
|
19
|
+
// given
|
|
20
|
+
const { cwd, targetPath } = makeProjectWithDefaultSources();
|
|
21
|
+
const config = defaultConfig();
|
|
22
|
+
|
|
23
|
+
// when
|
|
24
|
+
const initial = fingerprintDynamicTargets(cwd, [targetPath], config);
|
|
25
|
+
writeFileSync(
|
|
26
|
+
path.join(cwd, "AGENTS.md"),
|
|
27
|
+
[
|
|
28
|
+
"Always use the exact code style.",
|
|
29
|
+
"Updated guidance.",
|
|
30
|
+
"",
|
|
31
|
+
"This file should be excluded from auto mode.",
|
|
32
|
+
].join("\n"),
|
|
33
|
+
);
|
|
34
|
+
const afterChange = fingerprintDynamicTargets(cwd, [targetPath], config);
|
|
35
|
+
writeFileSync(
|
|
36
|
+
path.join(cwd, ".github", "instructions", "workflow.md"),
|
|
37
|
+
["---", "description: Workflow rules", 'globs: ["**/*.ts"]', "---", "Prefer explicit return types."].join(
|
|
38
|
+
"\n",
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
const afterEnabledChange = fingerprintDynamicTargets(cwd, [targetPath], config);
|
|
42
|
+
const initialFingerprint = initial[0]?.fingerprint;
|
|
43
|
+
|
|
44
|
+
// then
|
|
45
|
+
expect(afterChange).toHaveLength(1);
|
|
46
|
+
expect(afterEnabledChange).toHaveLength(1);
|
|
47
|
+
expect(initialFingerprint).toBeDefined();
|
|
48
|
+
expect(afterChange[0]?.fingerprint).toBe(initialFingerprint);
|
|
49
|
+
expect(afterEnabledChange[0]?.fingerprint).not.toBe(initialFingerprint);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function makeProjectWithDefaultSources(): { cwd: string; targetPath: string } {
|
|
54
|
+
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-fingerprint-"));
|
|
55
|
+
const projectRoot = path.join(root, "repo");
|
|
56
|
+
const instructionPath = path.join(projectRoot, ".github", "instructions");
|
|
57
|
+
const targetPath = path.join(projectRoot, "src", "app.ts");
|
|
58
|
+
tempDirectories.push(root);
|
|
59
|
+
|
|
60
|
+
mkdirSync(instructionPath, { recursive: true });
|
|
61
|
+
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
62
|
+
writeFileSync(path.join(projectRoot, "package.json"), "{}");
|
|
63
|
+
writeFileSync(path.join(projectRoot, "AGENTS.md"), "Default project AGENTS file.");
|
|
64
|
+
writeFileSync(path.join(projectRoot, ".github", "copilot-instructions.md"), "Legacy copilot instruction");
|
|
65
|
+
writeFileSync(
|
|
66
|
+
path.join(instructionPath, "workflow.md"),
|
|
67
|
+
["---", "description: Workflow rules", 'globs: ["**/*.ts"]', "---", "Keep async/await explicit."].join("\n"),
|
|
68
|
+
);
|
|
69
|
+
writeFileSync(targetPath, "export const answer = 42;\n");
|
|
70
|
+
return { cwd: projectRoot, targetPath };
|
|
71
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
|
+
import { configFromEnvironment } from "../src/config.js";
|
|
4
5
|
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
|
|
5
6
|
import { matchRule as defaultMatchRule } from "../src/rules/matcher.js";
|
|
6
7
|
import type { RuleCandidate } from "../src/rules/types.js";
|
|
@@ -190,3 +191,54 @@ describe("rule engine dynamic matching", () => {
|
|
|
190
191
|
expect(matchCalls).toBe(2);
|
|
191
192
|
});
|
|
192
193
|
});
|
|
194
|
+
|
|
195
|
+
describe("rule engine default source selection", () => {
|
|
196
|
+
it("#given auto source selection #when loading static rules #then Codex-native and Claude-home sources are disabled by default", () => {
|
|
197
|
+
// given
|
|
198
|
+
let capturedDisabledSources: ReadonlySet<string> | undefined;
|
|
199
|
+
const deps = {
|
|
200
|
+
findProjectRoot: () => projectRoot,
|
|
201
|
+
findCandidates: (options) => {
|
|
202
|
+
capturedDisabledSources = options.disabledSources;
|
|
203
|
+
return [];
|
|
204
|
+
},
|
|
205
|
+
readFile: () => null,
|
|
206
|
+
} satisfies EngineDeps;
|
|
207
|
+
const engine = createEngine(defaultConfig(), deps);
|
|
208
|
+
|
|
209
|
+
// when
|
|
210
|
+
engine.loadStaticRules(projectRoot);
|
|
211
|
+
|
|
212
|
+
// then
|
|
213
|
+
expect(capturedDisabledSources?.has("AGENTS.md")).toBe(true);
|
|
214
|
+
expect(capturedDisabledSources?.has("~/.claude/rules")).toBe(true);
|
|
215
|
+
expect(capturedDisabledSources?.has("~/.claude/CLAUDE.md")).toBe(true);
|
|
216
|
+
expect(capturedDisabledSources?.has("CLAUDE.md")).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("#given removed agent-doc sources and a real source are requested #when loading static rules #then only real sources are enabled", () => {
|
|
220
|
+
// given
|
|
221
|
+
let capturedDisabledSources: ReadonlySet<string> | undefined;
|
|
222
|
+
const deps = {
|
|
223
|
+
findProjectRoot: () => projectRoot,
|
|
224
|
+
findCandidates: (options) => {
|
|
225
|
+
capturedDisabledSources = options.disabledSources;
|
|
226
|
+
return [];
|
|
227
|
+
},
|
|
228
|
+
readFile: () => null,
|
|
229
|
+
} satisfies EngineDeps;
|
|
230
|
+
const engine = createEngine(
|
|
231
|
+
configFromEnvironment({ CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,~/.claude/CLAUDE.md,plugin-bundled" }),
|
|
232
|
+
deps,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// when
|
|
236
|
+
engine.loadStaticRules(projectRoot);
|
|
237
|
+
|
|
238
|
+
// then
|
|
239
|
+
expect(capturedDisabledSources?.has("AGENTS.md")).toBe(false);
|
|
240
|
+
expect(capturedDisabledSources?.has("~/.claude/CLAUDE.md")).toBe(false);
|
|
241
|
+
expect(capturedDisabledSources?.has("plugin-bundled")).toBe(false);
|
|
242
|
+
expect(capturedDisabledSources?.has(".omo/rules")).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -24,6 +24,7 @@ function makeProject(): { projectRoot: string; homeRoot: string; targetPath: str
|
|
|
24
24
|
mkdirSync(join(homeRoot, ".config", "opencode"), { recursive: true });
|
|
25
25
|
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "fixture" }));
|
|
26
26
|
writeFileSync(join(projectRoot, "AGENTS.md"), "Project rule\n");
|
|
27
|
+
writeFileSync(join(projectRoot, "CLAUDE.md"), "Claude rule\n");
|
|
27
28
|
writeFileSync(join(projectRoot, "src", ".omo", "rules", "local.md"), "Local rule\n");
|
|
28
29
|
writeFileSync(join(projectRoot, ".omo", "rules", "root.md"), "Root rule\n");
|
|
29
30
|
writeFileSync(join(homeRoot, ".opencode", "rules", "global.md"), "Global rule\n");
|
|
@@ -54,9 +55,7 @@ describe("findRuleCandidates", () => {
|
|
|
54
55
|
expect(candidates.map(candidateSummary)).toEqual([
|
|
55
56
|
".omo/rules:0:src/.omo/rules/local.md",
|
|
56
57
|
".omo/rules:1:.omo/rules/root.md",
|
|
57
|
-
"AGENTS.md:1:AGENTS.md",
|
|
58
58
|
"~/.opencode/rules:9999:.opencode/rules/global.md",
|
|
59
|
-
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
|
|
60
59
|
]);
|
|
61
60
|
});
|
|
62
61
|
|
|
@@ -73,10 +72,7 @@ describe("findRuleCandidates", () => {
|
|
|
73
72
|
});
|
|
74
73
|
|
|
75
74
|
// then
|
|
76
|
-
expect(candidates.map(candidateSummary)).toEqual([
|
|
77
|
-
"AGENTS.md:1:AGENTS.md",
|
|
78
|
-
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
|
|
79
|
-
]);
|
|
75
|
+
expect(candidates.map(candidateSummary)).toEqual([]);
|
|
80
76
|
});
|
|
81
77
|
|
|
82
78
|
it("#given skip user home #when finding candidates #then only project rules are returned", () => {
|
|
@@ -96,7 +92,6 @@ describe("findRuleCandidates", () => {
|
|
|
96
92
|
expect(candidates.map(candidateSummary)).toEqual([
|
|
97
93
|
".omo/rules:0:src/.omo/rules/local.md",
|
|
98
94
|
".omo/rules:1:.omo/rules/root.md",
|
|
99
|
-
"AGENTS.md:1:AGENTS.md",
|
|
100
95
|
]);
|
|
101
96
|
});
|
|
102
97
|
});
|
|
@@ -12,8 +12,8 @@ describe("rules formatter hook context", () => {
|
|
|
12
12
|
it("#given multiline dynamic rules #when formatting PostToolUse context #then labels and bodies render on separate lines", () => {
|
|
13
13
|
// given
|
|
14
14
|
const rule = loadedRule({
|
|
15
|
-
path: "/repo/packages/
|
|
16
|
-
relativePath: "packages/
|
|
15
|
+
path: "/repo/packages/CONTEXT.md",
|
|
16
|
+
relativePath: "packages/CONTEXT.md",
|
|
17
17
|
body: ["# packages", "", "## OVERVIEW", "23 sibling packages.", "", "## CONVENTIONS", "Use npm."].join("\n"),
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -29,7 +29,7 @@ describe("rules formatter hook context", () => {
|
|
|
29
29
|
[
|
|
30
30
|
"Additional project instructions matched for packages/omo-codex/plugin/components/ulw-loop/src/paths.ts:",
|
|
31
31
|
"",
|
|
32
|
-
"Instructions from: /repo/packages/
|
|
32
|
+
"Instructions from: /repo/packages/CONTEXT.md",
|
|
33
33
|
"",
|
|
34
34
|
"# packages",
|
|
35
35
|
"",
|
|
@@ -45,8 +45,8 @@ describe("rules formatter hook context", () => {
|
|
|
45
45
|
it("#given static rules #when formatting SessionStart context #then it avoids leading blank lines", () => {
|
|
46
46
|
// given
|
|
47
47
|
const rule = loadedRule({
|
|
48
|
-
path: "/repo/
|
|
49
|
-
relativePath: "
|
|
48
|
+
path: "/repo/CONTEXT.md",
|
|
49
|
+
relativePath: "CONTEXT.md",
|
|
50
50
|
body: "Keep generated hook context readable.",
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -58,7 +58,7 @@ describe("rules formatter hook context", () => {
|
|
|
58
58
|
[
|
|
59
59
|
"## Project Instructions",
|
|
60
60
|
"",
|
|
61
|
-
"Instructions from: /repo/
|
|
61
|
+
"Instructions from: /repo/CONTEXT.md",
|
|
62
62
|
"",
|
|
63
63
|
"Keep generated hook context readable.",
|
|
64
64
|
].join("\n"),
|
|
@@ -82,13 +82,13 @@ describe("rules formatter hook context", () => {
|
|
|
82
82
|
it("#given duplicate static rules with different line endings #when formatting context #then it renders one copy", () => {
|
|
83
83
|
// given
|
|
84
84
|
const lfRule = loadedRule({
|
|
85
|
-
path: "/repo/
|
|
86
|
-
relativePath: "
|
|
85
|
+
path: "/repo/CONTEXT.md",
|
|
86
|
+
relativePath: "CONTEXT.md",
|
|
87
87
|
body: "Shared rule\nKeep one copy.",
|
|
88
88
|
});
|
|
89
89
|
const crlfRule = loadedRule({
|
|
90
|
-
path: "/repo/packages/
|
|
91
|
-
relativePath: "packages/
|
|
90
|
+
path: "/repo/packages/CONTEXT.md",
|
|
91
|
+
relativePath: "packages/CONTEXT.md",
|
|
92
92
|
body: "Shared rule\r\nKeep one copy.",
|
|
93
93
|
});
|
|
94
94
|
|
|
@@ -97,7 +97,7 @@ describe("rules formatter hook context", () => {
|
|
|
97
97
|
|
|
98
98
|
// then
|
|
99
99
|
expect(occurrenceCount(block, "Shared rule\nKeep one copy.")).toBe(1);
|
|
100
|
-
expect(block).not.toContain("/repo/packages/
|
|
100
|
+
expect(block).not.toContain("/repo/packages/CONTEXT.md");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it("#given multiple oversized rules #when formatting under a tight result budget #then every rule receives a fair truncated share with a read-full guide", () => {
|
|
@@ -145,9 +145,9 @@ function loadedRule(input: {
|
|
|
145
145
|
readonly source?: RuleSource;
|
|
146
146
|
readonly matchReason?: MatchReason;
|
|
147
147
|
}): LoadedRule {
|
|
148
|
-
const path = input.path ?? "/repo/
|
|
149
|
-
const relativePath = input.relativePath ?? "
|
|
150
|
-
const source = input.source ?? "
|
|
148
|
+
const path = input.path ?? "/repo/CONTEXT.md";
|
|
149
|
+
const relativePath = input.relativePath ?? "CONTEXT.md";
|
|
150
|
+
const source = input.source ?? "CONTEXT.md";
|
|
151
151
|
return {
|
|
152
152
|
path,
|
|
153
153
|
realPath: path,
|
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import type { CodexPostCompactInput, CodexSessionStartInput, CodexUserPromptSubmitInput } from "../src/codex-hook.js";
|
|
6
6
|
|
|
7
7
|
export const PROJECT_RULES_ENV = {
|
|
8
|
-
CODEX_RULES_ENABLED_SOURCES: "
|
|
8
|
+
CODEX_RULES_ENABLED_SOURCES: "CONTEXT.md,.omo/rules",
|
|
9
9
|
CODEX_RULES_MAX_RESULT_CHARS: "50000",
|
|
10
10
|
CODEX_RULES_MAX_RULE_CHARS: "30000",
|
|
11
11
|
};
|
|
@@ -30,7 +30,9 @@ export function makeOversizedProject(prefix = "budget"): { root: string; pluginD
|
|
|
30
30
|
const pluginData = mkdtempSync(path.join(tmpdir(), `codex-rules-post-compact-${prefix}-data-`));
|
|
31
31
|
tempDirectories.push(root, pluginData);
|
|
32
32
|
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
|
|
33
|
-
writeFileSync(path.join(root, "AGENTS.md"),
|
|
33
|
+
writeFileSync(path.join(root, "AGENTS.md"), "Project AGENTS.md should stay Codex-native.");
|
|
34
|
+
writeFileSync(path.join(root, "CLAUDE.md"), "Project CLAUDE.md should stay outside rules hook context.");
|
|
35
|
+
writeFileSync(path.join(root, "CONTEXT.md"), `Project rule\n${"A".repeat(30_000)}`);
|
|
34
36
|
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
|
|
35
37
|
writeFileSync(
|
|
36
38
|
path.join(root, ".omo", "rules", "typescript.md"),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SOURCE_PRIORITY } from "../src/rules/constants.js";
|
|
4
|
+
import { defaultConfig } from "../src/rules/engine.js";
|
|
5
|
+
import { disabledSourcesFromConfig } from "../src/rules/sources.js";
|
|
6
|
+
import type { PiRulesConfig } from "../src/rules/types.js";
|
|
7
|
+
|
|
8
|
+
describe("rules source selection", () => {
|
|
9
|
+
it("#given default config #when disabled sources are derived #then opt-out sources stay disabled", () => {
|
|
10
|
+
// given
|
|
11
|
+
const config = defaultConfig();
|
|
12
|
+
const expected = new Set(["AGENTS.md", "~/.claude/rules", "~/.claude/CLAUDE.md"]);
|
|
13
|
+
|
|
14
|
+
// when
|
|
15
|
+
const disabledSources = disabledSourcesFromConfig(config);
|
|
16
|
+
expect(disabledSources).toBeDefined();
|
|
17
|
+
if (disabledSources === undefined) return;
|
|
18
|
+
|
|
19
|
+
// then
|
|
20
|
+
expect(disabledSources).toEqual(expected);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("#given explicit enabled sources #when disabled source set is derived #then all other known sources are omitted", () => {
|
|
24
|
+
// given
|
|
25
|
+
const config: PiRulesConfig = {
|
|
26
|
+
...defaultConfig(),
|
|
27
|
+
enabledSources: [".omo/rules", "plugin-bundled"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// when
|
|
31
|
+
const disabledSources = disabledSourcesFromConfig(config);
|
|
32
|
+
expect(disabledSources).toBeDefined();
|
|
33
|
+
if (disabledSources === undefined) return;
|
|
34
|
+
|
|
35
|
+
// then
|
|
36
|
+
for (const source of [".omo/rules", "plugin-bundled"]) {
|
|
37
|
+
expect(disabledSources.has(source)).toBe(false);
|
|
38
|
+
}
|
|
39
|
+
expect(disabledSources.has("~/.claude/rules")).toBe(true);
|
|
40
|
+
expect(disabledSources).toEqual(
|
|
41
|
+
new Set(
|
|
42
|
+
[...SOURCE_PRIORITY.keys()].filter((source) => source !== ".omo/rules" && source !== "plugin-bundled"),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -35,6 +35,8 @@ node ${PLUGIN_ROOT}/dist/cli.js hook user-prompt-submit
|
|
|
35
35
|
|
|
36
36
|
Codex passes the prompt payload on stdin. When the pattern `\b(?:ultrawork|ulw)\b` (case-insensitive) matches, the hook writes the directive to stdout — Codex injects non-JSON stdout as `additional_context` for the next turn. Otherwise the hook writes nothing and exits 0. Malformed input also exits 0 to never block the turn.
|
|
37
37
|
|
|
38
|
+
If a prior `UserPromptSubmit` hook output in transcript JSONL already contains `<ultrawork-mode>`, the hook suppresses itself so the same directive is not injected repeatedly. Plain transcript text containing `<ultrawork-mode>` is ignored unless it comes from hook output.
|
|
39
|
+
|
|
38
40
|
Bundled agent role TOMLs in `agents/` ship to `CODEX_HOME/agents/` at install time, not via a runtime hook. The installer creates a symlink on Linux / macOS and a file copy on Windows (because symlinks require admin privileges or Developer Mode). For the public marketplace, the source is the stable installed-marketplace snapshot, not the versioned plugin cache, so agent role configs remain valid when Codex replaces `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/` during auto-update. Both code paths overwrite stale files and write a `.installed-agents.json` manifest next to the source root for clean uninstall tracking.
|
|
39
41
|
|
|
40
42
|
## Smoke test
|
|
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { ULTRAWORK_DIRECTIVE } from "./directive.js";
|
|
4
4
|
|
|
5
5
|
const ULTRAWORK_PATTERN = /\b(?:ultrawork|ulw)\b/i;
|
|
6
|
+
const ULTRAWORK_DIRECTIVE_MARKER = "<ultrawork-mode>";
|
|
7
|
+
const TRANSCRIPT_SEARCH_BYTES = 512_000;
|
|
6
8
|
const CONTEXT_PRESSURE_MARKERS = [
|
|
7
9
|
"context compacted",
|
|
8
10
|
"context_length_exceeded",
|
|
@@ -29,10 +31,54 @@ interface UserPromptSubmitHookOutput {
|
|
|
29
31
|
export function runUserPromptSubmitHook(input: unknown): string {
|
|
30
32
|
if (!isCodexUserPromptSubmitInput(input)) return "";
|
|
31
33
|
if (isContextPressureRecoveryPrompt(input.prompt)) return "";
|
|
34
|
+
if (hasUltraworkDirectiveAlreadyInTranscript(input.transcript_path)) return "";
|
|
32
35
|
if (isContextPressureTranscript(input.transcript_path)) return "";
|
|
33
36
|
return isUltraworkPrompt(input.prompt) ? formatAdditionalContextOutput(ULTRAWORK_DIRECTIVE) : "";
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
function hasUltraworkDirectiveAlreadyInTranscript(transcriptPath: string | null | undefined): boolean {
|
|
40
|
+
if (transcriptPath === undefined || transcriptPath === null) return false;
|
|
41
|
+
try {
|
|
42
|
+
const rawTranscript = readTranscriptTail(transcriptPath);
|
|
43
|
+
for (const line of rawTranscript.split(/\r?\n/)) {
|
|
44
|
+
const parsed = parseJsonLine(line);
|
|
45
|
+
if (parsed === null) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!isRecord(parsed)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hookSpecificOutput = parsed["hookSpecificOutput"];
|
|
54
|
+
if (!isRecord(hookSpecificOutput)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (hookSpecificOutput["hookEventName"] !== "UserPromptSubmit") {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
typeof hookSpecificOutput["additionalContext"] === "string" &&
|
|
64
|
+
hookSpecificOutput["additionalContext"].includes(ULTRAWORK_DIRECTIVE_MARKER)
|
|
65
|
+
) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof Error) return false;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readTranscriptTail(transcriptPath: string): string {
|
|
78
|
+
const rawTranscript = readFileSync(transcriptPath);
|
|
79
|
+
return rawTranscript.subarray(Math.max(0, rawTranscript.byteLength - TRANSCRIPT_SEARCH_BYTES)).toString("utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
export function isUltraworkPrompt(prompt: string): boolean {
|
|
37
83
|
return ULTRAWORK_PATTERN.test(prompt);
|
|
38
84
|
}
|
|
@@ -68,6 +114,22 @@ function normalizeAdditionalContext(additionalContext: string): string {
|
|
|
68
114
|
return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
69
115
|
}
|
|
70
116
|
|
|
117
|
+
function parseJsonLine(line: string): unknown | null {
|
|
118
|
+
if (line.trim().length === 0) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const parsed: unknown = JSON.parse(line);
|
|
124
|
+
return parsed;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof Error) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
71
133
|
function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput {
|
|
72
134
|
return (
|
|
73
135
|
isRecord(value) &&
|
|
@@ -31,6 +31,49 @@ describe("codex ultrawork hook", () => {
|
|
|
31
31
|
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/First user-visible line this turn MUST be exactly:/);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("#given transcript already contains ultrawork directive #when hook sees ultrawork prompt #then it does not repeat directive", () => {
|
|
35
|
+
// given
|
|
36
|
+
const payload = {
|
|
37
|
+
hook_event_name: "UserPromptSubmit",
|
|
38
|
+
prompt: "please ulw this change",
|
|
39
|
+
transcript_path: writeTranscript(
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
hookSpecificOutput: {
|
|
42
|
+
hookEventName: "UserPromptSubmit",
|
|
43
|
+
additionalContext: "<ultrawork-mode>\nexisting directive",
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// when
|
|
50
|
+
const output = runUserPromptSubmitHook(payload);
|
|
51
|
+
|
|
52
|
+
// then
|
|
53
|
+
expect(output).toBe("");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("#given transcript only mentions ultrawork marker in user content #when hook sees first ultrawork prompt #then it emits directive", () => {
|
|
57
|
+
// given
|
|
58
|
+
const payload = {
|
|
59
|
+
hook_event_name: "UserPromptSubmit",
|
|
60
|
+
prompt: "please ulw this change",
|
|
61
|
+
transcript_path: writeTranscript(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
role: "user",
|
|
64
|
+
content: "Please inspect text containing <ultrawork-mode> but do not activate yet.",
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// when
|
|
70
|
+
const output = runUserPromptSubmitHook(payload);
|
|
71
|
+
const parsed = parseHookOutput(output);
|
|
72
|
+
|
|
73
|
+
// then
|
|
74
|
+
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/^<ultrawork-mode>/);
|
|
75
|
+
});
|
|
76
|
+
|
|
34
77
|
it("#given identifier-like ulw #when hook runs #then does not emit directive", () => {
|
|
35
78
|
// given
|
|
36
79
|
const payload = {
|
|
@@ -244,6 +287,14 @@ function writeContextPressureTranscript(): string {
|
|
|
244
287
|
return transcriptPath;
|
|
245
288
|
}
|
|
246
289
|
|
|
290
|
+
function writeTranscript(...lines: string[]): string {
|
|
291
|
+
const root = mkdtempSync(path.join(tmpdir(), "codex-ultrawork-transcript-"));
|
|
292
|
+
tempDirectories.push(root);
|
|
293
|
+
const transcriptPath = path.join(root, "transcript.jsonl");
|
|
294
|
+
writeFileSync(transcriptPath, `${lines.join("\n")}\n`);
|
|
295
|
+
return transcriptPath;
|
|
296
|
+
}
|
|
297
|
+
|
|
247
298
|
function writeCodexContextWindowTranscript(): string {
|
|
248
299
|
const root = mkdtempSync(path.join(tmpdir(), "codex-ultrawork-context-window-"));
|
|
249
300
|
tempDirectories.push(root);
|
package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
110
|
-
"- If final
|
|
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
|
|
114
|
-
: '- If final
|
|
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
|
|
131
|
+
codeReview: { recommendation, architectStatus, evidence: reviewEvidence },
|
|
68
132
|
};
|
|
69
133
|
Object.assign(result, { criteriaCoverage: { totalCriteria, passCount, adversarialClassesCovered: covered } });
|
|
70
134
|
return result;
|