oh-my-harness 0.13.0 → 0.14.0

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/README.md CHANGED
@@ -416,9 +416,8 @@ oh-my-harness/
416
416
  - [x] GitHub star prompt — first-time only
417
417
  - [x] Codex emitter — `AGENTS.md` + `.codex/hooks.json` + `.codex/config.toml`
418
418
  - [x] Unified `.omh/` layout — single source of truth for hooks & state across runtimes
419
- - [ ] Cursor (`.cursor/rules/`) emitter
420
- - [ ] Pi ([pi.dev](https://pi.dev)) emitter generate harness config for the Pi coding agent
421
- - [x] `ask` mode — request approval before executing risky tools (Claude; Codex falls back to block)
419
+ - [x] Pi ([pi.dev](https://pi.dev)) emitter — bridge extension (`.pi/extensions/omh-harness.ts`) reusing the same `.omh/hooks/*.sh`
420
+ - [x] `ask` moderequest approval before executing risky tools (Claude native prompt / Pi `ctx.ui.select`; Codex falls back to block)
422
421
  - [ ] Community harness.yaml registry — share and reuse configs
423
422
  - [ ] `omh modify "change X"` — NL config editing
424
423
 
@@ -10,6 +10,7 @@ export interface DoctorResult {
10
10
  agentsMd: boolean;
11
11
  settingsJson: boolean;
12
12
  codexConfig: boolean;
13
+ piConfig: boolean;
13
14
  hooksExecutable: boolean;
14
15
  };
15
16
  messages: string[];
@@ -11,6 +11,7 @@ export async function doctorCommand(options = {}) {
11
11
  agentsMd: false,
12
12
  settingsJson: false,
13
13
  codexConfig: false,
14
+ piConfig: false,
14
15
  hooksExecutable: false,
15
16
  };
16
17
  // 1. Check .claude/oh-my-harness.json exists
@@ -88,7 +89,28 @@ export async function doctorCommand(options = {}) {
88
89
  messages.push("FAIL: .codex/hooks.json or .codex/config.toml is invalid or unreadable.");
89
90
  }
90
91
  }
91
- // 6. Check hook scripts exist and are executable
92
+ // 6. Pi bridge extension (.pi/extensions/omh-harness.ts). Only generated when
93
+ // PreToolUse hooks exist, so absence is acceptable; a present-but-corrupt file
94
+ // (hand-edit / merge conflict) is a failure.
95
+ const piExtPath = path.join(projectDir, ".pi", "extensions", "omh-harness.ts");
96
+ try {
97
+ const raw = await fs.readFile(piExtPath, "utf-8");
98
+ if (raw.includes("AUTO-GENERATED by oh-my-harness") && raw.includes('pi.on("tool_call"')) {
99
+ checks.piConfig = true;
100
+ }
101
+ else {
102
+ messages.push("FAIL: .pi/extensions/omh-harness.ts exists but is not a valid oh-my-harness bridge; run `omh sync`.");
103
+ }
104
+ }
105
+ catch (err) {
106
+ if (err.code === "ENOENT") {
107
+ checks.piConfig = true; // no Pi extension — acceptable
108
+ }
109
+ else {
110
+ messages.push("FAIL: .pi/extensions/omh-harness.ts is unreadable.");
111
+ }
112
+ }
113
+ // 7. Check hook scripts exist and are executable
92
114
  const hooksDir = path.join(projectDir, OMH_HOOKS_DIR);
93
115
  try {
94
116
  const files = await fs.readdir(hooksDir);
@@ -3,6 +3,7 @@ import { generateAgentsMd } from "../generators/agents-md.js";
3
3
  import { generateHooks } from "../generators/hooks.js";
4
4
  import { generateSettings } from "../generators/settings.js";
5
5
  import { generateCodexConfig } from "../generators/codex-config.js";
6
+ import { generatePiExtension } from "../generators/pi-extension.js";
6
7
  import { updateGitignore } from "../generators/gitignore.js";
7
8
  import { migrateLegacyState } from "../utils/state-migration.js";
8
9
  import { OMH_DIR } from "../utils/paths.js";
@@ -21,13 +22,14 @@ export async function generate(options) {
21
22
  // depend on hooksOutput, so this stage runs first.
22
23
  const hooksOutput = await generateHooks({ projectDir, config });
23
24
  files.push(...hooksOutput.generatedFiles);
24
- // Claude settings.json and Codex config write to disjoint files using the
25
- // same hooksOutput — independent.
26
- const [, codexFiles] = await Promise.all([
25
+ // Claude settings.json, Codex config, and the Pi bridge extension write to
26
+ // disjoint files using the same hooksOutput — independent.
27
+ const [, codexFiles, piFiles] = await Promise.all([
27
28
  generateSettings({ projectDir, config, hooksOutput }),
28
29
  generateCodexConfig({ projectDir, hooksOutput }),
30
+ generatePiExtension({ projectDir, hooksOutput }),
29
31
  ]);
30
- files.push(`${projectDir}/.claude/settings.json`, ...codexFiles);
32
+ files.push(`${projectDir}/.claude/settings.json`, ...codexFiles, ...piFiles);
31
33
  // .omh/state/ holds volatile log data; hooks/manifest are reproducible.
32
34
  await updateGitignore(projectDir, [`${OMH_DIR}/state/`]);
33
35
  files.push(`${projectDir}/.gitignore`);
@@ -0,0 +1,48 @@
1
+ import type { HooksOutput } from "./hooks.js";
2
+ /**
3
+ * Pi (pi.dev) emitter.
4
+ *
5
+ * Pi hooks are TypeScript extensions (`pi.on("tool_call", ...)`), not shell
6
+ * scripts. Rather than re-implement every catalog block in TypeScript, the
7
+ * emitter generates a thin *bridge* extension that shells out to the same
8
+ * `.omh/hooks/*.sh` scripts the Claude and Codex emitters use — keeping a
9
+ * single source of truth for block logic.
10
+ *
11
+ * The bridge always sends a Claude-style payload (a `transcript_path` field is
12
+ * present), so the runtime-detecting `_emit_decision` in each script emits
13
+ * `permissionDecision:"ask"` for ask-mode blocks and `decision:"block"` for
14
+ * block-mode blocks. The bridge maps those back onto Pi's native primitives:
15
+ * `ctx.ui.select` for ask, `{ block: true }` for block.
16
+ */
17
+ export interface PiBinding {
18
+ /** Pi tool names this binding intercepts (e.g. ["edit", "write"]). */
19
+ tools: string[];
20
+ /**
21
+ * The exact shell command line that runs the generated hook, as emitted into
22
+ * the Claude/Codex settings (e.g. `bash '/abs/.omh/hooks/foo.sh'`). The bridge
23
+ * runs it through a shell so the format stays identical across all emitters.
24
+ */
25
+ command: string;
26
+ }
27
+ /**
28
+ * Extract the PreToolUse bindings the Pi bridge can express. Pi's `tool_call`
29
+ * event fires before execution and can block, which maps onto PreToolUse.
30
+ * Other events (PostToolUse, SessionStart, ...) have no `tool_call` equivalent
31
+ * and are skipped for now.
32
+ */
33
+ export declare function extractPiBindings(hooksConfig: HooksOutput["hooksConfig"]): PiBinding[];
34
+ /**
35
+ * Render the bridge extension TypeScript source for the given bindings.
36
+ * Pure (no IO) so it can be unit-tested.
37
+ */
38
+ export declare function buildPiExtension(bindings: PiBinding[]): string;
39
+ export interface GeneratePiExtensionOptions {
40
+ projectDir: string;
41
+ hooksOutput: HooksOutput;
42
+ }
43
+ /**
44
+ * Write the Pi bridge extension to .pi/extensions/omh-harness.ts. Returns the
45
+ * list of generated file paths (empty when there are no PreToolUse bindings,
46
+ * in which case no file is written).
47
+ */
48
+ export declare function generatePiExtension(options: GeneratePiExtensionOptions): Promise<string[]>;
@@ -0,0 +1,177 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const PI_EXTENSION_DIR = ".pi/extensions";
4
+ const PI_EXTENSION_FILE = "omh-harness.ts";
5
+ // Claude/catalog matcher tool names -> Pi tool names. Pi exposes lowercase
6
+ // built-in tools: bash, read, edit, write, grep, find, ls.
7
+ const MATCHER_TO_PI_TOOL = {
8
+ bash: "bash",
9
+ edit: "edit",
10
+ write: "write",
11
+ read: "read",
12
+ grep: "grep",
13
+ find: "find",
14
+ ls: "ls",
15
+ };
16
+ /**
17
+ * Translate a catalog matcher (e.g. "Edit|Write") into the set of Pi tool
18
+ * names the bridge should intercept. Unknown tokens are dropped.
19
+ */
20
+ function matcherToPiTools(matcher) {
21
+ const tools = [];
22
+ for (const token of matcher.split("|")) {
23
+ const pi = MATCHER_TO_PI_TOOL[token.trim().toLowerCase()];
24
+ if (pi && !tools.includes(pi))
25
+ tools.push(pi);
26
+ }
27
+ return tools;
28
+ }
29
+ /**
30
+ * Extract the PreToolUse bindings the Pi bridge can express. Pi's `tool_call`
31
+ * event fires before execution and can block, which maps onto PreToolUse.
32
+ * Other events (PostToolUse, SessionStart, ...) have no `tool_call` equivalent
33
+ * and are skipped for now.
34
+ */
35
+ export function extractPiBindings(hooksConfig) {
36
+ const pre = hooksConfig["PreToolUse"] ?? [];
37
+ const bindings = [];
38
+ for (const entry of pre) {
39
+ const tools = matcherToPiTools(entry.matcher);
40
+ if (tools.length === 0)
41
+ continue;
42
+ for (const hook of entry.hooks) {
43
+ bindings.push({ tools, command: hook.command });
44
+ }
45
+ }
46
+ return bindings;
47
+ }
48
+ /**
49
+ * Render the bridge extension TypeScript source for the given bindings.
50
+ * Pure (no IO) so it can be unit-tested.
51
+ */
52
+ export function buildPiExtension(bindings) {
53
+ const bindingLiterals = bindings
54
+ .map((b) => ` { tools: ${JSON.stringify(b.tools)}, command: ${JSON.stringify(b.command)} },`)
55
+ .join("\n");
56
+ return `// AUTO-GENERATED by oh-my-harness. Do not edit by hand.
57
+ //
58
+ // Bridges oh-my-harness catalog hooks onto the Pi coding agent. Each binding
59
+ // shells out to a generated .omh/hooks/*.sh script (the single source of truth
60
+ // shared with the Claude and Codex emitters) and maps its decision onto Pi's
61
+ // native tool_call result.
62
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
63
+ import { spawnSync } from "node:child_process";
64
+ import { dirname, join } from "node:path";
65
+ import { fileURLToPath } from "node:url";
66
+
67
+ interface Binding {
68
+ tools: string[];
69
+ command: string;
70
+ }
71
+
72
+ const BINDINGS: Binding[] = [
73
+ ${bindingLiterals}
74
+ ];
75
+
76
+ // .pi/extensions/omh-harness.ts -> project root is three levels up.
77
+ const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
78
+
79
+ // Map a Pi tool_call onto the Claude-style payload the shell hooks expect.
80
+ // A transcript_path field marks the payload as Claude-like so ask-mode hooks
81
+ // emit permissionDecision:"ask" instead of a hard block.
82
+ function buildPayload(toolName: string, input: Record<string, unknown>): unknown {
83
+ const base = { transcript_path: "pi", hook_event_name: "PreToolUse" };
84
+ switch (toolName) {
85
+ case "bash":
86
+ return { ...base, tool_name: "Bash", tool_input: { command: input.command ?? "" } };
87
+ case "edit":
88
+ return { ...base, tool_name: "Edit", tool_input: { file_path: input.path ?? "" } };
89
+ case "write":
90
+ return { ...base, tool_name: "Write", tool_input: { file_path: input.path ?? "", content: input.content ?? "" } };
91
+ case "read":
92
+ return { ...base, tool_name: "Read", tool_input: { file_path: input.path ?? "" } };
93
+ default:
94
+ return { ...base, tool_name: toolName, tool_input: input };
95
+ }
96
+ }
97
+
98
+ export default function (pi: ExtensionAPI) {
99
+ pi.on("tool_call", async (event, ctx) => {
100
+ for (const binding of BINDINGS) {
101
+ if (!binding.tools.includes(event.toolName)) continue;
102
+
103
+ const payload = buildPayload(event.toolName, event.input as Record<string, unknown>);
104
+ // binding.command is a full shell command line (e.g. bash '/abs/foo.sh'),
105
+ // identical to what Claude/Codex settings invoke — run it through a shell.
106
+ const proc = spawnSync(binding.command, {
107
+ cwd: PROJECT_ROOT,
108
+ input: JSON.stringify(payload),
109
+ encoding: "utf-8",
110
+ timeout: 10000,
111
+ shell: true,
112
+ });
113
+
114
+ // The generated hook scripts always exit 0 in normal operation (allow or
115
+ // block is signalled via stdout JSON), so a spawn error (timeout/signal)
116
+ // or a non-zero exit means the guard itself malfunctioned. Fail closed:
117
+ // a guardrail must never be silently downgraded to allow by a failure.
118
+ if (proc.error || (typeof proc.status === "number" && proc.status !== 0)) {
119
+ return {
120
+ block: true,
121
+ reason: "oh-my-harness hook failed: " + (proc.error?.message ?? "exited with code " + proc.status),
122
+ };
123
+ }
124
+
125
+ const out = (proc.stdout ?? "").trim();
126
+ if (!out) continue;
127
+
128
+ let decision: any;
129
+ try {
130
+ decision = JSON.parse(out);
131
+ } catch {
132
+ continue; // non-JSON output is treated as a pass-through
133
+ }
134
+
135
+ const isAsk = decision?.hookSpecificOutput?.permissionDecision === "ask";
136
+ const isBlock = decision?.decision === "block" || decision?.hookSpecificOutput?.permissionDecision === "deny";
137
+ const reason: string =
138
+ decision?.hookSpecificOutput?.permissionDecisionReason ??
139
+ decision?.reason ??
140
+ "blocked by oh-my-harness";
141
+
142
+ if (isAsk) {
143
+ if (!ctx.hasUI) {
144
+ return { block: true, reason: reason + " (no UI to confirm)" };
145
+ }
146
+ const choice = await ctx.ui.select(\`oh-my-harness — approve this action?\\n\\n \${reason}\`, ["Yes", "No"]);
147
+ if (choice !== "Yes") {
148
+ return { block: true, reason: "declined by user" };
149
+ }
150
+ continue; // approved — keep checking remaining bindings
151
+ }
152
+
153
+ if (isBlock) {
154
+ return { block: true, reason };
155
+ }
156
+ }
157
+ return undefined;
158
+ });
159
+ }
160
+ `;
161
+ }
162
+ /**
163
+ * Write the Pi bridge extension to .pi/extensions/omh-harness.ts. Returns the
164
+ * list of generated file paths (empty when there are no PreToolUse bindings,
165
+ * in which case no file is written).
166
+ */
167
+ export async function generatePiExtension(options) {
168
+ const { projectDir, hooksOutput } = options;
169
+ const bindings = extractPiBindings(hooksOutput.hooksConfig);
170
+ if (bindings.length === 0)
171
+ return [];
172
+ const dir = join(projectDir, PI_EXTENSION_DIR);
173
+ await mkdir(dir, { recursive: true });
174
+ const filePath = join(dir, PI_EXTENSION_FILE);
175
+ await writeFile(filePath, buildPiExtension(bindings), "utf-8");
176
+ return [filePath];
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {