Skip to content

Commit 6eee2c3

Browse files
committed
release: occam 0.4.0 (occam run + Codex PreToolUse hard-wire)
- engine/run.mjs: occam run <cmd> wraps a command, compacts output, keeps exit code (subshell so compound commands redirect + exit cleanly). - engine/codex.mjs + occam codex-hook: Codex PreToolUse rewrites noisy commands to occam run <cmd> (the one non-Claude host whose hook can rewrite a command). 6 unit tests cover wrap / no-double-wrap / non-noisy / non-Bash / bad input. - occam init codex writes .codex/hooks.json (idempotent merge, no clobber). - detectFilter recognizes npm/yarn/pnpm/bun test, so npm test sieves as a test. - README + CHANGELOG: honest per-agent input story. 18/18 tests, generate --check in sync.
1 parent 0ef9744 commit 6eee2c3

11 files changed

Lines changed: 151 additions & 21 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
"name": "occam",
33
"description": "Occam, write the right amount of code.",
44
"owner": { "name": "borghei", "url": "https://github.com/borghei" },
5-
"version": "0.3.0",
5+
"version": "0.4.0",
66
"plugins": [
77
{
88
"name": "occam",
99
"source": "./",
1010
"displayName": "Occam",
1111
"description": "Always-on right-sizing ruleset for AI coding agents, fewer tokens, compact code, no under-engineering.",
12-
"version": "0.3.0",
12+
"version": "0.4.0",
1313
"author": { "name": "borghei" },
1414
"homepage": "https://github.com/borghei/occam",
1515
"repository": "https://github.com/borghei/occam",

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "occam",
33
"displayName": "Occam",
4-
"version": "0.3.0",
4+
"version": "0.4.0",
55
"description": "Write the right amount of code. An always-on right-sizing ruleset for AI coding agents, fewer tokens, compact code, no under-engineering.",
66
"author": { "name": "borghei", "url": "https://github.com/borghei/occam" },
77
"homepage": "https://github.com/borghei/occam",

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 0.4.0
4+
5+
- `occam run <cmd>`: run a command, compact its output, keep its exit code. The
6+
cross-agent input lever, and it fixes the exit-code-through-a-pipe problem that
7+
`occam compact` can't. Full passthrough on a non-zero exit, same as the hook.
8+
- `occam init codex` hard-wires the input side: a `.codex/hooks.json` `PreToolUse`
9+
hook rewrites noisy commands to `occam run` (Codex is the one non-Claude host
10+
whose hook can rewrite a command). The merge is idempotent and never clobbers an
11+
existing config. Other agents get the same effect from a rule instruction.
12+
- `detectFilter` now recognizes `npm` / `yarn` / `pnpm` / `bun test` (and `npm run
13+
test`), not just runner names, so `npm test` output is sieved as a test run.
14+
315
## 0.3.0
416

517
- `occam init <agent>`: one line to turn the right-sizing rule on for any agent

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ commands through `occam run`, so the input side compacts too:
6363
occam run npm test
6464
```
6565

66+
On Codex, `occam init codex` goes further and wires a `PreToolUse` hook that does
67+
the wrapping for you. Trust it once in Codex with `/hooks`.
68+
6669
## How it works
6770

6871
**Output, an always-on rule.** One short paragraph, injected every turn, that makes
@@ -76,9 +79,10 @@ generates every agent's format, so the rule can't drift. `scripts/generate.mjs
7679
model reads it. Strip ANSI, drop the `PASS`/`` noise, dedupe repeats, truncate
7780
while keeping head and tail. It knows a test run from an npm install, and on a
7881
non-zero exit it hands back the full output untouched. On Claude Code this runs on
79-
every command automatically (the plugin's `PostToolUse` hook). Other agents can't
80-
rewrite tool output from a hook, so `occam init` tells them to wrap noisy commands
81-
as `occam run <cmd>`, same engine, same trim.
82+
every command automatically (the plugin's `PostToolUse` hook). Codex gets a
83+
`PreToolUse` hook from `occam init codex` that rewrites noisy commands to `occam
84+
run`. Other hosts can't rewrite output or commands from a hook, so the installed
85+
rule tells the agent to use `occam run <cmd>` itself. Same engine, same trim.
8286

8387
After install you also get `/occam:review` to shrink what you just wrote, plus
8488
`/occam:audit`, `/occam:debt` and `occam gain` for your running total.

engine/cli.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ if (cmd === "run") {
2929
process.exit(run(rest, { ledger: !opt.noLedger }));
3030
}
3131

32+
if (cmd === "codex-hook") {
33+
// Codex PreToolUse: rewrite noisy commands to `occam run <cmd>` (wired by init).
34+
const { codexHook } = await import("./codex.mjs");
35+
let stdin = "";
36+
try { stdin = readFileSync(0, "utf8"); } catch {}
37+
const out = codexHook(stdin);
38+
if (out) process.stdout.write(out);
39+
process.exit(0);
40+
}
41+
3242
if (cmd === "gain") {
3343
// IN only, and it's a real measurement: actual bytes the compactor removed from
3444
// tool output before the model saw it. There's no honest OUT number to self-

engine/codex.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Codex PreToolUse hook. Codex (unlike Cursor) lets a PreToolUse hook rewrite the
2+
// command before it runs, so occam wraps noisy commands in `occam run` and the
3+
// model reads the compacted output. Same idea as rtk's command rewrite, done the
4+
// way Codex actually supports.
5+
//
6+
// Input: the PreToolUse JSON on stdin ({ tool_name, tool_input: { command } }).
7+
// Output: nothing (leave the command as-is) or the rewrite envelope Codex wants:
8+
// { hookSpecificOutput: { hookEventName, permissionDecision: "allow",
9+
// updatedInput: { command } } }
10+
11+
import { detectFilter } from "./transforms.mjs";
12+
13+
// Single-quote for /bin/sh so the whole original command survives as one argument
14+
// to `occam run`, compound commands and all.
15+
const shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
16+
17+
export function codexHook(stdin) {
18+
let input;
19+
try { input = JSON.parse(stdin); } catch { return ""; }
20+
const cmd = input?.tool_input?.command;
21+
const tool = input?.tool_name;
22+
if (!cmd || (tool && tool !== "Bash" && tool !== "shell")) return "";
23+
// Don't touch a command that already goes through occam (no double-wrap).
24+
if (/\boccam\s+(run|compact)\b/.test(cmd)) return "";
25+
// Only wrap what occam has a real filter for (tests, installs), the safe subset.
26+
if (!detectFilter(cmd)) return "";
27+
return JSON.stringify({
28+
hookSpecificOutput: {
29+
hookEventName: "PreToolUse",
30+
permissionDecision: "allow",
31+
updatedInput: { command: `occam run ${shellQuote(cmd)}` },
32+
},
33+
});
34+
}

engine/init.mjs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
// `occam init <agent>` — one line to turn the right-sizing rule on for any agent.
22
// Writes that agent's rule file into the current project, from the same source
3-
// (RULES.md) the committed copies come from. The IN-side compaction engine is a
4-
// Claude Code hook (the plugin wires it); other agents get the rule here and run
5-
// noisy commands through `occam compact` for the input side.
3+
// (RULES.md) the committed copies come from.
4+
//
5+
// Input compaction:
6+
// - Claude Code: the plugin's PostToolUse hook does it (output-rewriting hooks
7+
// only work there).
8+
// - Codex: a PreToolUse hook can rewrite the command, so we wire `.codex/hooks.json`
9+
// to wrap noisy commands in `occam run` (hard, no reliance on the agent).
10+
// - Everyone else: their hooks can't rewrite output or command, so the rule tells
11+
// the agent to run noisy commands through `occam run` itself.
612

7-
import { writeFileSync, mkdirSync } from "node:fs";
13+
import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
814
import { dirname, join } from "node:path";
915
import { fileURLToPath } from "node:url";
1016
import { alwaysOnRule, buildTargets } from "../scripts/generate.mjs";
@@ -22,10 +28,8 @@ export function init(agent) {
2228
}
2329

2430
const isClaude = a === "claude" || a === "claude-code";
31+
const isCodex = a === "codex" || a === "openai";
2532

26-
// On Claude Code the plugin's PostToolUse hook compacts output automatically.
27-
// Other agents can't rewrite tool output from a hook, so the rule tells the
28-
// agent to run noisy commands through `occam run` instead (works on any agent).
2933
const body = isClaude
3034
? target.body
3135
: target.body +
@@ -36,9 +40,36 @@ export function init(agent) {
3640
writeFileSync(dest, body);
3741

3842
process.stdout.write(`occam: right-sizing rule on for ${a} → wrote ${target.path}\n`);
39-
process.stdout.write(
40-
isClaude
41-
? `input compaction is automatic via the plugin: /plugin marketplace add borghei/occam\n`
42-
: `input side included: the rule now runs noisy commands through \`occam run\`. keep occam on PATH (npm i -g github:borghei/occam).\n`,
43-
);
43+
if (isClaude) {
44+
process.stdout.write(`input compaction is automatic via the plugin: /plugin marketplace add borghei/occam\n`);
45+
} else if (isCodex) {
46+
wireCodexHook();
47+
} else {
48+
process.stdout.write(`input side included: the rule runs noisy commands through \`occam run\`. keep occam on PATH (npm i -g github:borghei/occam).\n`);
49+
}
50+
}
51+
52+
// Wire (or merge into) .codex/hooks.json a PreToolUse hook that rewrites noisy
53+
// commands to `occam run`. Never clobbers an unparseable file.
54+
function wireCodexHook() {
55+
const file = join(process.cwd(), ".codex", "hooks.json");
56+
const entry = { matcher: "^Bash$", hooks: [{ type: "command", command: "occam codex-hook" }] };
57+
58+
let cfg = { hooks: { PreToolUse: [] } };
59+
if (existsSync(file)) {
60+
try { cfg = JSON.parse(readFileSync(file, "utf8")); } catch { cfg = null; }
61+
}
62+
if (!cfg || typeof cfg !== "object") {
63+
process.stdout.write(`couldn't parse .codex/hooks.json, add this PreToolUse hook by hand:\n ${JSON.stringify(entry)}\n`);
64+
return;
65+
}
66+
67+
cfg.hooks = cfg.hooks || {};
68+
cfg.hooks.PreToolUse = cfg.hooks.PreToolUse || [];
69+
if (!JSON.stringify(cfg.hooks.PreToolUse).includes("occam codex-hook")) {
70+
cfg.hooks.PreToolUse.push(entry);
71+
}
72+
mkdirSync(dirname(file), { recursive: true });
73+
writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
74+
process.stdout.write(`input side: wired Codex PreToolUse hook → .codex/hooks.json. trust it in Codex with /hooks, and keep occam on PATH.\n`);
4475
}

engine/run.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export function run(argv, { ledger = true } = {}) {
1616
return 2;
1717
}
1818
// Merge stderr into stdout so the model reads them in order, like a terminal.
19-
const res = spawnSync("/bin/sh", ["-c", `${command} 2>&1`], {
19+
// Subshell so the redirect and exit code cover a compound command as a whole.
20+
const res = spawnSync("/bin/sh", ["-c", `( ${command} ) 2>&1`], {
2021
encoding: "utf8",
2122
maxBuffer: 64 * 1024 * 1024,
2223
});

engine/test/codex.test.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { codexHook } from "../codex.mjs";
4+
5+
const pre = (command, tool_name = "Bash") =>
6+
JSON.stringify({ tool_name, tool_input: { command } });
7+
8+
test("codex-hook wraps a noisy test command in occam run", () => {
9+
const out = JSON.parse(codexHook(pre("npm test")));
10+
assert.equal(out.hookSpecificOutput.hookEventName, "PreToolUse");
11+
assert.equal(out.hookSpecificOutput.permissionDecision, "allow");
12+
assert.equal(out.hookSpecificOutput.updatedInput.command, "occam run 'npm test'");
13+
});
14+
15+
test("codex-hook wraps an install command too", () => {
16+
const out = JSON.parse(codexHook(pre("pnpm install")));
17+
assert.equal(out.hookSpecificOutput.updatedInput.command, "occam run 'pnpm install'");
18+
});
19+
20+
test("codex-hook single-quotes a compound command as one argument", () => {
21+
const out = JSON.parse(codexHook(pre("npm test && echo done")));
22+
assert.equal(out.hookSpecificOutput.updatedInput.command, "occam run 'npm test && echo done'");
23+
});
24+
25+
test("codex-hook leaves an already-wrapped command alone (no double-wrap)", () => {
26+
assert.equal(codexHook(pre("occam run 'npm test'")), "");
27+
});
28+
29+
test("codex-hook ignores commands occam has no filter for", () => {
30+
assert.equal(codexHook(pre("ls -la")), "");
31+
assert.equal(codexHook(pre("git status")), "");
32+
});
33+
34+
test("codex-hook ignores non-Bash tools and bad input", () => {
35+
assert.equal(codexHook(pre("npm test", "Read")), "");
36+
assert.equal(codexHook("not json"), "");
37+
assert.equal(codexHook("{}"), "");
38+
});

engine/transforms.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const FILTERS = {
7575

7676
export function detectFilter(command = "") {
7777
const c = command.toLowerCase();
78-
if (/\b(jest|vitest|pytest|mocha|rspec|go test|cargo test)\b/.test(c)) return "test";
78+
if (/\b(jest|vitest|pytest|mocha|rspec)\b|\b(go|cargo) test\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/.test(c)) return "test";
7979
if (/\b(npm|pnpm|yarn|pip|pip3|bundle)\b.*\b(i|install|ci|add)\b/.test(c)) return "install";
8080
return null;
8181
}

0 commit comments

Comments
 (0)