oh-my-openagent 4.7.2 → 4.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +12 -12
- package/package.json +12 -12
- package/packages/omo-codex/plugin/components/rules/README.md +2 -2
- package/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts +2 -11
- package/packages/omo-codex/plugin/components/rules/src/rules/engine.ts +3 -12
- package/packages/omo-codex/plugin/components/rules/src/rules/sources.ts +17 -0
- package/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts +32 -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/sources.test.ts +45 -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/scripts/migrate-codex-config.mjs +15 -5
- package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +87 -1
package/dist/cli/index.js
CHANGED
|
@@ -61669,7 +61669,7 @@ var {
|
|
|
61669
61669
|
// package.json
|
|
61670
61670
|
var package_default = {
|
|
61671
61671
|
name: "oh-my-openagent",
|
|
61672
|
-
version: "4.7.
|
|
61672
|
+
version: "4.7.3",
|
|
61673
61673
|
description: "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
|
61674
61674
|
main: "./dist/index.js",
|
|
61675
61675
|
types: "dist/index.d.ts",
|
|
@@ -61804,17 +61804,17 @@ var package_default = {
|
|
|
61804
61804
|
zod: "^4.4.3"
|
|
61805
61805
|
},
|
|
61806
61806
|
optionalDependencies: {
|
|
61807
|
-
"oh-my-openagent-darwin-arm64": "4.7.
|
|
61808
|
-
"oh-my-openagent-darwin-x64": "4.7.
|
|
61809
|
-
"oh-my-openagent-darwin-x64-baseline": "4.7.
|
|
61810
|
-
"oh-my-openagent-linux-arm64": "4.7.
|
|
61811
|
-
"oh-my-openagent-linux-arm64-musl": "4.7.
|
|
61812
|
-
"oh-my-openagent-linux-x64": "4.7.
|
|
61813
|
-
"oh-my-openagent-linux-x64-baseline": "4.7.
|
|
61814
|
-
"oh-my-openagent-linux-x64-musl": "4.7.
|
|
61815
|
-
"oh-my-openagent-linux-x64-musl-baseline": "4.7.
|
|
61816
|
-
"oh-my-openagent-windows-x64": "4.7.
|
|
61817
|
-
"oh-my-openagent-windows-x64-baseline": "4.7.
|
|
61807
|
+
"oh-my-openagent-darwin-arm64": "4.7.3",
|
|
61808
|
+
"oh-my-openagent-darwin-x64": "4.7.3",
|
|
61809
|
+
"oh-my-openagent-darwin-x64-baseline": "4.7.3",
|
|
61810
|
+
"oh-my-openagent-linux-arm64": "4.7.3",
|
|
61811
|
+
"oh-my-openagent-linux-arm64-musl": "4.7.3",
|
|
61812
|
+
"oh-my-openagent-linux-x64": "4.7.3",
|
|
61813
|
+
"oh-my-openagent-linux-x64-baseline": "4.7.3",
|
|
61814
|
+
"oh-my-openagent-linux-x64-musl": "4.7.3",
|
|
61815
|
+
"oh-my-openagent-linux-x64-musl-baseline": "4.7.3",
|
|
61816
|
+
"oh-my-openagent-windows-x64": "4.7.3",
|
|
61817
|
+
"oh-my-openagent-windows-x64-baseline": "4.7.3"
|
|
61818
61818
|
},
|
|
61819
61819
|
overrides: {
|
|
61820
61820
|
hono: "^4.12.18",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-my-openagent",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.3",
|
|
4
4
|
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -135,17 +135,17 @@
|
|
|
135
135
|
"zod": "^4.4.3"
|
|
136
136
|
},
|
|
137
137
|
"optionalDependencies": {
|
|
138
|
-
"oh-my-openagent-darwin-arm64": "4.7.
|
|
139
|
-
"oh-my-openagent-darwin-x64": "4.7.
|
|
140
|
-
"oh-my-openagent-darwin-x64-baseline": "4.7.
|
|
141
|
-
"oh-my-openagent-linux-arm64": "4.7.
|
|
142
|
-
"oh-my-openagent-linux-arm64-musl": "4.7.
|
|
143
|
-
"oh-my-openagent-linux-x64": "4.7.
|
|
144
|
-
"oh-my-openagent-linux-x64-baseline": "4.7.
|
|
145
|
-
"oh-my-openagent-linux-x64-musl": "4.7.
|
|
146
|
-
"oh-my-openagent-linux-x64-musl-baseline": "4.7.
|
|
147
|
-
"oh-my-openagent-windows-x64": "4.7.
|
|
148
|
-
"oh-my-openagent-windows-x64-baseline": "4.7.
|
|
138
|
+
"oh-my-openagent-darwin-arm64": "4.7.3",
|
|
139
|
+
"oh-my-openagent-darwin-x64": "4.7.3",
|
|
140
|
+
"oh-my-openagent-darwin-x64-baseline": "4.7.3",
|
|
141
|
+
"oh-my-openagent-linux-arm64": "4.7.3",
|
|
142
|
+
"oh-my-openagent-linux-arm64-musl": "4.7.3",
|
|
143
|
+
"oh-my-openagent-linux-x64": "4.7.3",
|
|
144
|
+
"oh-my-openagent-linux-x64-baseline": "4.7.3",
|
|
145
|
+
"oh-my-openagent-linux-x64-musl": "4.7.3",
|
|
146
|
+
"oh-my-openagent-linux-x64-musl-baseline": "4.7.3",
|
|
147
|
+
"oh-my-openagent-windows-x64": "4.7.3",
|
|
148
|
+
"oh-my-openagent-windows-x64-baseline": "4.7.3"
|
|
149
149
|
},
|
|
150
150
|
"overrides": {
|
|
151
151
|
"hono": "^4.12.18",
|
|
@@ -26,7 +26,7 @@ Project-level sources:
|
|
|
26
26
|
- `.github/instructions/**/*.md`
|
|
27
27
|
- `.github/copilot-instructions.md`
|
|
28
28
|
|
|
29
|
-
User-home sources are also supported by the ported engine when available.
|
|
29
|
+
User-home sources are also supported by the ported engine when available. `AGENTS.md` is not part of `auto` source selection because Codex already loads it as native project instructions, so re-injecting it through hooks duplicates context; opt into it explicitly with `CODEX_RULES_ENABLED_SOURCES` if you need hook-level migration behavior. Claude user-home sources (`~/.claude/rules`, `~/.claude/CLAUDE.md`) are also excluded from `auto` because they usually contain Claude Code runtime instructions rather than Codex rules; opt into them explicitly when you want that migration behavior.
|
|
30
30
|
|
|
31
31
|
Markdown rule files may use frontmatter such as:
|
|
32
32
|
|
|
@@ -73,7 +73,7 @@ Use `CODEX_RULES_*` environment variables:
|
|
|
73
73
|
| `CODEX_RULES_MODE` | `both`, `static`, `dynamic`, `off` | `both` |
|
|
74
74
|
| `CODEX_RULES_MAX_RULE_CHARS` | positive integer | `12000` |
|
|
75
75
|
| `CODEX_RULES_MAX_RESULT_CHARS` | positive integer | `40000` |
|
|
76
|
-
| `CODEX_RULES_ENABLED_SOURCES` | comma-separated source names | `auto` |
|
|
76
|
+
| `CODEX_RULES_ENABLED_SOURCES` | comma-separated source names or `auto` | `auto` (excludes `AGENTS.md`, `~/.claude/rules`, `~/.claude/CLAUDE.md`) |
|
|
77
77
|
|
|
78
78
|
For migration from `pi-rules`, equivalent `PI_RULES_*` variables are accepted as fallbacks.
|
|
79
79
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { isSameOrChildPath, toPosixPath, uniqueStrings } from "./path-utils.js";
|
|
4
|
-
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
|
5
4
|
import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
|
|
6
5
|
import { hashContent } from "./rules/matcher.js";
|
|
7
6
|
import { sortCandidates } from "./rules/ordering.js";
|
|
8
7
|
import { findProjectRoot } from "./rules/project-root.js";
|
|
8
|
+
import { disabledSourcesFromConfig } from "./rules/sources.js";
|
|
9
9
|
import type { PiRulesConfig, RuleCandidate } from "./rules/types.js";
|
|
10
10
|
|
|
11
11
|
export interface DynamicTargetFingerprint {
|
|
@@ -19,7 +19,7 @@ export function fingerprintDynamicTargets(
|
|
|
19
19
|
targetPaths: ReadonlyArray<string>,
|
|
20
20
|
config: PiRulesConfig,
|
|
21
21
|
): DynamicTargetFingerprint[] {
|
|
22
|
-
const disabledSources =
|
|
22
|
+
const disabledSources = disabledSourcesFromConfig(config);
|
|
23
23
|
const discoveryCache = createRuleDiscoveryCache();
|
|
24
24
|
const cwdProjectRoot = findProjectRoot(cwd);
|
|
25
25
|
const fingerprints: DynamicTargetFingerprint[] = [];
|
|
@@ -84,15 +84,6 @@ function fileFingerprint(filePath: string): string {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
|
|
88
|
-
if (config.enabledSources === "auto") {
|
|
89
|
-
return undefined;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const enabledSources = new Set(config.enabledSources);
|
|
93
|
-
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
87
|
function dynamicTargetCacheKey(targetPath: string): string {
|
|
97
88
|
return toPosixPath(resolve(targetPath));
|
|
98
89
|
}
|
|
@@ -15,13 +15,13 @@ import {
|
|
|
15
15
|
DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
|
|
16
16
|
DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
|
|
17
17
|
PROJECT_SINGLE_FILES,
|
|
18
|
-
SOURCE_PRIORITY,
|
|
19
18
|
} from "./constants.js";
|
|
20
19
|
import { createRuleDiscoveryCache, type RuleDiscoveryCache } from "./finder.js";
|
|
21
20
|
import { formatDynamicBlock, formatStaticBlock } from "./formatter.js";
|
|
22
21
|
import { hashContent, matchRule } from "./matcher.js";
|
|
23
22
|
import { sortCandidates } from "./ordering.js";
|
|
24
23
|
import { parseRule } from "./parser.js";
|
|
24
|
+
import { disabledSourcesFromConfig } from "./sources.js";
|
|
25
25
|
import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";
|
|
26
26
|
|
|
27
27
|
interface LoadedRuleContent {
|
|
@@ -97,7 +97,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
|
|
|
97
97
|
projectRoot,
|
|
98
98
|
targetFile: null,
|
|
99
99
|
};
|
|
100
|
-
const disabledSources =
|
|
100
|
+
const disabledSources = disabledSourcesFromConfig(config);
|
|
101
101
|
if (disabledSources !== undefined) {
|
|
102
102
|
findOptions.disabledSources = disabledSources;
|
|
103
103
|
}
|
|
@@ -121,7 +121,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
|
|
|
121
121
|
const seenRules = new Set<string>();
|
|
122
122
|
const loadedRuleContent = new Map<string, LoadedRuleContent | null>();
|
|
123
123
|
const projectMembership = new Map<string, boolean>();
|
|
124
|
-
const disabledSources =
|
|
124
|
+
const disabledSources = disabledSourcesFromConfig(config);
|
|
125
125
|
const discoveryCache = createRuleDiscoveryCache();
|
|
126
126
|
const candidateDiscoveryCache: CandidateDiscoveryCache = new Map();
|
|
127
127
|
const cwdProjectRoot = deps.findProjectRoot(cwd);
|
|
@@ -442,15 +442,6 @@ function staticMatchReason(rule: LoadedRule): MatchReason | null {
|
|
|
442
442
|
return null;
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
|
|
446
|
-
if (config.enabledSources === "auto") {
|
|
447
|
-
return undefined;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const enabledSources = new Set(config.enabledSources);
|
|
451
|
-
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
|
|
452
|
-
}
|
|
453
|
-
|
|
454
445
|
function isDedupedRootSingleFile(candidate: RuleCandidate, rootSingleFileSelected: boolean): boolean {
|
|
455
446
|
return rootSingleFileSelected && isRootSingleFile(candidate);
|
|
456
447
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { SOURCE_PRIORITY } from "./constants.js";
|
|
2
|
+
import type { PiRulesConfig, RuleSource } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_AUTO_DISABLED_SOURCES: readonly RuleSource[] = [
|
|
5
|
+
"AGENTS.md",
|
|
6
|
+
"~/.claude/rules",
|
|
7
|
+
"~/.claude/CLAUDE.md",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function disabledSourcesFromConfig(config: PiRulesConfig): ReadonlySet<string> | undefined {
|
|
11
|
+
if (config.enabledSources === "auto") {
|
|
12
|
+
return new Set(DEFAULT_AUTO_DISABLED_SOURCES);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const enabledSources = new Set(config.enabledSources);
|
|
16
|
+
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
|
|
17
|
+
}
|
|
@@ -211,6 +211,21 @@ describe("codex rules hooks", () => {
|
|
|
211
211
|
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Always wear safety goggles");
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
it("#given default auto sources #when SessionStart runs #then native Codex AGENTS.md is not duplicated", async () => {
|
|
215
|
+
// given
|
|
216
|
+
const { root, pluginData } = makeTempProject();
|
|
217
|
+
|
|
218
|
+
// when
|
|
219
|
+
const output = await runSessionStartHook(sessionStartInput(root), {
|
|
220
|
+
pluginDataRoot: pluginData,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// then
|
|
224
|
+
const parsed = parseHookOutput(output);
|
|
225
|
+
expect(parsed.hookSpecificOutput?.additionalContext).toContain("## Project Instructions");
|
|
226
|
+
expect(parsed.hookSpecificOutput?.additionalContext).not.toContain("Always wear safety goggles");
|
|
227
|
+
});
|
|
228
|
+
|
|
214
229
|
it("#given static context already injected #when UserPromptSubmit runs #then it emits no duplicate context", async () => {
|
|
215
230
|
// given
|
|
216
231
|
const { root, pluginData } = makeTempProject();
|
|
@@ -351,6 +366,23 @@ describe("codex rules hooks", () => {
|
|
|
351
366
|
expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
|
|
352
367
|
});
|
|
353
368
|
|
|
369
|
+
it("#given default auto sources #when excluded AGENTS.md changes #then PostToolUse fingerprint stays stable", async () => {
|
|
370
|
+
// given
|
|
371
|
+
const { root, pluginData } = makeTempProject();
|
|
372
|
+
const filePath = path.join(root, "src", "app.ts");
|
|
373
|
+
const input = postToolUseInput(root, filePath);
|
|
374
|
+
await runPostToolUseHook(input, { pluginDataRoot: pluginData });
|
|
375
|
+
const cachedState = readSessionCache(pluginData);
|
|
376
|
+
writeFileSync(path.join(root, "AGENTS.md"), "Native Codex instructions changed outside codex-rules auto.");
|
|
377
|
+
|
|
378
|
+
// when
|
|
379
|
+
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData });
|
|
380
|
+
|
|
381
|
+
// then
|
|
382
|
+
expect(output).toBe("");
|
|
383
|
+
expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
|
|
384
|
+
});
|
|
385
|
+
|
|
354
386
|
it("#given dynamic context remains in transcript but cache is missing #when PostToolUse repeats #then it emits no duplicate context", async () => {
|
|
355
387
|
// given
|
|
356
388
|
const { root, pluginData } = makeTempProject();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { fingerprintDynamicTargets } from "../src/dynamic-target-fingerprints.js";
|
|
7
|
+
import { defaultConfig } from "../src/rules/engine.js";
|
|
8
|
+
|
|
9
|
+
const tempDirectories: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
13
|
+
rmSync(directory, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("codex rules dynamic target fingerprints", () => {
|
|
18
|
+
it("#given auto source mode #when an excluded source file changes #then cache key stays stable", () => {
|
|
19
|
+
// given
|
|
20
|
+
const { cwd, targetPath } = makeProjectWithDefaultSources();
|
|
21
|
+
const config = defaultConfig();
|
|
22
|
+
|
|
23
|
+
// when
|
|
24
|
+
const initial = fingerprintDynamicTargets(cwd, [targetPath], config);
|
|
25
|
+
writeFileSync(
|
|
26
|
+
path.join(cwd, "AGENTS.md"),
|
|
27
|
+
[
|
|
28
|
+
"Always use the exact code style.",
|
|
29
|
+
"Updated guidance.",
|
|
30
|
+
"",
|
|
31
|
+
"This file should be excluded from auto mode.",
|
|
32
|
+
].join("\n"),
|
|
33
|
+
);
|
|
34
|
+
const afterChange = fingerprintDynamicTargets(cwd, [targetPath], config);
|
|
35
|
+
writeFileSync(
|
|
36
|
+
path.join(cwd, ".github", "instructions", "workflow.md"),
|
|
37
|
+
["---", "description: Workflow rules", 'globs: ["**/*.ts"]', "---", "Prefer explicit return types."].join(
|
|
38
|
+
"\n",
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
const afterEnabledChange = fingerprintDynamicTargets(cwd, [targetPath], config);
|
|
42
|
+
const initialFingerprint = initial[0]?.fingerprint;
|
|
43
|
+
|
|
44
|
+
// then
|
|
45
|
+
expect(afterChange).toHaveLength(1);
|
|
46
|
+
expect(afterEnabledChange).toHaveLength(1);
|
|
47
|
+
expect(initialFingerprint).toBeDefined();
|
|
48
|
+
expect(afterChange[0]?.fingerprint).toBe(initialFingerprint);
|
|
49
|
+
expect(afterEnabledChange[0]?.fingerprint).not.toBe(initialFingerprint);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function makeProjectWithDefaultSources(): { cwd: string; targetPath: string } {
|
|
54
|
+
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-fingerprint-"));
|
|
55
|
+
const projectRoot = path.join(root, "repo");
|
|
56
|
+
const instructionPath = path.join(projectRoot, ".github", "instructions");
|
|
57
|
+
const targetPath = path.join(projectRoot, "src", "app.ts");
|
|
58
|
+
tempDirectories.push(root);
|
|
59
|
+
|
|
60
|
+
mkdirSync(instructionPath, { recursive: true });
|
|
61
|
+
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
62
|
+
writeFileSync(path.join(projectRoot, "package.json"), "{}");
|
|
63
|
+
writeFileSync(path.join(projectRoot, "AGENTS.md"), "Default project AGENTS file.");
|
|
64
|
+
writeFileSync(path.join(projectRoot, ".github", "copilot-instructions.md"), "Legacy copilot instruction");
|
|
65
|
+
writeFileSync(
|
|
66
|
+
path.join(instructionPath, "workflow.md"),
|
|
67
|
+
["---", "description: Workflow rules", 'globs: ["**/*.ts"]', "---", "Keep async/await explicit."].join("\n"),
|
|
68
|
+
);
|
|
69
|
+
writeFileSync(targetPath, "export const answer = 42;\n");
|
|
70
|
+
return { cwd: projectRoot, targetPath };
|
|
71
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
|
+
import { configFromEnvironment } from "../src/config.js";
|
|
4
5
|
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
|
|
5
6
|
import { matchRule as defaultMatchRule } from "../src/rules/matcher.js";
|
|
6
7
|
import type { RuleCandidate } from "../src/rules/types.js";
|
|
@@ -190,3 +191,54 @@ describe("rule engine dynamic matching", () => {
|
|
|
190
191
|
expect(matchCalls).toBe(2);
|
|
191
192
|
});
|
|
192
193
|
});
|
|
194
|
+
|
|
195
|
+
describe("rule engine default source selection", () => {
|
|
196
|
+
it("#given auto source selection #when loading static rules #then Codex-native and Claude-home sources are disabled by default", () => {
|
|
197
|
+
// given
|
|
198
|
+
let capturedDisabledSources: ReadonlySet<string> | undefined;
|
|
199
|
+
const deps = {
|
|
200
|
+
findProjectRoot: () => projectRoot,
|
|
201
|
+
findCandidates: (options) => {
|
|
202
|
+
capturedDisabledSources = options.disabledSources;
|
|
203
|
+
return [];
|
|
204
|
+
},
|
|
205
|
+
readFile: () => null,
|
|
206
|
+
} satisfies EngineDeps;
|
|
207
|
+
const engine = createEngine(defaultConfig(), deps);
|
|
208
|
+
|
|
209
|
+
// when
|
|
210
|
+
engine.loadStaticRules(projectRoot);
|
|
211
|
+
|
|
212
|
+
// then
|
|
213
|
+
expect(capturedDisabledSources?.has("AGENTS.md")).toBe(true);
|
|
214
|
+
expect(capturedDisabledSources?.has("~/.claude/rules")).toBe(true);
|
|
215
|
+
expect(capturedDisabledSources?.has("~/.claude/CLAUDE.md")).toBe(true);
|
|
216
|
+
expect(capturedDisabledSources?.has("CLAUDE.md")).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("#given Codex-native and Claude-home sources are explicitly enabled #when loading static rules #then they are not disabled", () => {
|
|
220
|
+
// given
|
|
221
|
+
let capturedDisabledSources: ReadonlySet<string> | undefined;
|
|
222
|
+
const deps = {
|
|
223
|
+
findProjectRoot: () => projectRoot,
|
|
224
|
+
findCandidates: (options) => {
|
|
225
|
+
capturedDisabledSources = options.disabledSources;
|
|
226
|
+
return [];
|
|
227
|
+
},
|
|
228
|
+
readFile: () => null,
|
|
229
|
+
} satisfies EngineDeps;
|
|
230
|
+
const engine = createEngine(
|
|
231
|
+
configFromEnvironment({ CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,~/.claude/CLAUDE.md,plugin-bundled" }),
|
|
232
|
+
deps,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// when
|
|
236
|
+
engine.loadStaticRules(projectRoot);
|
|
237
|
+
|
|
238
|
+
// then
|
|
239
|
+
expect(capturedDisabledSources?.has("AGENTS.md")).toBe(false);
|
|
240
|
+
expect(capturedDisabledSources?.has("~/.claude/CLAUDE.md")).toBe(false);
|
|
241
|
+
expect(capturedDisabledSources?.has("plugin-bundled")).toBe(false);
|
|
242
|
+
expect(capturedDisabledSources?.has(".omo/rules")).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SOURCE_PRIORITY } from "../src/rules/constants.js";
|
|
4
|
+
import { defaultConfig } from "../src/rules/engine.js";
|
|
5
|
+
import { disabledSourcesFromConfig } from "../src/rules/sources.js";
|
|
6
|
+
import type { PiRulesConfig } from "../src/rules/types.js";
|
|
7
|
+
|
|
8
|
+
describe("rules source selection", () => {
|
|
9
|
+
it("#given default config #when disabled sources are derived #then opt-out sources stay disabled", () => {
|
|
10
|
+
// given
|
|
11
|
+
const config = defaultConfig();
|
|
12
|
+
const expected = new Set(["AGENTS.md", "~/.claude/rules", "~/.claude/CLAUDE.md"]);
|
|
13
|
+
|
|
14
|
+
// when
|
|
15
|
+
const disabledSources = disabledSourcesFromConfig(config);
|
|
16
|
+
expect(disabledSources).toBeDefined();
|
|
17
|
+
if (disabledSources === undefined) return;
|
|
18
|
+
|
|
19
|
+
// then
|
|
20
|
+
expect(disabledSources).toEqual(expected);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("#given explicit enabled sources #when disabled source set is derived #then all other known sources are omitted", () => {
|
|
24
|
+
// given
|
|
25
|
+
const config: PiRulesConfig = {
|
|
26
|
+
...defaultConfig(),
|
|
27
|
+
enabledSources: [".omo/rules", "AGENTS.md"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// when
|
|
31
|
+
const disabledSources = disabledSourcesFromConfig(config);
|
|
32
|
+
expect(disabledSources).toBeDefined();
|
|
33
|
+
if (disabledSources === undefined) return;
|
|
34
|
+
|
|
35
|
+
// then
|
|
36
|
+
for (const source of [".omo/rules", "AGENTS.md"]) {
|
|
37
|
+
expect(disabledSources.has(source)).toBe(false);
|
|
38
|
+
}
|
|
39
|
+
expect(disabledSources.has("plugin-bundled")).toBe(true);
|
|
40
|
+
expect(disabledSources.has("~/.claude/rules")).toBe(true);
|
|
41
|
+
expect(disabledSources).toEqual(
|
|
42
|
+
new Set([...SOURCE_PRIORITY.keys()].filter((source) => source !== ".omo/rules" && source !== "AGENTS.md")),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -35,6 +35,8 @@ node ${PLUGIN_ROOT}/dist/cli.js hook user-prompt-submit
|
|
|
35
35
|
|
|
36
36
|
Codex passes the prompt payload on stdin. When the pattern `\b(?:ultrawork|ulw)\b` (case-insensitive) matches, the hook writes the directive to stdout — Codex injects non-JSON stdout as `additional_context` for the next turn. Otherwise the hook writes nothing and exits 0. Malformed input also exits 0 to never block the turn.
|
|
37
37
|
|
|
38
|
+
If a prior `UserPromptSubmit` hook output in transcript JSONL already contains `<ultrawork-mode>`, the hook suppresses itself so the same directive is not injected repeatedly. Plain transcript text containing `<ultrawork-mode>` is ignored unless it comes from hook output.
|
|
39
|
+
|
|
38
40
|
Bundled agent role TOMLs in `agents/` ship to `CODEX_HOME/agents/` at install time, not via a runtime hook. The installer creates a symlink on Linux / macOS and a file copy on Windows (because symlinks require admin privileges or Developer Mode). For the public marketplace, the source is the stable installed-marketplace snapshot, not the versioned plugin cache, so agent role configs remain valid when Codex replaces `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/` during auto-update. Both code paths overwrite stale files and write a `.installed-agents.json` manifest next to the source root for clean uninstall tracking.
|
|
39
41
|
|
|
40
42
|
## Smoke test
|
|
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { ULTRAWORK_DIRECTIVE } from "./directive.js";
|
|
4
4
|
|
|
5
5
|
const ULTRAWORK_PATTERN = /\b(?:ultrawork|ulw)\b/i;
|
|
6
|
+
const ULTRAWORK_DIRECTIVE_MARKER = "<ultrawork-mode>";
|
|
7
|
+
const TRANSCRIPT_SEARCH_BYTES = 512_000;
|
|
6
8
|
const CONTEXT_PRESSURE_MARKERS = [
|
|
7
9
|
"context compacted",
|
|
8
10
|
"context_length_exceeded",
|
|
@@ -29,10 +31,54 @@ interface UserPromptSubmitHookOutput {
|
|
|
29
31
|
export function runUserPromptSubmitHook(input: unknown): string {
|
|
30
32
|
if (!isCodexUserPromptSubmitInput(input)) return "";
|
|
31
33
|
if (isContextPressureRecoveryPrompt(input.prompt)) return "";
|
|
34
|
+
if (hasUltraworkDirectiveAlreadyInTranscript(input.transcript_path)) return "";
|
|
32
35
|
if (isContextPressureTranscript(input.transcript_path)) return "";
|
|
33
36
|
return isUltraworkPrompt(input.prompt) ? formatAdditionalContextOutput(ULTRAWORK_DIRECTIVE) : "";
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
function hasUltraworkDirectiveAlreadyInTranscript(transcriptPath: string | null | undefined): boolean {
|
|
40
|
+
if (transcriptPath === undefined || transcriptPath === null) return false;
|
|
41
|
+
try {
|
|
42
|
+
const rawTranscript = readTranscriptTail(transcriptPath);
|
|
43
|
+
for (const line of rawTranscript.split(/\r?\n/)) {
|
|
44
|
+
const parsed = parseJsonLine(line);
|
|
45
|
+
if (parsed === null) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!isRecord(parsed)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hookSpecificOutput = parsed["hookSpecificOutput"];
|
|
54
|
+
if (!isRecord(hookSpecificOutput)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (hookSpecificOutput["hookEventName"] !== "UserPromptSubmit") {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
typeof hookSpecificOutput["additionalContext"] === "string" &&
|
|
64
|
+
hookSpecificOutput["additionalContext"].includes(ULTRAWORK_DIRECTIVE_MARKER)
|
|
65
|
+
) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof Error) return false;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readTranscriptTail(transcriptPath: string): string {
|
|
78
|
+
const rawTranscript = readFileSync(transcriptPath);
|
|
79
|
+
return rawTranscript.subarray(Math.max(0, rawTranscript.byteLength - TRANSCRIPT_SEARCH_BYTES)).toString("utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
export function isUltraworkPrompt(prompt: string): boolean {
|
|
37
83
|
return ULTRAWORK_PATTERN.test(prompt);
|
|
38
84
|
}
|
|
@@ -68,6 +114,22 @@ function normalizeAdditionalContext(additionalContext: string): string {
|
|
|
68
114
|
return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
69
115
|
}
|
|
70
116
|
|
|
117
|
+
function parseJsonLine(line: string): unknown | null {
|
|
118
|
+
if (line.trim().length === 0) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const parsed: unknown = JSON.parse(line);
|
|
124
|
+
return parsed;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof Error) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
71
133
|
function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput {
|
|
72
134
|
return (
|
|
73
135
|
isRecord(value) &&
|
|
@@ -31,6 +31,49 @@ describe("codex ultrawork hook", () => {
|
|
|
31
31
|
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/First user-visible line this turn MUST be exactly:/);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("#given transcript already contains ultrawork directive #when hook sees ultrawork prompt #then it does not repeat directive", () => {
|
|
35
|
+
// given
|
|
36
|
+
const payload = {
|
|
37
|
+
hook_event_name: "UserPromptSubmit",
|
|
38
|
+
prompt: "please ulw this change",
|
|
39
|
+
transcript_path: writeTranscript(
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
hookSpecificOutput: {
|
|
42
|
+
hookEventName: "UserPromptSubmit",
|
|
43
|
+
additionalContext: "<ultrawork-mode>\nexisting directive",
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// when
|
|
50
|
+
const output = runUserPromptSubmitHook(payload);
|
|
51
|
+
|
|
52
|
+
// then
|
|
53
|
+
expect(output).toBe("");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("#given transcript only mentions ultrawork marker in user content #when hook sees first ultrawork prompt #then it emits directive", () => {
|
|
57
|
+
// given
|
|
58
|
+
const payload = {
|
|
59
|
+
hook_event_name: "UserPromptSubmit",
|
|
60
|
+
prompt: "please ulw this change",
|
|
61
|
+
transcript_path: writeTranscript(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
role: "user",
|
|
64
|
+
content: "Please inspect text containing <ultrawork-mode> but do not activate yet.",
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// when
|
|
70
|
+
const output = runUserPromptSubmitHook(payload);
|
|
71
|
+
const parsed = parseHookOutput(output);
|
|
72
|
+
|
|
73
|
+
// then
|
|
74
|
+
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/^<ultrawork-mode>/);
|
|
75
|
+
});
|
|
76
|
+
|
|
34
77
|
it("#given identifier-like ulw #when hook runs #then does not emit directive", () => {
|
|
35
78
|
// given
|
|
36
79
|
const payload = {
|
|
@@ -244,6 +287,14 @@ function writeContextPressureTranscript(): string {
|
|
|
244
287
|
return transcriptPath;
|
|
245
288
|
}
|
|
246
289
|
|
|
290
|
+
function writeTranscript(...lines: string[]): string {
|
|
291
|
+
const root = mkdtempSync(path.join(tmpdir(), "codex-ultrawork-transcript-"));
|
|
292
|
+
tempDirectories.push(root);
|
|
293
|
+
const transcriptPath = path.join(root, "transcript.jsonl");
|
|
294
|
+
writeFileSync(transcriptPath, `${lines.join("\n")}\n`);
|
|
295
|
+
return transcriptPath;
|
|
296
|
+
}
|
|
297
|
+
|
|
247
298
|
function writeCodexContextWindowTranscript(): string {
|
|
248
299
|
const root = mkdtempSync(path.join(tmpdir(), "codex-ultrawork-context-window-"));
|
|
249
300
|
tempDirectories.push(root);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { mkdir, readFile,
|
|
3
|
+
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -169,7 +169,9 @@ async function configPaths({ env, cwd }) {
|
|
|
169
169
|
const codexHome = resolve(env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
170
170
|
const paths = new Set([join(codexHome, "config.toml")]);
|
|
171
171
|
for (const projectConfig of projectConfigPaths({ cwd, stopAt: homedir() })) {
|
|
172
|
-
if (await
|
|
172
|
+
if (!(await isRegularFile(projectConfig))) continue;
|
|
173
|
+
if (!(await isRegularDirectory(dirname(projectConfig)))) continue;
|
|
174
|
+
paths.add(projectConfig);
|
|
173
175
|
}
|
|
174
176
|
return [...paths];
|
|
175
177
|
}
|
|
@@ -216,10 +218,18 @@ async function writeState(statePath, state) {
|
|
|
216
218
|
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
217
219
|
}
|
|
218
220
|
|
|
219
|
-
async function
|
|
221
|
+
async function isRegularFile(path) {
|
|
220
222
|
try {
|
|
221
|
-
await
|
|
222
|
-
|
|
223
|
+
return (await lstat(path)).isFile();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function isRegularDirectory(path) {
|
|
231
|
+
try {
|
|
232
|
+
return (await lstat(path)).isDirectory();
|
|
223
233
|
} catch (error) {
|
|
224
234
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
225
235
|
throw error;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -32,6 +32,66 @@ test("#given stale root reasoning config #when ensuring config #then replaces st
|
|
|
32
32
|
assert.match(result, /\[features\]/);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
test("#given project .codex is a symlink #when migrating #then project config is skipped", async (t) => {
|
|
36
|
+
if (!(await canCreateSymlink("dir"))) t.skip("symbolic links are unavailable in this environment");
|
|
37
|
+
|
|
38
|
+
const root = await mkdtemp(join(tmpdir(), "lazycodex-config-symlink-dir-"));
|
|
39
|
+
const codexHome = join(root, "codex-home");
|
|
40
|
+
const project = join(root, "project");
|
|
41
|
+
const projectNested = join(project, "nested");
|
|
42
|
+
const projectCodexDirectory = join(root, "project-codex-real");
|
|
43
|
+
const projectConfigTarget = join(projectCodexDirectory, "config.toml");
|
|
44
|
+
const projectConfig = join(project, ".codex", "config.toml");
|
|
45
|
+
|
|
46
|
+
await mkdir(codexHome, { recursive: true });
|
|
47
|
+
await mkdir(projectCodexDirectory, { recursive: true });
|
|
48
|
+
await mkdir(dirname(projectConfigTarget), { recursive: true });
|
|
49
|
+
await mkdir(projectNested, { recursive: true });
|
|
50
|
+
await writeFile(join(codexHome, "config.toml"), 'model = "gpt-5.2"\n');
|
|
51
|
+
await writeFile(projectConfigTarget, 'model = "gpt-5.2"\nmodel_context_window = 272000\n');
|
|
52
|
+
await rm(join(project, ".codex"), { recursive: true, force: true });
|
|
53
|
+
await symlink(projectCodexDirectory, join(project, ".codex"), "dir");
|
|
54
|
+
|
|
55
|
+
const result = await migrateCodexConfig({
|
|
56
|
+
env: {
|
|
57
|
+
CODEX_HOME: codexHome,
|
|
58
|
+
LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
|
|
59
|
+
},
|
|
60
|
+
cwd: projectNested,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(result.changed, [join(codexHome, "config.toml")]);
|
|
64
|
+
assert.match(await readFile(projectConfig, "utf8"), /model = "gpt-5\.2"/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("#given project config.toml is a symlink #when migrating #then project config is skipped", async (t) => {
|
|
68
|
+
if (!(await canCreateSymlink("file"))) t.skip("symbolic links are unavailable in this environment");
|
|
69
|
+
|
|
70
|
+
const root = await mkdtemp(join(tmpdir(), "lazycodex-config-symlink-file-"));
|
|
71
|
+
const codexHome = join(root, "codex-home");
|
|
72
|
+
const project = join(root, "project");
|
|
73
|
+
const projectConfigDirectory = join(project, ".codex");
|
|
74
|
+
const projectConfig = join(projectConfigDirectory, "config.toml");
|
|
75
|
+
const realConfigSource = join(root, "shared-config.toml");
|
|
76
|
+
|
|
77
|
+
await mkdir(codexHome, { recursive: true });
|
|
78
|
+
await mkdir(projectConfigDirectory, { recursive: true });
|
|
79
|
+
await writeFile(join(codexHome, "config.toml"), 'model = "gpt-5.2"\n');
|
|
80
|
+
await writeFile(realConfigSource, 'model = "gpt-5.2"\nmodel_context_window = 272000\n');
|
|
81
|
+
await symlink(realConfigSource, projectConfig, "file");
|
|
82
|
+
|
|
83
|
+
const result = await migrateCodexConfig({
|
|
84
|
+
env: {
|
|
85
|
+
CODEX_HOME: codexHome,
|
|
86
|
+
LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json"),
|
|
87
|
+
},
|
|
88
|
+
cwd: project,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.deepEqual(result.changed, [join(codexHome, "config.toml")]);
|
|
92
|
+
assert.match(await readFile(realConfigSource, "utf8"), /model = "gpt-5\.2"/);
|
|
93
|
+
});
|
|
94
|
+
|
|
35
95
|
test("#given global and project-local stale Codex configs #when migrating #then both configs are forced to current defaults", async () => {
|
|
36
96
|
const root = await mkdtemp(join(tmpdir(), "lazycodex-config-migration-"));
|
|
37
97
|
const codexHome = join(root, "codex-home");
|
|
@@ -144,3 +204,29 @@ test("#given managed catalog state #when catalog version advances #then only pre
|
|
|
144
204
|
assert.match(content, /model = "gpt-5\.5"/);
|
|
145
205
|
assert.match(content, /model_context_window = 400000/);
|
|
146
206
|
});
|
|
207
|
+
|
|
208
|
+
async function canCreateSymlink(type) {
|
|
209
|
+
const root = await mkdtemp(join(tmpdir(), "lazycodex-symlink-capability-"));
|
|
210
|
+
const target = join(root, "target");
|
|
211
|
+
const link = join(root, "link");
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
if (type === "dir") {
|
|
215
|
+
await mkdir(target, { recursive: true });
|
|
216
|
+
await symlink(target, link, "dir");
|
|
217
|
+
} else {
|
|
218
|
+
await writeFile(target, "");
|
|
219
|
+
await symlink(target, link, "file");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await rm(link);
|
|
223
|
+
await rm(target, { recursive: true, force: true });
|
|
224
|
+
await rm(root, { recursive: true, force: true });
|
|
225
|
+
return true;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await rm(root, { recursive: true, force: true });
|
|
228
|
+
if (!(error instanceof Error)) throw error;
|
|
229
|
+
if (error.code === "EPERM" || error.code === "EEXIST") return false;
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|