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 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] codex_hooks = true, goals = true
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
- - [ ] PI (Process Isolation) emitter — sandboxed tool execution
406
- - [ ] `ask` mode — request approval before executing risky tools
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
 
@@ -4,6 +4,7 @@ export interface HookConfigEntry {
4
4
  type: "command";
5
5
  command: string;
6
6
  matcher?: string;
7
+ mode?: "block" | "ask";
7
8
  }
8
9
  export interface ConvertResult {
9
10
  hooksConfig: Record<string, HookConfigEntry[]>;
@@ -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;
@@ -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
  }>;
@@ -21,4 +21,5 @@ export const BuildingBlockSchema = z.object({
21
21
  export const HookEntrySchema = z.object({
22
22
  block: z.string(),
23
23
  params: z.record(z.unknown()).default({}),
24
+ mode: z.enum(["block", "ask"]).default("block"),
24
25
  });
@@ -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?.codex_hooks !== true) {
72
- messages.push("FAIL: .codex/config.toml missing [features] codex_hooks = true.");
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;
@@ -5,6 +5,7 @@ export interface HookDefinition {
5
5
  script?: string;
6
6
  inline?: string;
7
7
  variables?: Record<string, unknown>;
8
+ mode?: "block" | "ask";
8
9
  }
9
10
  export interface ClaudeMdSection {
10
11
  id: string;
@@ -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 codex_hooks=true entry under [features] is required for Codex hooks.\n" +
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
- codex_hooks: true,
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;
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {