oh-my-harness 0.12.0 → 0.13.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 +18 -3
- package/dist/catalog/converter.d.ts +1 -0
- package/dist/catalog/converter.js +9 -0
- package/dist/catalog/types.d.ts +5 -1
- package/dist/catalog/types.js +1 -0
- package/dist/cli/commands/doctor.js +4 -2
- package/dist/core/harness-converter-v2.js +1 -0
- package/dist/core/harness-schema.d.ts +7 -2
- package/dist/core/merged-config.d.ts +1 -0
- package/dist/generators/codex-config.js +11 -2
- package/dist/generators/hooks.d.ts +1 -1
- package/dist/generators/hooks.js +21 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ your-project/
|
|
|
89
89
|
│ ├── settings.json # Claude permissions + hooks → .omh/hooks/*.sh
|
|
90
90
|
│ └── oh-my-harness.json # Harness init/sync state
|
|
91
91
|
└── .codex/
|
|
92
|
-
├── config.toml # [features]
|
|
92
|
+
├── config.toml # [features] hooks = true, goals = true
|
|
93
93
|
└── hooks.json # Codex hooks → .omh/hooks/*.sh (same scripts)
|
|
94
94
|
```
|
|
95
95
|
|
|
@@ -195,6 +195,7 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
|
|
|
195
195
|
hooks:
|
|
196
196
|
- block: branch-guard
|
|
197
197
|
- block: tdd-guard
|
|
198
|
+
mode: ask # ask for approval instead of hard-blocking (Claude)
|
|
198
199
|
- block: commit-test-gate
|
|
199
200
|
params:
|
|
200
201
|
testCommand: "npx vitest run"
|
|
@@ -217,6 +218,20 @@ hooks:
|
|
|
217
218
|
baseBranch: main
|
|
218
219
|
```
|
|
219
220
|
|
|
221
|
+
#### `mode`: block vs. ask
|
|
222
|
+
|
|
223
|
+
Any blocking hook accepts an optional `mode` (default `block`):
|
|
224
|
+
|
|
225
|
+
- **`block`** — hard-blocks the tool call. The agent cannot proceed.
|
|
226
|
+
- **`ask`** — escalates to the user for approval instead of blocking outright.
|
|
227
|
+
- **Claude Code**: shows a native permission prompt (`permissionDecision: "ask"`).
|
|
228
|
+
- **Codex**: `ask` is **not** supported, so the hook falls back to a hard
|
|
229
|
+
block — your guardrail is never silently downgraded to "allow". The same
|
|
230
|
+
generated script detects the calling runtime and responds accordingly.
|
|
231
|
+
|
|
232
|
+
`mode: ask` only applies to blocks that can block (`canBlock: true`); setting it
|
|
233
|
+
on a non-blocking block (e.g. `lint-on-save`) is reported and ignored.
|
|
234
|
+
|
|
220
235
|
---
|
|
221
236
|
|
|
222
237
|
## 🖥️ Commands
|
|
@@ -402,8 +417,8 @@ oh-my-harness/
|
|
|
402
417
|
- [x] Codex emitter — `AGENTS.md` + `.codex/hooks.json` + `.codex/config.toml`
|
|
403
418
|
- [x] Unified `.omh/` layout — single source of truth for hooks & state across runtimes
|
|
404
419
|
- [ ] Cursor (`.cursor/rules/`) emitter
|
|
405
|
-
- [ ]
|
|
406
|
-
- [
|
|
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)
|
|
407
422
|
- [ ] Community harness.yaml registry — share and reuse configs
|
|
408
423
|
- [ ] `omh modify "change X"` — NL config editing
|
|
409
424
|
|
|
@@ -31,9 +31,18 @@ export async function convertHookEntries(entries, registry, _projectDir) {
|
|
|
31
31
|
const scriptName = count === 0 ? `${entry.block}.sh` : `${entry.block}-${count}.sh`;
|
|
32
32
|
const scriptPath = `${OMH_HOOKS_DIR}/${scriptName}`;
|
|
33
33
|
scripts.set(scriptPath, scriptContent);
|
|
34
|
+
// Resolve ask/block mode. "ask" only makes sense for blocks that can
|
|
35
|
+
// block a tool call; for non-blocking blocks it is meaningless, so warn
|
|
36
|
+
// and fall back to "block" rather than emitting a no-op ask.
|
|
37
|
+
let mode = entry.mode ?? "block";
|
|
38
|
+
if (mode === "ask" && !block.canBlock) {
|
|
39
|
+
errors.push(`Block "${entry.block}" does not support ask mode (canBlock=false); falling back to block.`);
|
|
40
|
+
mode = "block";
|
|
41
|
+
}
|
|
34
42
|
const hookEntry = {
|
|
35
43
|
type: "command",
|
|
36
44
|
command: scriptPath,
|
|
45
|
+
mode,
|
|
37
46
|
};
|
|
38
47
|
if (block.matcher) {
|
|
39
48
|
hookEntry.matcher = block.matcher;
|
package/dist/catalog/types.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface BuildingBlock {
|
|
|
23
23
|
export interface HookEntry {
|
|
24
24
|
block: string;
|
|
25
25
|
params: Record<string, unknown>;
|
|
26
|
+
mode?: "block" | "ask";
|
|
26
27
|
}
|
|
27
28
|
export declare const ParamDefinitionSchema: z.ZodObject<{
|
|
28
29
|
name: z.ZodString;
|
|
@@ -110,10 +111,13 @@ export declare const BuildingBlockSchema: z.ZodObject<{
|
|
|
110
111
|
export declare const HookEntrySchema: z.ZodObject<{
|
|
111
112
|
block: z.ZodString;
|
|
112
113
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
114
|
+
mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
|
|
113
115
|
}, "strip", z.ZodTypeAny, {
|
|
114
|
-
params: Record<string, unknown>;
|
|
115
116
|
block: string;
|
|
117
|
+
params: Record<string, unknown>;
|
|
118
|
+
mode: "block" | "ask";
|
|
116
119
|
}, {
|
|
117
120
|
block: string;
|
|
118
121
|
params?: Record<string, unknown> | undefined;
|
|
122
|
+
mode?: "block" | "ask" | undefined;
|
|
119
123
|
}>;
|
package/dist/catalog/types.js
CHANGED
|
@@ -68,8 +68,10 @@ export async function doctorCommand(options = {}) {
|
|
|
68
68
|
JSON.parse(await fs.readFile(codexHooksPath, "utf-8"));
|
|
69
69
|
const tomlRaw = await fs.readFile(codexTomlPath, "utf-8");
|
|
70
70
|
const parsed = parse(tomlRaw);
|
|
71
|
-
if (parsed.features?.
|
|
72
|
-
messages.push(
|
|
71
|
+
if (parsed.features?.hooks !== true) {
|
|
72
|
+
messages.push(parsed.features?.codex_hooks === true
|
|
73
|
+
? "FAIL: .codex/config.toml uses deprecated [features] codex_hooks; run `omh sync` to migrate to hooks = true."
|
|
74
|
+
: "FAIL: .codex/config.toml missing [features] hooks = true.");
|
|
73
75
|
}
|
|
74
76
|
else if (parsed.features?.goals !== true) {
|
|
75
77
|
messages.push("FAIL: .codex/config.toml missing [features] goals = true.");
|
|
@@ -90,6 +90,7 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
|
|
|
90
90
|
matcher: entry.matcher ?? "",
|
|
91
91
|
description: `Catalog block: ${blockId}`,
|
|
92
92
|
inline: catalogResult.scripts.get(entry.command),
|
|
93
|
+
mode: entry.mode ?? "block",
|
|
93
94
|
};
|
|
94
95
|
additionalHooks[field].push(hookDef);
|
|
95
96
|
}
|
|
@@ -104,12 +104,15 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
104
104
|
hooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
105
105
|
block: z.ZodString;
|
|
106
106
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
107
|
+
mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
|
|
107
108
|
}, "strip", z.ZodTypeAny, {
|
|
108
|
-
params: Record<string, unknown>;
|
|
109
109
|
block: string;
|
|
110
|
+
params: Record<string, unknown>;
|
|
111
|
+
mode: "block" | "ask";
|
|
110
112
|
}, {
|
|
111
113
|
block: string;
|
|
112
114
|
params?: Record<string, unknown> | undefined;
|
|
115
|
+
mode?: "block" | "ask" | undefined;
|
|
113
116
|
}>, "many">>;
|
|
114
117
|
permissions: z.ZodDefault<z.ZodObject<{
|
|
115
118
|
allow: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
@@ -123,8 +126,9 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
123
126
|
}>>;
|
|
124
127
|
}, "strip", z.ZodTypeAny, {
|
|
125
128
|
hooks: {
|
|
126
|
-
params: Record<string, unknown>;
|
|
127
129
|
block: string;
|
|
130
|
+
params: Record<string, unknown>;
|
|
131
|
+
mode: "block" | "ask";
|
|
128
132
|
}[];
|
|
129
133
|
permissions: {
|
|
130
134
|
allow: string[];
|
|
@@ -167,6 +171,7 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
167
171
|
hooks?: {
|
|
168
172
|
block: string;
|
|
169
173
|
params?: Record<string, unknown> | undefined;
|
|
174
|
+
mode?: "block" | "ask" | undefined;
|
|
170
175
|
}[] | undefined;
|
|
171
176
|
permissions?: {
|
|
172
177
|
allow?: string[] | undefined;
|
|
@@ -10,14 +10,18 @@ const CODEX_SUPPORTED_EVENTS = new Set([
|
|
|
10
10
|
"Stop",
|
|
11
11
|
]);
|
|
12
12
|
const CODEX_CONFIG_HEADER = "# Managed by oh-my-harness.\n" +
|
|
13
|
-
"# The
|
|
13
|
+
"# The hooks=true entry under [features] is required for Codex hooks.\n" +
|
|
14
14
|
"# The goals=true entry under [features] enables Codex /goal.\n" +
|
|
15
15
|
"# Add your own tables (e.g. [mcp_servers.foo]) above or below freely.\n" +
|
|
16
16
|
"# https://github.com/kyu1204/oh-my-harness\n\n";
|
|
17
17
|
const REQUIRED_CODEX_FEATURES = {
|
|
18
|
-
|
|
18
|
+
hooks: true,
|
|
19
19
|
goals: true,
|
|
20
20
|
};
|
|
21
|
+
// Feature flags Codex has deprecated. We strip these on every sync so a
|
|
22
|
+
// previously-generated config.toml stops emitting Codex's deprecation warning
|
|
23
|
+
// (`[features].codex_hooks is deprecated. Use [features].hooks instead.`).
|
|
24
|
+
const DEPRECATED_CODEX_FEATURES = ["codex_hooks"];
|
|
21
25
|
function normalizeMatcher(matcher) {
|
|
22
26
|
if (!matcher)
|
|
23
27
|
return matcher;
|
|
@@ -78,6 +82,11 @@ export function buildCodexConfigToml(existing) {
|
|
|
78
82
|
// array — only treat it as an existing table when it actually is one.
|
|
79
83
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
80
84
|
const features = isPlainObject(data.features) ? data.features : {};
|
|
85
|
+
// Strip deprecated flags first so a config generated by an older
|
|
86
|
+
// oh-my-harness (which wrote codex_hooks) migrates cleanly to the new key.
|
|
87
|
+
for (const deprecated of DEPRECATED_CODEX_FEATURES) {
|
|
88
|
+
delete features[deprecated];
|
|
89
|
+
}
|
|
81
90
|
for (const [feature, enabled] of Object.entries(REQUIRED_CODEX_FEATURES)) {
|
|
82
91
|
features[feature] = enabled;
|
|
83
92
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MergedConfig } from "../core/merged-config.js";
|
|
2
|
-
export declare function wrapWithLogger(script: string, event?: string, projectDir?: string): string;
|
|
2
|
+
export declare function wrapWithLogger(script: string, event?: string, projectDir?: string, mode?: "block" | "ask"): string;
|
|
3
3
|
export interface GenerateHooksOptions {
|
|
4
4
|
projectDir: string;
|
|
5
5
|
config: MergedConfig;
|
package/dist/generators/hooks.js
CHANGED
|
@@ -7,7 +7,7 @@ import { OMH_HOOKS_DIR, OMH_STATE_DIR, OMH_MANIFEST, OMH_EVENTS_FILE } from "../
|
|
|
7
7
|
function shellSingleQuote(value) {
|
|
8
8
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
9
9
|
}
|
|
10
|
-
function buildLoggerSnippet(event, projectDir) {
|
|
10
|
+
function buildLoggerSnippet(event, projectDir, mode = "block") {
|
|
11
11
|
const stateDir = projectDir
|
|
12
12
|
? `${projectDir}/${OMH_STATE_DIR}`
|
|
13
13
|
: OMH_STATE_DIR;
|
|
@@ -16,6 +16,7 @@ _OMH_STATE_DIR=${shellSingleQuote(stateDir)}
|
|
|
16
16
|
mkdir -p "$_OMH_STATE_DIR" 2>/dev/null || true
|
|
17
17
|
_OMH_HOOK_NAME="$(basename "$0")"
|
|
18
18
|
_OMH_EVENT="${event}"
|
|
19
|
+
_OMH_DECISION_MODE="${mode}"
|
|
19
20
|
_OMH_LOGGED=0
|
|
20
21
|
_log_event() {
|
|
21
22
|
# Build the JSONL record entirely through jq so every string field is
|
|
@@ -52,16 +53,32 @@ _log_event() {
|
|
|
52
53
|
# via echo "{...}" — a file name or pattern containing a quote, backslash,
|
|
53
54
|
# or newline would otherwise produce invalid JSON that the runtime cannot
|
|
54
55
|
# parse as a block decision.
|
|
56
|
+
#
|
|
57
|
+
# In ask mode the same hook escalates to the user instead of hard-blocking,
|
|
58
|
+
# but only on runtimes that understand a permissionDecision:"ask" response.
|
|
59
|
+
# Claude's PreToolUse payload carries a transcript_path field; Codex's does
|
|
60
|
+
# not. A runtime we cannot positively identify as Claude falls through to a
|
|
61
|
+
# hard block, so a guardrail (e.g. TDD) is never silently downgraded to allow.
|
|
62
|
+
# The two requirements (Claude=ask, Codex=block) cannot coexist in one JSON —
|
|
63
|
+
# a legacy {decision:"block"} overrides permissionDecision:"ask" on Claude —
|
|
64
|
+
# so we branch on the caller instead of emitting a combined object.
|
|
55
65
|
_emit_decision() {
|
|
56
66
|
local decision="\${1:-block}" reason="\${2:-}"
|
|
67
|
+
if [ "\${_OMH_DECISION_MODE:-block}" = "ask" ] && [ "$decision" = "block" ]; then
|
|
68
|
+
if printf '%s' "\${INPUT:-}" | jq -e 'has("transcript_path")' >/dev/null 2>&1; then
|
|
69
|
+
jq -cn --arg reason "$reason" --arg event "$_OMH_EVENT" \\
|
|
70
|
+
'{hookSpecificOutput:{hookEventName:$event,permissionDecision:"ask",permissionDecisionReason:$reason}}'
|
|
71
|
+
return 0
|
|
72
|
+
fi
|
|
73
|
+
fi
|
|
57
74
|
jq -cn --arg decision "$decision" --arg reason "$reason" \\
|
|
58
75
|
'{decision:$decision,reason:$reason}'
|
|
59
76
|
}
|
|
60
77
|
trap '_OMH_EXIT_CODE=$?; if [ "$_OMH_LOGGED" -eq 0 ]; then if [ "$_OMH_EXIT_CODE" -ne 0 ]; then _log_event "error" "hook exited with code $_OMH_EXIT_CODE"; else _log_event "allow"; fi; fi' EXIT
|
|
61
78
|
# --- end logger ---`;
|
|
62
79
|
}
|
|
63
|
-
export function wrapWithLogger(script, event = "unknown", projectDir) {
|
|
64
|
-
const snippet = buildLoggerSnippet(event, projectDir);
|
|
80
|
+
export function wrapWithLogger(script, event = "unknown", projectDir, mode = "block") {
|
|
81
|
+
const snippet = buildLoggerSnippet(event, projectDir, mode);
|
|
65
82
|
if (script.includes("INPUT=$(cat)")) {
|
|
66
83
|
return script.replace("INPUT=$(cat)", `INPUT=$(cat)\n\n${snippet}`);
|
|
67
84
|
}
|
|
@@ -156,7 +173,7 @@ export async function generateHooks(options) {
|
|
|
156
173
|
event: hook.event,
|
|
157
174
|
matcher: hook.matcher,
|
|
158
175
|
scriptPath: join(hooksDir, scriptName),
|
|
159
|
-
wrappedScript: wrapWithLogger(hook.inline, hook.event, projectDir),
|
|
176
|
+
wrappedScript: wrapWithLogger(hook.inline, hook.event, projectDir, hook.mode ?? "block"),
|
|
160
177
|
});
|
|
161
178
|
}
|
|
162
179
|
// Independent IO across hooks — parallelize.
|