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
|
-
- [ ]
|
|
420
|
-
- [
|
|
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` mode — request 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
|
|
|
@@ -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.
|
|
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);
|
package/dist/core/generator.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|