diff --git a/packages/complexity-gate/README.md b/packages/complexity-gate/README.md
index 7820c18..8fb73af 100644
--- a/packages/complexity-gate/README.md
+++ b/packages/complexity-gate/README.md
@@ -1,17 +1,19 @@
# @pickforge/complexity-gate
-Function-complexity feedback and stop gates for Pi, Claude Code, and Codex. The npm package downloads the matching Rust binary, verifies its SHA-256 checksum, and keeps install non-fatal when a release or network is unavailable.
+Function-complexity feedback and completion gates for Claude Code, Codex, Pi, OMP, Grok, Cursor, and OpenCode. The npm package downloads the matching Rust binary, verifies its SHA-256 checksum, and keeps installation non-fatal when a release or network is unavailable.
## Install
-Requires Node 22 or newer. Set `COMPLEXITY_GATE_BIN` to an existing binary to skip the release download. Set `COMPLEXITY_GATE_VERSION` to a release tag (default `v0.1.1`).
+Requires Node 22 or newer. Set `COMPLEXITY_GATE_BIN` to an existing binary to skip the release download. Set `COMPLEXITY_GATE_VERSION` to a release tag (default `v0.2.0`).
+
+Want your coding agent to perform the setup? Send it the [AI installation guide](https://github.com/pickforge/complexity-gate/blob/main/INSTALL_WITH_AGENT.md). It covers harness selection, hooks, plugins, agent instructions, and verification.
```bash
npm install -g @pickforge/complexity-gate
-complexity-gate-install --all
+complexity-gate-install
```
-Choose one or more harnesses with `--harness claude,codex,pi`. With no flags, the installer prompts for a comma-separated list. `--print` prints configuration without writing, and `--home
` changes the settings root.
+The package install adds only the binary. The second command asks which harness integrations to install; choose a comma-separated list, `all`, or `none`. For automation, use `--all` or `--harness claude,codex`. `--print` prints configuration without writing, and `--home ` changes the JSON hook settings root.
### Pi
@@ -39,6 +41,22 @@ cp -r node_modules/@pickforge/complexity-gate/codex-skill/complexity-gate ~/.cod
The installer merges `codex-hooks.json` into `~/.codex/hooks.json`.
+### OMP
+
+The installer runs `omp plugin install npm:@pickforge/complexity-gate`. OMP uses the same after-edit and agent-end extension as Pi.
+
+### Grok
+
+The installer adds native `PostToolUse` and `Stop` hooks under `~/.grok/hooks/`. If Claude Code or Cursor hooks are already selected, Grok reuses those compatible hooks to avoid running the gate twice. Edit findings appear in the hook annotation; the stop hook blocks completion until the changed functions pass.
+
+### Cursor
+
+The installer merges `afterFileEdit` and `stop` hooks into `~/.cursor/hooks.json`. Edit findings appear in hook output, and the stop hook sends a correction follow-up when changed functions fail.
+
+### OpenCode
+
+The installer runs `opencode plugin @pickforge/complexity-gate --global`, which updates OpenCode's global config. The plugin appends edit findings to tool output and continues an idle session when changed functions fail.
+
## Configure
Run `complexity-gate init` to create `.complexity-gate.json`. The defaults are complexity 15, depth 4, 100 nonblank/non-comment lines, and 6 parameters. See the installed skill for the refactoring workflow.
@@ -47,4 +65,4 @@ The wrapper resolves the executable in this order: `COMPLEXITY_GATE_BIN`, the ve
## Hook documentation
-Hook formats and event names were checked against the Claude Code plugin/hooks and Codex hooks documentation on 2026-08-26. Both currently expose `PostToolUse` and `Stop`; both fragments invoke the Rust binary's harness-specific hook adapter.
+Hook formats and event names were checked against the official Claude Code, Codex, Grok, Cursor, and OpenCode documentation on 2026-09-03. Pi and OMP use their native extension API.
diff --git a/packages/complexity-gate/claude-plugin/skills/complexity-gate/SKILL.md b/packages/complexity-gate/claude-plugin/skills/complexity-gate/SKILL.md
index c356adf..1b274b4 100644
--- a/packages/complexity-gate/claude-plugin/skills/complexity-gate/SKILL.md
+++ b/packages/complexity-gate/claude-plugin/skills/complexity-gate/SKILL.md
@@ -1,10 +1,13 @@
---
name: complexity-gate
-description: Measure and reduce function complexity with the complexity-gate binary. Use when a complexity-gate hook reports FAIL lines, when the user asks to refactor, simplify, or clean up code, mentions complexity, maintainability, deeply nested logic, or god functions, or after writing any nontrivial branching code.
+description: Measure and reduce function complexity with the complexity-gate binary. Use for every coding and code-review task, when a complexity-gate hook reports FAIL lines, or when the user asks to refactor or simplify code.
---
# Complexity gate
+For every coding or code-review task, run `complexity-gate check --changed`
+before completion. Hooks provide immediate feedback but do not replace this final check.
+
Never estimate complexity yourself. The only accepted numbers come from:
```bash
diff --git a/packages/complexity-gate/codex-skill/complexity-gate/SKILL.md b/packages/complexity-gate/codex-skill/complexity-gate/SKILL.md
index c356adf..1b274b4 100644
--- a/packages/complexity-gate/codex-skill/complexity-gate/SKILL.md
+++ b/packages/complexity-gate/codex-skill/complexity-gate/SKILL.md
@@ -1,10 +1,13 @@
---
name: complexity-gate
-description: Measure and reduce function complexity with the complexity-gate binary. Use when a complexity-gate hook reports FAIL lines, when the user asks to refactor, simplify, or clean up code, mentions complexity, maintainability, deeply nested logic, or god functions, or after writing any nontrivial branching code.
+description: Measure and reduce function complexity with the complexity-gate binary. Use for every coding and code-review task, when a complexity-gate hook reports FAIL lines, or when the user asks to refactor or simplify code.
---
# Complexity gate
+For every coding or code-review task, run `complexity-gate check --changed`
+before completion. Hooks provide immediate feedback but do not replace this final check.
+
Never estimate complexity yourself. The only accepted numbers come from:
```bash
diff --git a/packages/complexity-gate/package.json b/packages/complexity-gate/package.json
index 777d534..9ab374c 100644
--- a/packages/complexity-gate/package.json
+++ b/packages/complexity-gate/package.json
@@ -4,7 +4,7 @@
"description": "Cross-harness function complexity gate for coding agents.",
"type": "module",
"license": "MIT",
- "keywords": ["pi-package", "claude-code-plugin", "codex", "code-review", "complexity"],
+ "keywords": ["pi-package", "claude-code-plugin", "codex", "cursor", "grok", "omp", "opencode", "code-review", "complexity"],
"repository": {
"type": "git",
"url": "git+https://github.com/pickforge/pickforge-platform.git",
@@ -13,6 +13,10 @@
"homepage": "https://github.com/pickforge/pickforge-platform/tree/main/packages/complexity-gate",
"engines": { "node": ">=22" },
"publishConfig": { "access": "public" },
+ "main": "./dist/opencode.js",
+ "exports": {
+ ".": "./dist/opencode.js"
+ },
"pi": {
"extensions": ["extensions/complexity-gate.ts"],
"skills": ["skills/complexity-gate"]
@@ -23,7 +27,7 @@
},
"files": ["bin", "dist", "scripts", "extensions", "skills", "claude-plugin", "codex-skill", "codex-hooks.json", "README.md"],
"scripts": {
- "build": "tsup src/install.ts src/postinstall.ts --format esm --clean --splitting false --out-dir dist",
+ "build": "tsup src/install.ts src/postinstall.ts src/opencode.ts --format esm --clean --splitting false --out-dir dist",
"postinstall": "node scripts/postinstall.cjs",
"test": "cd ../.. && vitest run packages/complexity-gate/test",
"typecheck": "tsc -p ../../tsconfig.json --noEmit"
diff --git a/packages/complexity-gate/skills/complexity-gate/SKILL.md b/packages/complexity-gate/skills/complexity-gate/SKILL.md
index c356adf..1b274b4 100644
--- a/packages/complexity-gate/skills/complexity-gate/SKILL.md
+++ b/packages/complexity-gate/skills/complexity-gate/SKILL.md
@@ -1,10 +1,13 @@
---
name: complexity-gate
-description: Measure and reduce function complexity with the complexity-gate binary. Use when a complexity-gate hook reports FAIL lines, when the user asks to refactor, simplify, or clean up code, mentions complexity, maintainability, deeply nested logic, or god functions, or after writing any nontrivial branching code.
+description: Measure and reduce function complexity with the complexity-gate binary. Use for every coding and code-review task, when a complexity-gate hook reports FAIL lines, or when the user asks to refactor or simplify code.
---
# Complexity gate
+For every coding or code-review task, run `complexity-gate check --changed`
+before completion. Hooks provide immediate feedback but do not replace this final check.
+
Never estimate complexity yourself. The only accepted numbers come from:
```bash
diff --git a/packages/complexity-gate/src/install.ts b/packages/complexity-gate/src/install.ts
index f9c1d7d..5859e30 100644
--- a/packages/complexity-gate/src/install.ts
+++ b/packages/complexity-gate/src/install.ts
@@ -7,10 +7,12 @@ import { spawn } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
-export type Harness = "claude" | "codex" | "pi";
+export type Harness = "claude" | "codex" | "pi" | "omp" | "grok" | "cursor" | "opencode";
type Json = Record;
-export const hookFragments: Record, Json> = {
+const harnessNames: Harness[] = ["claude", "codex", "pi", "omp", "grok", "cursor", "opencode"];
+
+export const hookFragments: Record<"claude" | "codex" | "grok" | "cursor", Json> = {
claude: {
hooks: {
PostToolUse: [{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: "complexity-gate hook claude" }] }],
@@ -23,6 +25,19 @@ export const hookFragments: Record, Json> = {
Stop: [{ hooks: [{ type: "command", command: "complexity-gate hook codex" }] }],
},
},
+ grok: {
+ hooks: {
+ PostToolUse: [{ matcher: "edit|write|apply_patch|Edit|Write|MultiEdit", hooks: [{ type: "command", command: "complexity-gate hook grok" }] }],
+ Stop: [{ hooks: [{ type: "command", command: "complexity-gate hook grok" }] }],
+ },
+ },
+ cursor: {
+ version: 1,
+ hooks: {
+ afterFileEdit: [{ command: "complexity-gate hook cursor" }],
+ stop: [{ command: "complexity-gate hook cursor", loop_limit: 3 }],
+ },
+ },
};
function stable(value: unknown): string { return JSON.stringify(value); }
@@ -35,10 +50,11 @@ function isPlainObject(value: unknown): value is Json {
function validateDocument(value: unknown, source: string): asserts value is Json {
if (!isPlainObject(value)) throw new Error(`${source}: expected a JSON object`);
- if (value.hooks === undefined) return;
- if (!isPlainObject(value.hooks)) throw new Error(`${source}: expected "hooks" to be an object`);
- for (const [event, entries] of Object.entries(value.hooks)) {
- if (!Array.isArray(entries)) throw new Error(`${source}: expected "hooks.${event}" to be an array`);
+ if (value.hooks !== undefined) {
+ if (!isPlainObject(value.hooks)) throw new Error(`${source}: expected "hooks" to be an object`);
+ for (const [event, entries] of Object.entries(value.hooks)) {
+ if (!Array.isArray(entries)) throw new Error(`${source}: expected "hooks.${event}" to be an array`);
+ }
}
}
@@ -53,7 +69,9 @@ export function mergeHookFragment(existing: Json, fragment: Json): Json {
const additions = (entries as unknown[]).filter((entry) => !current.some((item) => stable(item) === stable(entry)));
hooks[event] = [...current, ...additions];
}
- return { ...existing, hooks };
+ const merged = { ...fragment, ...existing };
+ if (Object.keys(hooks).length) merged.hooks = hooks;
+ return merged;
}
async function readJson(path: string): Promise {
@@ -76,6 +94,26 @@ async function mergeFile(path: string, fragment: Json): Promise {
await writeFile(path, `${JSON.stringify(merged, null, 2)}\n`);
}
+function containsGateHook(value: unknown): boolean {
+ if (typeof value === "string") return value.includes("complexity-gate hook ");
+ if (Array.isArray(value)) return value.some(containsGateHook);
+ return isPlainObject(value) && Object.values(value).some(containsGateHook);
+}
+
+async function hasGateHook(path: string): Promise {
+ try {
+ return containsGateHook(JSON.parse(await readFile(path, "utf8")));
+ } catch {
+ return false;
+ }
+}
+
+async function hasGrokCompatibleHook(home: string, harnesses: Harness[]): Promise {
+ if (harnesses.includes("claude") || harnesses.includes("cursor")) return true;
+ return await hasGateHook(join(home, ".claude", "settings.json"))
+ || await hasGateHook(join(home, ".cursor", "hooks.json"));
+}
+
async function executable(name: string): Promise {
const paths = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
for (const path of paths) {
@@ -91,6 +129,20 @@ export function spawnPi(command: string, platform = process.platform, spawnProce
: spawnProcess(command, args, { stdio: "inherit" });
}
+export function spawnOmp(command: string, platform = process.platform, spawnProcess = spawn): ReturnType {
+ const args = ["plugin", "install", "npm:@pickforge/complexity-gate"];
+ return platform === "win32"
+ ? spawnProcess("cmd.exe", ["/d", "/s", "/c", command, ...args], { stdio: "inherit" })
+ : spawnProcess(command, args, { stdio: "inherit" });
+}
+
+export function spawnOpenCode(command: string, platform = process.platform, spawnProcess = spawn): ReturnType {
+ const args = ["plugin", "@pickforge/complexity-gate", "--global"];
+ return platform === "win32"
+ ? spawnProcess("cmd.exe", ["/d", "/s", "/c", command, ...args], { stdio: "inherit" })
+ : spawnProcess(command, args, { stdio: "inherit" });
+}
+
async function runPi(): Promise {
const command = process.platform === "win32" ? "pi.cmd" : "pi";
if (!(await executable(command))) {
@@ -104,6 +156,32 @@ async function runPi(): Promise {
});
}
+async function runOmp(): Promise {
+ const command = process.platform === "win32" ? "omp.cmd" : "omp";
+ if (!(await executable(command))) {
+ console.log("omp plugin install npm:@pickforge/complexity-gate");
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ const child = spawnOmp(command);
+ child.on("error", reject);
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`omp plugin install exited ${code}`)));
+ });
+}
+
+async function runOpenCode(): Promise {
+ const command = process.platform === "win32" ? "opencode.cmd" : "opencode";
+ if (!(await executable(command))) {
+ console.log("opencode plugin @pickforge/complexity-gate --global");
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ const child = spawnOpenCode(command);
+ child.on("error", reject);
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`opencode plugin exited ${code}`)));
+ });
+}
+
export function parseArgs(argv: string[]): { harnesses: Harness[]; print: boolean; home: string } {
let home = homedir();
let print = false;
@@ -111,33 +189,64 @@ export function parseArgs(argv: string[]): { harnesses: Harness[]; print: boolea
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--print") print = true;
- else if (arg === "--all") harnesses.push("claude", "codex", "pi");
+ else if (arg === "--all") harnesses.push(...harnessNames);
else if (arg === "--home" && argv[index + 1]) home = argv[index += 1]!;
- else if (arg === "--harness" && argv[index + 1]) harnesses.push(...argv[index += 1]!.split(",") as Harness[]);
- else throw new Error(`unknown or incomplete option: ${arg}`);
+ else if (arg === "--harness" && argv[index + 1]) {
+ harnesses.push(...argv[index += 1]!.split(",").map((value) => value.trim()).filter(Boolean) as Harness[]);
+ } else throw new Error(`unknown or incomplete option: ${arg}`);
}
const unique = [...new Set(harnesses)];
- if (unique.some((value) => !["claude", "codex", "pi"].includes(value))) throw new Error("--harness expects claude,codex,pi");
+ if (unique.some((value) => !harnessNames.includes(value))) {
+ throw new Error(`--harness expects ${harnessNames.join(",")}`);
+ }
return { harnesses: unique, print, home };
}
+export function parseHarnessSelection(answer: string): Harness[] {
+ const selection = answer.trim().toLowerCase();
+ if (selection === "none" || selection === "") return [];
+ if (selection === "all") return [...harnessNames];
+ return parseArgs(["--harness", selection]).harnesses;
+}
+
async function promptHarnesses(): Promise {
const input = createInterface({ input: process.stdin, output: process.stdout });
- const answer = await input.question("Install for harnesses (claude,codex,pi; comma-separated): ");
+ const answer = await input.question(`Install hooks/plugins for harnesses (${harnessNames.join(",")}; comma-separated, all, or none): `);
input.close();
- return parseArgs(["--harness", answer]).harnesses;
+ return parseHarnessSelection(answer);
+}
+
+function printHarness(harness: Harness): void {
+ if (harness === "pi") console.log("pi install npm:@pickforge/complexity-gate");
+ else if (harness === "omp") console.log("omp plugin install npm:@pickforge/complexity-gate");
+ else if (harness === "opencode") console.log("opencode plugin @pickforge/complexity-gate --global");
+ else console.log(JSON.stringify(hookFragments[harness], null, 2));
+}
+
+async function installHarnesses(harnesses: Harness[], home: string): Promise {
+ if (harnesses.includes("claude")) await mergeFile(join(home, ".claude", "settings.json"), hookFragments.claude);
+ if (harnesses.includes("codex")) await mergeFile(join(home, ".codex", "hooks.json"), hookFragments.codex);
+ if (harnesses.includes("pi")) await runPi();
+ if (harnesses.includes("omp")) await runOmp();
+ if (harnesses.includes("grok")) {
+ if (await hasGrokCompatibleHook(home, harnesses)) {
+ console.log("grok: using the installed Claude Code or Cursor complexity-gate hooks");
+ } else {
+ await mergeFile(join(home, ".grok", "hooks", "complexity-gate.json"), hookFragments.grok);
+ }
+ }
+ if (harnesses.includes("cursor")) await mergeFile(join(home, ".cursor", "hooks.json"), hookFragments.cursor);
+ if (harnesses.includes("opencode")) await runOpenCode();
}
export async function install(argv = process.argv.slice(2)): Promise {
const options = parseArgs(argv);
const harnesses = options.harnesses.length ? options.harnesses : await promptHarnesses();
if (options.print) {
- for (const harness of harnesses) console.log(harness === "pi" ? "pi install npm:@pickforge/complexity-gate" : JSON.stringify(hookFragments[harness], null, 2));
+ harnesses.forEach(printHarness);
return;
}
- if (harnesses.includes("claude")) await mergeFile(join(options.home, ".claude", "settings.json"), hookFragments.claude);
- if (harnesses.includes("codex")) await mergeFile(join(options.home, ".codex", "hooks.json"), hookFragments.codex);
- if (harnesses.includes("pi")) await runPi();
+ await installHarnesses(harnesses, options.home);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) install().catch((error) => { console.error(`complexity-gate-install: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 2; });
diff --git a/packages/complexity-gate/src/opencode-plugin.ts b/packages/complexity-gate/src/opencode-plugin.ts
new file mode 100644
index 0000000..bfdacdd
--- /dev/null
+++ b/packages/complexity-gate/src/opencode-plugin.ts
@@ -0,0 +1,133 @@
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+type OpenCodeInput = {
+ client: {
+ session: {
+ promptAsync(options: {
+ path: { id: string };
+ query: { directory: string };
+ body: { parts: Array<{ type: "text"; text: string }> };
+ }): Promise;
+ };
+ };
+ directory: string;
+};
+
+type OpenCodeHooks = {
+ "tool.execute.after": (
+ input: { tool: string; sessionID: string; callID: string; args: unknown },
+ output: { title: string; output: string; metadata: unknown },
+ ) => Promise;
+ event: (input: {
+ event: { type: string; properties?: { sessionID?: string } };
+ }) => Promise;
+};
+
+type OpenCodePlugin = (input: OpenCodeInput) => Promise;
+
+type GateRequest = {
+ event: "PostToolUse" | "Stop";
+ sessionID: string;
+ directory: string;
+ tool?: string;
+ file?: string;
+};
+
+export type GateRunner = (request: GateRequest) => Promise;
+
+const wrapper = fileURLToPath(new URL("../bin/complexity-gate", import.meta.url));
+
+export function adapterProcess(platform = process.platform): { command: string; args: string[] } {
+ return platform === "win32"
+ ? { command: "node", args: [wrapper] }
+ : { command: wrapper, args: [] };
+}
+
+function adapterInput(request: GateRequest): string {
+ return JSON.stringify({
+ hook_event_name: request.event,
+ session_id: request.sessionID,
+ cwd: request.directory,
+ tool_name: request.tool,
+ tool_input: request.file ? { file_path: request.file } : {},
+ });
+}
+
+async function runAdapter(request: GateRequest): Promise {
+ return new Promise((resolve) => {
+ const executable = adapterProcess();
+ const child = spawn(executable.command, [...executable.args, "hook", "codex"], {
+ cwd: request.directory,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
+ child.on("error", (error) => {
+ console.warn(`complexity-gate: ${error.message}`);
+ resolve(undefined);
+ });
+ child.on("close", (code) => {
+ if (code !== 0) {
+ console.warn(`complexity-gate: hook failed${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
+ resolve(undefined);
+ return;
+ }
+ try {
+ const result = stdout.trim() ? JSON.parse(stdout) as { reason?: unknown } : {};
+ resolve(typeof result.reason === "string" ? result.reason : undefined);
+ } catch {
+ console.warn("complexity-gate: hook returned invalid JSON");
+ resolve(undefined);
+ }
+ });
+ child.stdin.end(adapterInput(request));
+ });
+}
+
+function mappedTool(tool: string): string | undefined {
+ if (tool === "edit") return "Edit";
+ if (tool === "write") return "Write";
+ if (tool === "apply_patch") return "apply_patch";
+}
+
+function editedFile(args: unknown): string | undefined {
+ if (!args || typeof args !== "object") return undefined;
+ const value = (args as { filePath?: unknown }).filePath
+ ?? (args as { file_path?: unknown }).file_path
+ ?? (args as { path?: unknown }).path;
+ return typeof value === "string" ? value : undefined;
+}
+
+export function createOpenCodePlugin(runGate: GateRunner = runAdapter): OpenCodePlugin {
+ return async ({ client, directory }) => ({
+ "tool.execute.after": async (input, output) => {
+ const tool = mappedTool(input.tool);
+ if (!tool) return;
+ const reason = await runGate({
+ event: "PostToolUse",
+ sessionID: input.sessionID,
+ directory,
+ tool,
+ file: editedFile(input.args),
+ });
+ if (reason) output.output += `\n\ncomplexity-gate feedback:\n${reason}`;
+ },
+ event: async ({ event }) => {
+ if (event.type !== "session.idle" || !event.properties?.sessionID) return;
+ const reason = await runGate({
+ event: "Stop",
+ sessionID: event.properties.sessionID,
+ directory,
+ });
+ if (!reason) return;
+ await client.session.promptAsync({
+ path: { id: event.properties.sessionID },
+ query: { directory },
+ body: { parts: [{ type: "text", text: reason }] },
+ });
+ },
+ });
+}
diff --git a/packages/complexity-gate/src/opencode.ts b/packages/complexity-gate/src/opencode.ts
new file mode 100644
index 0000000..288e792
--- /dev/null
+++ b/packages/complexity-gate/src/opencode.ts
@@ -0,0 +1,3 @@
+import { createOpenCodePlugin } from "./opencode-plugin.js";
+
+export default createOpenCodePlugin();
diff --git a/packages/complexity-gate/src/postinstall.ts b/packages/complexity-gate/src/postinstall.ts
index 273b32f..4d36267 100644
--- a/packages/complexity-gate/src/postinstall.ts
+++ b/packages/complexity-gate/src/postinstall.ts
@@ -53,7 +53,7 @@ export async function downloadBinary(options: DownloadOptions): Promise
const platform = options.platform ?? process.platform;
const target = targets[`${platform}-${options.arch ?? process.arch}`];
if (!target) throw new Error(`unsupported platform: ${platform}/${options.arch ?? process.arch}`);
- const tag = options.tag ?? process.env.COMPLEXITY_GATE_VERSION ?? "v0.1.1";
+ const tag = options.tag ?? process.env.COMPLEXITY_GATE_VERSION ?? "v0.2.0";
const extension = platform === "win32" ? "zip" : "tar.xz";
const archive = `complexity-gate-${target}.${extension}`;
const base = `https://github.com/pickforge/complexity-gate/releases/download/${tag}`;
diff --git a/packages/complexity-gate/test/install.test.ts b/packages/complexity-gate/test/install.test.ts
index a426d98..58cad3f 100644
--- a/packages/complexity-gate/test/install.test.ts
+++ b/packages/complexity-gate/test/install.test.ts
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { hookFragments, install, mergeHookFragment, spawnPi } from "../src/install.ts";
+import { hookFragments, install, mergeHookFragment, parseArgs, parseHarnessSelection, spawnOmp, spawnOpenCode, spawnPi } from "../src/install.ts";
const roots: string[] = [];
afterEach(async () => Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true }))));
@@ -61,12 +61,52 @@ describe("hook installation", () => {
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", "pi.cmd", "install", "npm:@pickforge/complexity-gate"], { stdio: "inherit" });
});
+ it("runs omp.cmd through cmd.exe on Windows", () => {
+ const spawn = vi.fn();
+ spawnOmp("omp.cmd", "win32", spawn as never);
+ expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", "omp.cmd", "plugin", "install", "npm:@pickforge/complexity-gate"], { stdio: "inherit" });
+ });
+
+ it("runs opencode.cmd through cmd.exe on Windows", () => {
+ const spawn = vi.fn();
+ spawnOpenCode("opencode.cmd", "win32", spawn as never);
+ expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", "opencode.cmd", "plugin", "@pickforge/complexity-gate", "--global"], { stdio: "inherit" });
+ });
+
+ it("parses explicit and interactive harness selections", () => {
+ expect(parseArgs(["--all"]).harnesses).toEqual(["claude", "codex", "pi", "omp", "grok", "cursor", "opencode"]);
+ expect(parseHarnessSelection("Cursor, OpenCode")).toEqual(["cursor", "opencode"]);
+ expect(parseHarnessSelection("none")).toEqual([]);
+ expect(parseHarnessSelection("")).toEqual([]);
+ });
+
+ it("writes native Grok and Cursor configuration", async () => {
+ const home = await mkdtemp(join(tmpdir(), "complexity-harnesses-"));
+ roots.push(home);
+ await install(["--harness", "grok", "--home", home]);
+ await install(["--harness", "cursor", "--home", home]);
+ const grok = JSON.parse(await readFile(join(home, ".grok", "hooks", "complexity-gate.json"), "utf8"));
+ const cursor = JSON.parse(await readFile(join(home, ".cursor", "hooks.json"), "utf8"));
+ expect(grok.hooks.Stop[0].hooks[0].command).toContain("hook grok");
+ expect(cursor.hooks.stop[0]).toMatchObject({ command: "complexity-gate hook cursor", loop_limit: 3 });
+ });
+
+ it("reuses compatible hooks instead of running Grok twice", async () => {
+ const home = await mkdtemp(join(tmpdir(), "complexity-grok-compat-"));
+ roots.push(home);
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
+ await install(["--harness", "claude,grok", "--home", home]);
+ expect(log).toHaveBeenCalledWith(expect.stringContaining("using the installed Claude Code"));
+ await expect(readFile(join(home, ".grok", "hooks", "complexity-gate.json"))).rejects.toMatchObject({ code: "ENOENT" });
+ log.mockRestore();
+ });
+
it("prints fragments without writing", async () => {
const home = await mkdtemp(join(tmpdir(), "complexity-print-"));
roots.push(home);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await install(["--print", "--all", "--home", home]);
- expect(log).toHaveBeenCalledTimes(3);
+ expect(log).toHaveBeenCalledTimes(7);
expect(log.mock.calls.flat().join("\n")).toContain("complexity-gate hook codex");
await expect(readFile(join(home, ".claude", "settings.json"))).rejects.toMatchObject({ code: "ENOENT" });
log.mockRestore();
diff --git a/packages/complexity-gate/test/opencode.test.ts b/packages/complexity-gate/test/opencode.test.ts
new file mode 100644
index 0000000..466023e
--- /dev/null
+++ b/packages/complexity-gate/test/opencode.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it, vi } from "vitest";
+import { adapterProcess, createOpenCodePlugin, type GateRunner } from "../src/opencode-plugin.ts";
+
+async function hooks(runGate: GateRunner) {
+ const promptAsync = vi.fn(async () => ({}));
+ const plugin = createOpenCodePlugin(runGate);
+ const result = await plugin({
+ client: { session: { promptAsync } },
+ directory: "/repo",
+ } as never);
+ return { result, promptAsync };
+}
+
+describe("OpenCode plugin", () => {
+ it("runs the package wrapper directly outside Windows", () => {
+ expect(adapterProcess("linux")).toMatchObject({ command: expect.stringContaining("/bin/complexity-gate"), args: [] });
+ expect(adapterProcess("win32")).toMatchObject({ command: "node", args: [expect.stringContaining("/bin/complexity-gate")] });
+ });
+
+ it("adds complexity feedback to edit tool output", async () => {
+ const runGate = vi.fn(async () => "FAIL src/a.ts:2 work depth 5 > 4");
+ const { result } = await hooks(runGate);
+ const output = { title: "Edit", output: "done", metadata: {} };
+ await result["tool.execute.after"]!({
+ tool: "edit",
+ sessionID: "session",
+ callID: "call",
+ args: { filePath: "src/a.ts" },
+ }, output);
+ expect(runGate).toHaveBeenCalledWith({
+ event: "PostToolUse",
+ sessionID: "session",
+ directory: "/repo",
+ tool: "Edit",
+ file: "src/a.ts",
+ });
+ expect(output.output).toContain("complexity-gate feedback");
+ });
+
+ it("continues an idle session when changed functions violate", async () => {
+ const runGate = vi.fn(async () => "FAIL src/a.ts:2 work depth 5 > 4");
+ const { result, promptAsync } = await hooks(runGate);
+ await result.event!({ event: { type: "session.idle", properties: { sessionID: "session" } } as never });
+ expect(promptAsync).toHaveBeenCalledWith({
+ path: { id: "session" },
+ query: { directory: "/repo" },
+ body: { parts: [{ type: "text", text: expect.stringContaining("FAIL src/a.ts") }] },
+ });
+ });
+
+ it("ignores unrelated tools and clean idle sessions", async () => {
+ const runGate = vi.fn(async () => undefined);
+ const { result, promptAsync } = await hooks(runGate);
+ const output = { title: "Read", output: "done", metadata: {} };
+ await result["tool.execute.after"]!({ tool: "read", sessionID: "session", callID: "call", args: {} }, output);
+ await result.event!({ event: { type: "session.idle", properties: { sessionID: "session" } } as never });
+ expect(runGate).toHaveBeenCalledTimes(1);
+ expect(promptAsync).not.toHaveBeenCalled();
+ expect(output.output).toBe("done");
+ });
+});