oh-my-harness 0.11.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.
Files changed (91) hide show
  1. package/README.md +32 -20
  2. package/dist/catalog/blocks/lint-on-save.js +33 -12
  3. package/dist/catalog/blocks/lockfile-guard.js +27 -10
  4. package/dist/catalog/blocks/path-guard.js +57 -37
  5. package/dist/catalog/blocks/secret-file-guard.js +27 -10
  6. package/dist/catalog/converter.d.ts +1 -0
  7. package/dist/catalog/converter.js +9 -0
  8. package/dist/catalog/types.d.ts +27 -23
  9. package/dist/catalog/types.js +1 -0
  10. package/dist/cli/commands/doctor.js +7 -2
  11. package/dist/cli/commands/hook.js +7 -1
  12. package/dist/cli/commands/init.d.ts +3 -8
  13. package/dist/cli/commands/init.js +15 -131
  14. package/dist/cli/commands/sync.js +2 -0
  15. package/dist/cli/commands/test.js +1 -1
  16. package/dist/cli/index.js +6 -16
  17. package/dist/cli/tui/init-flow.d.ts +0 -2
  18. package/dist/cli/tui/init-flow.js +69 -305
  19. package/dist/core/generator.d.ts +1 -1
  20. package/dist/core/harness-converter-v2.d.ts +4 -5
  21. package/dist/core/harness-converter-v2.js +52 -1
  22. package/dist/core/harness-defaults.d.ts +28 -0
  23. package/dist/core/harness-defaults.js +78 -0
  24. package/dist/core/harness-schema.d.ts +53 -33
  25. package/dist/core/harness-schema.js +11 -4
  26. package/dist/core/merged-config.d.ts +39 -0
  27. package/dist/core/merged-config.js +1 -0
  28. package/dist/generators/agents-md.d.ts +1 -1
  29. package/dist/generators/claude-md.d.ts +1 -1
  30. package/dist/generators/codex-config.js +18 -2
  31. package/dist/generators/hooks.d.ts +2 -2
  32. package/dist/generators/hooks.js +21 -4
  33. package/dist/generators/managed-md.d.ts +1 -1
  34. package/dist/generators/settings.d.ts +1 -1
  35. package/package.json +1 -1
  36. package/dist/cli/commands/add.d.ts +0 -5
  37. package/dist/cli/commands/add.js +0 -41
  38. package/dist/cli/commands/remove.d.ts +0 -5
  39. package/dist/cli/commands/remove.js +0 -89
  40. package/dist/core/config-merger.d.ts +0 -2
  41. package/dist/core/config-merger.js +0 -87
  42. package/dist/core/harness-converter.d.ts +0 -20
  43. package/dist/core/harness-converter.js +0 -91
  44. package/dist/core/preset-loader.d.ts +0 -3
  45. package/dist/core/preset-loader.js +0 -18
  46. package/dist/core/preset-registry.d.ts +0 -14
  47. package/dist/core/preset-registry.js +0 -39
  48. package/dist/core/preset-types.d.ts +0 -756
  49. package/dist/core/preset-types.js +0 -55
  50. package/presets/_base/preset.yaml +0 -103
  51. package/presets/actix/preset.yaml +0 -55
  52. package/presets/android/preset.yaml +0 -150
  53. package/presets/cargo/preset.yaml +0 -11
  54. package/presets/cpp/preset.yaml +0 -50
  55. package/presets/csharp/preset.yaml +0 -42
  56. package/presets/dart/preset.yaml +0 -43
  57. package/presets/django/preset.yaml +0 -72
  58. package/presets/elixir/preset.yaml +0 -45
  59. package/presets/express/preset.yaml +0 -61
  60. package/presets/fastapi/preset.yaml +0 -122
  61. package/presets/flask/preset.yaml +0 -65
  62. package/presets/flutter/preset.yaml +0 -64
  63. package/presets/gin/preset.yaml +0 -57
  64. package/presets/go/preset.yaml +0 -43
  65. package/presets/gradle/preset.yaml +0 -13
  66. package/presets/java/preset.yaml +0 -48
  67. package/presets/javascript/preset.yaml +0 -44
  68. package/presets/laravel/preset.yaml +0 -70
  69. package/presets/maven/preset.yaml +0 -13
  70. package/presets/nextjs/preset.yaml +0 -109
  71. package/presets/nextjs-fastapi/preset.yaml +0 -54
  72. package/presets/npm/preset.yaml +0 -12
  73. package/presets/phoenix/preset.yaml +0 -64
  74. package/presets/php/preset.yaml +0 -46
  75. package/presets/pip/preset.yaml +0 -13
  76. package/presets/pipenv/preset.yaml +0 -12
  77. package/presets/pnpm/preset.yaml +0 -12
  78. package/presets/python/preset.yaml +0 -48
  79. package/presets/rails/preset.yaml +0 -65
  80. package/presets/react/preset.yaml +0 -61
  81. package/presets/ruby/preset.yaml +0 -45
  82. package/presets/rust/preset.yaml +0 -42
  83. package/presets/scala/preset.yaml +0 -44
  84. package/presets/springboot/preset.yaml +0 -61
  85. package/presets/swift/preset.yaml +0 -44
  86. package/presets/terraform/preset.yaml +0 -65
  87. package/presets/typescript/preset.yaml +0 -46
  88. package/presets/uv/preset.yaml +0 -12
  89. package/presets/vue/preset.yaml +0 -62
  90. package/presets/yarn/preset.yaml +0 -12
  91. package/presets/zig/preset.yaml +0 -41
package/README.md CHANGED
@@ -53,11 +53,10 @@ npx oh-my-harness init "TypeScript Next.js frontend with Python FastAPI backend"
53
53
 
54
54
  # Or install globally
55
55
  npm install -g oh-my-harness
56
- oh-my-harness init --preset nextjs fastapi
56
+ oh-my-harness init "React app with TDD"
57
57
 
58
58
  # Short alias works too
59
- omh init "React app with TDD"
60
- omh init --preset android # Android/Kotlin with Hilt, JUnit, Gradle
59
+ omh init "Android Kotlin app with Hilt, JUnit, Gradle"
61
60
  omh catalog list
62
61
  omh test # Dry-run verify your harness
63
62
  omh stats # TUI analytics dashboard
@@ -88,9 +87,9 @@ your-project/
88
87
  │ └── manifest.json # Generated-files manifest
89
88
  ├── .claude/
90
89
  │ ├── settings.json # Claude permissions + hooks → .omh/hooks/*.sh
91
- │ └── oh-my-harness.json # Active preset tracking
90
+ │ └── oh-my-harness.json # Harness init/sync state
92
91
  └── .codex/
93
- ├── config.toml # [features] codex_hooks = true
92
+ ├── config.toml # [features] hooks = true, goals = true
94
93
  └── hooks.json # Codex hooks → .omh/hooks/*.sh (same scripts)
95
94
  ```
96
95
 
@@ -102,7 +101,7 @@ your-project/
102
101
  ~/.omh/config.json ┌─────────────────────┐
103
102
  ┌────────────────┐ │ │
104
103
  │ • Claude CLI │──▶│ NL Processing │◀── "React + FastAPI
105
- │ • Claude API │ │ or --preset flag │ TDD enforced"
104
+ │ • Claude API │ │ (describe your │ TDD enforced"
106
105
  │ • OpenAI API │ │ │
107
106
  │ • Gemini API │ └────────┬────────────┘
108
107
  └────────────────┘ │
@@ -196,6 +195,7 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
196
195
  hooks:
197
196
  - block: branch-guard
198
197
  - block: tdd-guard
198
+ mode: ask # ask for approval instead of hard-blocking (Claude)
199
199
  - block: commit-test-gate
200
200
  params:
201
201
  testCommand: "npx vitest run"
@@ -218,6 +218,20 @@ hooks:
218
218
  baseBranch: main
219
219
  ```
220
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
+
221
235
  ---
222
236
 
223
237
  ## 🖥️ Commands
@@ -225,7 +239,7 @@ hooks:
225
239
  ```bash
226
240
  # 🚀 Initialize
227
241
  omh init "your project description" # NL-powered (requires AI provider)
228
- omh init --preset nextjs fastapi # Preset-based (instant)
242
+ omh init # Interactive TUI (import existing harness.yaml)
229
243
 
230
244
  # 📋 Catalog
231
245
  omh catalog list # Browse all building blocks
@@ -236,9 +250,7 @@ omh hook add branch-guard # Add a hook
236
250
  omh hook remove auto-pr # Remove a hook
237
251
 
238
252
  # 🔄 Sync & manage
239
- omh sync # Regenerate from harness.yaml
240
- omh add nextjs # Add a preset
241
- omh remove fastapi # Remove a preset
253
+ omh sync # Regenerate all files from harness.yaml
242
254
 
243
255
  # 🩺 Verify & monitor
244
256
  omh doctor # Health check
@@ -314,7 +326,7 @@ Keyboard: `1/2/3` views, `↑/↓` scroll, `d` date filter, `r` reload, `q` quit
314
326
 
315
327
  ## 📊 Stateful Hook Logging
316
328
 
317
- Every hook invocation is recorded in `.claude/hooks/.state/events.jsonl`:
329
+ Every hook invocation is recorded in `.omh/state/events.jsonl`:
318
330
 
319
331
  ```jsonl
320
332
  {"ts":"2026-03-18T08:00:00Z","event":"PreToolUse","hook":"catalog-tdd-guard.sh","decision":"block","reason":"TDD — foo.test.* 테스트 파일을 먼저 수정하세요"}
@@ -338,7 +350,7 @@ oh-my-harness/
338
350
  │ │ ├── template-engine.ts # Handlebars rendering + applyDefaults
339
351
  │ │ └── converter.ts # HookEntry[] → rendered scripts
340
352
  │ ├── cli/
341
- │ │ ├── commands/ # init, add, remove, doctor, catalog, hook, sync, test
353
+ │ │ ├── commands/ # init, doctor, catalog, hook, sync, test
342
354
  │ │ ├── stats/ # TUI dashboard (ink/React)
343
355
  │ │ │ ├── App.tsx # App shell (tab bar, keyboard nav)
344
356
  │ │ │ ├── data.ts # Data aggregation layer
@@ -350,10 +362,10 @@ oh-my-harness/
350
362
  │ │ ├── provider-setup.ts # Provider configuration UI
351
363
  │ │ └── tool-checker.ts # Command executable checks
352
364
  │ ├── core/
353
- │ │ ├── harness-schema.ts # harness.yaml Zod schema
354
- │ │ ├── harness-converter.ts # enforcement→hooks + MergedConfig
355
- │ │ ├── generator.ts # Orchestrates all generators
356
- │ │ └── config-merger.ts # Multi-preset merge
365
+ │ │ ├── harness-schema.ts # harness.yaml Zod schema
366
+ │ │ ├── merged-config.ts # MergedConfig + HooksConfig interfaces
367
+ │ │ ├── harness-converter-v2.ts # harness.yaml MergedConfig (catalog pipeline)
368
+ │ │ └── generator.ts # Orchestrates all generators
357
369
  │ ├── generators/
358
370
  │ │ ├── claude-md.ts # CLAUDE.md with idempotent markers
359
371
  │ │ ├── hooks.ts # Hook scripts + event logger injection
@@ -376,8 +388,7 @@ oh-my-harness/
376
388
  │ │ └── gemini-api.ts
377
389
  │ ├── parse-intent.ts # LLM prompt integration
378
390
  │ └── prompt-templates.ts # NL prompt construction
379
- ├── presets/ # Built-in preset definitions
380
- └── tests/ # 873+ tests (unit + integration)
391
+ └── tests/ # 900+ tests (unit + integration)
381
392
  ```
382
393
 
383
394
  ---
@@ -406,8 +417,9 @@ oh-my-harness/
406
417
  - [x] Codex emitter — `AGENTS.md` + `.codex/hooks.json` + `.codex/config.toml`
407
418
  - [x] Unified `.omh/` layout — single source of truth for hooks & state across runtimes
408
419
  - [ ] Cursor (`.cursor/rules/`) emitter
409
- - [ ] GitHub Copilot emitter
410
- - [ ] Community preset registry
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)
422
+ - [ ] Community harness.yaml registry — share and reuse configs
411
423
  - [ ] `omh modify "change X"` — NL config editing
412
424
 
413
425
  ---
@@ -31,19 +31,40 @@ export const lintOnSave = {
31
31
  template: `#!/bin/bash
32
32
  set -euo pipefail
33
33
  INPUT=$(cat)
34
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
35
- [[ -z "$FILE_PATH" ]] && exit 0
36
- PATTERN='{{{filePattern}}}'
37
- BASENAME=$(basename "$FILE_PATH")
38
- if [[ "$BASENAME" == $PATTERN ]]; then
39
- SCOPE='{{{scope}}}'
40
- if [[ "\${SCOPE:-file}" == "module" ]]; then
41
- echo "oh-my-harness: Running {{{command}}} ..." >&2
42
- {{{command}}} >&2 || true
43
- else
44
- echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
45
- {{{command}}} "$FILE_PATH" >&2 || true
34
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
35
+ FILE_PATHS=()
36
+ DIRECT_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
37
+ [[ -n "$DIRECT_PATH" ]] && FILE_PATHS+=("$DIRECT_PATH")
38
+ if [[ "$TOOL_NAME" == "apply_patch" ]]; then
39
+ PATCH_TEXT=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
40
+ if [[ -n "$PATCH_TEXT" ]]; then
41
+ while IFS= read -r _OMH_HEADER_PATH; do
42
+ # CRLF patches leave a trailing \\r since sed's $ matches before \\n
43
+ # only; strip it so filename pattern matching isn't bypassed.
44
+ _OMH_HEADER_PATH="\${_OMH_HEADER_PATH%$'\\r'}"
45
+ [[ -n "$_OMH_HEADER_PATH" ]] && FILE_PATHS+=("$_OMH_HEADER_PATH")
46
+ done < <(printf '%s\\n' "$PATCH_TEXT" | sed -nE 's/^\\*\\*\\* (Add|Update) File: (.+)$/\\2/p')
46
47
  fi
47
48
  fi
49
+ [[ \${#FILE_PATHS[@]} -eq 0 ]] && exit 0
50
+
51
+ PATTERN='{{{filePattern}}}'
52
+ SCOPE='{{{scope}}}'
53
+ _OMH_RAN_MODULE=0
54
+ for FILE_PATH in "\${FILE_PATHS[@]}"; do
55
+ BASENAME=$(basename "$FILE_PATH")
56
+ if [[ "$BASENAME" == $PATTERN ]]; then
57
+ if [[ "\${SCOPE:-file}" == "module" ]]; then
58
+ if [[ "$_OMH_RAN_MODULE" -eq 0 ]]; then
59
+ echo "oh-my-harness: Running {{{command}}} ..." >&2
60
+ {{{command}}} >&2 || true
61
+ _OMH_RAN_MODULE=1
62
+ fi
63
+ else
64
+ echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
65
+ {{{command}}} "$FILE_PATH" >&2 || true
66
+ fi
67
+ fi
68
+ done
48
69
  exit 0`,
49
70
  };
@@ -19,17 +19,34 @@ export const lockfileGuard = {
19
19
  template: `#!/bin/bash
20
20
  set -euo pipefail
21
21
  INPUT=$(cat)
22
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
23
- [[ -z "$FILE_PATH" ]] && exit 0
24
- BASENAME=$(basename "$FILE_PATH")
25
- LOCKFILES=({{#each lockfiles}}"{{{this}}}" {{/each}})
26
- for LOCKFILE in "\${LOCKFILES[@]}"; do
27
- if [[ "$BASENAME" == "$LOCKFILE" ]]; then
28
- REASON="oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead."
29
- _log_event "block" "$REASON"
30
- _emit_decision "block" "$REASON"
31
- exit 0
22
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
23
+ FILE_PATHS=()
24
+ DIRECT_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
25
+ [[ -n "$DIRECT_PATH" ]] && FILE_PATHS+=("$DIRECT_PATH")
26
+ if [[ "$TOOL_NAME" == "apply_patch" ]]; then
27
+ PATCH_TEXT=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
28
+ if [[ -n "$PATCH_TEXT" ]]; then
29
+ while IFS= read -r _OMH_HEADER_PATH; do
30
+ # CRLF patches leave a trailing \\r since sed's $ matches before \\n
31
+ # only; strip it so basename/path comparisons aren't bypassed.
32
+ _OMH_HEADER_PATH="\${_OMH_HEADER_PATH%$'\\r'}"
33
+ [[ -n "$_OMH_HEADER_PATH" ]] && FILE_PATHS+=("$_OMH_HEADER_PATH")
34
+ done < <(printf '%s\\n' "$PATCH_TEXT" | sed -nE 's/^\\*\\*\\* (Add|Update|Delete) File: (.+)$/\\2/p')
32
35
  fi
36
+ fi
37
+ [[ \${#FILE_PATHS[@]} -eq 0 ]] && exit 0
38
+
39
+ LOCKFILES=({{#each lockfiles}}"{{{this}}}" {{/each}})
40
+ for FILE_PATH in "\${FILE_PATHS[@]}"; do
41
+ BASENAME=$(basename "$FILE_PATH")
42
+ for LOCKFILE in "\${LOCKFILES[@]}"; do
43
+ if [[ "$BASENAME" == "$LOCKFILE" ]]; then
44
+ REASON="oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead."
45
+ _log_event "block" "$REASON"
46
+ _emit_decision "block" "$REASON"
47
+ exit 0
48
+ fi
49
+ done
33
50
  done
34
51
  exit 0`,
35
52
  };
@@ -18,52 +18,72 @@ export const pathGuard = {
18
18
  template: `#!/bin/bash
19
19
  set -euo pipefail
20
20
  INPUT=$(cat)
21
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
22
- [[ -z "$FILE_PATH" ]] && exit 0
23
- # Normalize path to prevent directory traversal attacks (e.g., ./foo/../dist/secret.js -> dist/secret.js)
24
- if command -v python3 >/dev/null 2>&1; then
25
- if ! NORMALIZED=$(python3 -c "import os,sys; print(os.path.normpath(sys.argv[1]))" "$FILE_PATH" 2>/dev/null); then
26
- REASON="oh-my-harness: path normalization unavailable for non-canonical path"
27
- _log_event "block" "$REASON"
28
- _emit_decision "block" "$REASON"
29
- exit 0
21
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
22
+ # Collect file paths. Claude Edit/Write expose tool_input.file_path directly;
23
+ # Codex apply_patch ships the patch text in tool_input.command and encodes
24
+ # paths in "*** {Add|Update|Delete} File: <path>" headers.
25
+ FILE_PATHS=()
26
+ DIRECT_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
27
+ [[ -n "$DIRECT_PATH" ]] && FILE_PATHS+=("$DIRECT_PATH")
28
+ if [[ "$TOOL_NAME" == "apply_patch" ]]; then
29
+ PATCH_TEXT=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
30
+ if [[ -n "$PATCH_TEXT" ]]; then
31
+ while IFS= read -r _OMH_HEADER_PATH; do
32
+ # CRLF patches leave a trailing \\r since sed's $ matches before \\n
33
+ # only; strip it so basename/path comparisons aren't bypassed.
34
+ _OMH_HEADER_PATH="\${_OMH_HEADER_PATH%$'\\r'}"
35
+ [[ -n "$_OMH_HEADER_PATH" ]] && FILE_PATHS+=("$_OMH_HEADER_PATH")
36
+ done < <(printf '%s\\n' "$PATCH_TEXT" | sed -nE 's/^\\*\\*\\* (Add|Update|Delete) File: (.+)$/\\2/p')
30
37
  fi
31
- else
32
- case "$FILE_PATH" in
33
- /*|../*|*/../*|*/..|./*|*/./*|*/.)
34
- REASON="oh-my-harness: path normalization unavailable for non-canonical path"
35
- _log_event "block" "$REASON"
36
- _emit_decision "block" "$REASON"
37
- exit 0
38
- ;;
39
- esac
40
- NORMALIZED="$FILE_PATH"
41
38
  fi
39
+ [[ \${#FILE_PATHS[@]} -eq 0 ]] && exit 0
40
+
42
41
  BLOCKED_PATHS=({{#each blockedPaths}}"{{{this}}}" {{/each}})
43
- for BLOCKED in "\${BLOCKED_PATHS[@]}"; do
44
- if [[ "$BLOCKED" == */ ]]; then
45
- if [[ "$NORMALIZED" == "$BLOCKED"* || "$NORMALIZED" == *"/$BLOCKED"* ]]; then
46
- REASON="oh-my-harness: file path matches blocked directory: $BLOCKED"
47
- _log_event "block" "$REASON"
48
- _emit_decision "block" "$REASON"
49
- exit 0
50
- fi
51
- elif [[ "$BLOCKED" == \\** ]]; then
52
- PATTERN="\${BLOCKED#\\*}"
53
- if [[ "$NORMALIZED" == *"$PATTERN" ]]; then
54
- REASON="oh-my-harness: file path matches blocked pattern: $BLOCKED"
42
+ for FILE_PATH in "\${FILE_PATHS[@]}"; do
43
+ # Normalize each path to prevent directory traversal attacks (e.g., ./foo/../dist/secret.js -> dist/secret.js)
44
+ if command -v python3 >/dev/null 2>&1; then
45
+ if ! NORMALIZED=$(python3 -c "import os,sys; print(os.path.normpath(sys.argv[1]))" "$FILE_PATH" 2>/dev/null); then
46
+ REASON="oh-my-harness: path normalization unavailable for non-canonical path"
55
47
  _log_event "block" "$REASON"
56
48
  _emit_decision "block" "$REASON"
57
49
  exit 0
58
50
  fi
59
51
  else
60
- if [[ "$NORMALIZED" == "$BLOCKED" || "$NORMALIZED" == *"/$BLOCKED" ]]; then
61
- REASON="oh-my-harness: file path matches blocked path: $BLOCKED"
62
- _log_event "block" "$REASON"
63
- _emit_decision "block" "$REASON"
64
- exit 0
65
- fi
52
+ case "$FILE_PATH" in
53
+ /*|../*|*/../*|*/..|./*|*/./*|*/.)
54
+ REASON="oh-my-harness: path normalization unavailable for non-canonical path"
55
+ _log_event "block" "$REASON"
56
+ _emit_decision "block" "$REASON"
57
+ exit 0
58
+ ;;
59
+ esac
60
+ NORMALIZED="$FILE_PATH"
66
61
  fi
62
+ for BLOCKED in "\${BLOCKED_PATHS[@]}"; do
63
+ if [[ "$BLOCKED" == */ ]]; then
64
+ if [[ "$NORMALIZED" == "$BLOCKED"* || "$NORMALIZED" == *"/$BLOCKED"* ]]; then
65
+ REASON="oh-my-harness: file path matches blocked directory: $BLOCKED"
66
+ _log_event "block" "$REASON"
67
+ _emit_decision "block" "$REASON"
68
+ exit 0
69
+ fi
70
+ elif [[ "$BLOCKED" == \\** ]]; then
71
+ PATTERN="\${BLOCKED#\\*}"
72
+ if [[ "$NORMALIZED" == *"$PATTERN" ]]; then
73
+ REASON="oh-my-harness: file path matches blocked pattern: $BLOCKED"
74
+ _log_event "block" "$REASON"
75
+ _emit_decision "block" "$REASON"
76
+ exit 0
77
+ fi
78
+ else
79
+ if [[ "$NORMALIZED" == "$BLOCKED" || "$NORMALIZED" == *"/$BLOCKED" ]]; then
80
+ REASON="oh-my-harness: file path matches blocked path: $BLOCKED"
81
+ _log_event "block" "$REASON"
82
+ _emit_decision "block" "$REASON"
83
+ exit 0
84
+ fi
85
+ fi
86
+ done
67
87
  done
68
88
  exit 0`,
69
89
  };
@@ -19,17 +19,34 @@ export const secretFileGuard = {
19
19
  template: `#!/bin/bash
20
20
  set -euo pipefail
21
21
  INPUT=$(cat)
22
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
23
- [[ -z "$FILE_PATH" ]] && exit 0
24
- BASENAME=$(basename "$FILE_PATH")
25
- PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
- for PATTERN in "\${PATTERNS[@]}"; do
27
- if [[ "$BASENAME" == $PATTERN ]]; then
28
- REASON="oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN"
29
- _log_event "block" "$REASON"
30
- _emit_decision "block" "$REASON"
31
- exit 0
22
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
23
+ FILE_PATHS=()
24
+ DIRECT_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
25
+ [[ -n "$DIRECT_PATH" ]] && FILE_PATHS+=("$DIRECT_PATH")
26
+ if [[ "$TOOL_NAME" == "apply_patch" ]]; then
27
+ PATCH_TEXT=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
28
+ if [[ -n "$PATCH_TEXT" ]]; then
29
+ while IFS= read -r _OMH_HEADER_PATH; do
30
+ # CRLF patches leave a trailing \\r since sed's $ matches before \\n
31
+ # only; strip it so basename/path comparisons aren't bypassed.
32
+ _OMH_HEADER_PATH="\${_OMH_HEADER_PATH%$'\\r'}"
33
+ [[ -n "$_OMH_HEADER_PATH" ]] && FILE_PATHS+=("$_OMH_HEADER_PATH")
34
+ done < <(printf '%s\\n' "$PATCH_TEXT" | sed -nE 's/^\\*\\*\\* (Add|Update|Delete) File: (.+)$/\\2/p')
32
35
  fi
36
+ fi
37
+ [[ \${#FILE_PATHS[@]} -eq 0 ]] && exit 0
38
+
39
+ PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
40
+ for FILE_PATH in "\${FILE_PATHS[@]}"; do
41
+ BASENAME=$(basename "$FILE_PATH")
42
+ for PATTERN in "\${PATTERNS[@]}"; do
43
+ if [[ "$BASENAME" == $PATTERN ]]; then
44
+ REASON="oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN"
45
+ _log_event "block" "$REASON"
46
+ _emit_decision "block" "$REASON"
47
+ exit 0
48
+ fi
49
+ done
33
50
  done
34
51
  exit 0`,
35
52
  };
@@ -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;
@@ -31,15 +32,15 @@ export declare const ParamDefinitionSchema: z.ZodObject<{
31
32
  required: z.ZodBoolean;
32
33
  default: z.ZodOptional<z.ZodUnknown>;
33
34
  }, "strip", z.ZodTypeAny, {
34
- description: string;
35
- type: "string" | "number" | "boolean" | "string[]";
36
35
  name: string;
36
+ type: "string" | "number" | "boolean" | "string[]";
37
+ description: string;
37
38
  required: boolean;
38
39
  default?: unknown;
39
40
  }, {
40
- description: string;
41
- type: "string" | "number" | "boolean" | "string[]";
42
41
  name: string;
42
+ type: "string" | "number" | "boolean" | "string[]";
43
+ description: string;
43
44
  required: boolean;
44
45
  default?: unknown;
45
46
  }>;
@@ -58,15 +59,15 @@ export declare const BuildingBlockSchema: z.ZodObject<{
58
59
  required: z.ZodBoolean;
59
60
  default: z.ZodOptional<z.ZodUnknown>;
60
61
  }, "strip", z.ZodTypeAny, {
61
- description: string;
62
- type: "string" | "number" | "boolean" | "string[]";
63
62
  name: string;
63
+ type: "string" | "number" | "boolean" | "string[]";
64
+ description: string;
64
65
  required: boolean;
65
66
  default?: unknown;
66
67
  }, {
67
- description: string;
68
- type: "string" | "number" | "boolean" | "string[]";
69
68
  name: string;
69
+ type: "string" | "number" | "boolean" | "string[]";
70
+ description: string;
70
71
  required: boolean;
71
72
  default?: unknown;
72
73
  }>, "many">;
@@ -74,46 +75,49 @@ export declare const BuildingBlockSchema: z.ZodObject<{
74
75
  tags: z.ZodArray<z.ZodString, "many">;
75
76
  }, "strip", z.ZodTypeAny, {
76
77
  id: string;
77
- description: string;
78
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
79
+ name: string;
78
80
  params: {
79
- description: string;
80
- type: "string" | "number" | "boolean" | "string[]";
81
81
  name: string;
82
+ type: "string" | "number" | "boolean" | "string[]";
83
+ description: string;
82
84
  required: boolean;
83
85
  default?: unknown;
84
86
  }[];
87
+ description: string;
88
+ category: "notification" | "git" | "quality" | "security" | "formatting" | "custom" | "auto-fix" | "automation" | "file-protection" | "audit";
89
+ canBlock: boolean;
85
90
  template: string;
86
- name: string;
87
91
  tags: string[];
88
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
89
- category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
90
- canBlock: boolean;
91
92
  matcher?: string | undefined;
92
93
  }, {
93
94
  id: string;
94
- description: string;
95
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
96
+ name: string;
95
97
  params: {
96
- description: string;
97
- type: "string" | "number" | "boolean" | "string[]";
98
98
  name: string;
99
+ type: "string" | "number" | "boolean" | "string[]";
100
+ description: string;
99
101
  required: boolean;
100
102
  default?: unknown;
101
103
  }[];
104
+ description: string;
105
+ category: "notification" | "git" | "quality" | "security" | "formatting" | "custom" | "auto-fix" | "automation" | "file-protection" | "audit";
106
+ canBlock: boolean;
102
107
  template: string;
103
- name: string;
104
108
  tags: string[];
105
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
106
- category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
107
- canBlock: boolean;
108
109
  matcher?: string | undefined;
109
110
  }>;
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,13 @@ 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.");
75
+ }
76
+ else if (parsed.features?.goals !== true) {
77
+ messages.push("FAIL: .codex/config.toml missing [features] goals = true.");
73
78
  }
74
79
  else {
75
80
  checks.codexConfig = true;
@@ -5,6 +5,7 @@ import chalk from "chalk";
5
5
  import * as clack from "@clack/prompts";
6
6
  import { createDefaultRegistry } from "../../catalog/registry.js";
7
7
  import { syncCommand } from "./sync.js";
8
+ import { buildMinimalHarnessConfig } from "../../core/harness-defaults.js";
8
9
  async function readHarnessYaml(projectDir) {
9
10
  const harnessYamlPath = path.join(projectDir, "harness.yaml");
10
11
  try {
@@ -14,7 +15,12 @@ async function readHarnessYaml(projectDir) {
14
15
  catch (err) {
15
16
  const error = err;
16
17
  if (error.code === "ENOENT") {
17
- return {};
18
+ // Bootstrap a schema-conformant skeleton on first use so that
19
+ // `omh hook add` works before the user has run `omh init`.
20
+ // stacks are populated from the deterministic project detector so the
21
+ // generated yaml is informative rather than empty.
22
+ const minimal = await buildMinimalHarnessConfig(projectDir);
23
+ return minimal;
18
24
  }
19
25
  throw error;
20
26
  }
@@ -1,13 +1,9 @@
1
- import { PresetRegistry } from "../../core/preset-registry.js";
2
- import type { PresetConfig } from "../../core/preset-types.js";
3
1
  import type { ClaudeRunner } from "../../nl/parse-intent.js";
4
2
  export interface InitOptions {
5
3
  yes?: boolean;
6
4
  projectDir?: string;
7
- presetsDir?: string;
8
- preset?: string[];
9
5
  nlRunner?: ClaudeRunner;
10
- _nlDescription?: string;
6
+ description?: string;
11
7
  }
12
8
  export interface HarnessState {
13
9
  presets: string[];
@@ -15,7 +11,6 @@ export interface HarnessState {
15
11
  }
16
12
  export declare function readHarnessState(projectDir: string): Promise<HarnessState>;
17
13
  export declare function writeHarnessState(projectDir: string, state: HarnessState): Promise<void>;
18
- export declare function loadAndMergePresets(presetNames: string[], registry: PresetRegistry): Promise<PresetConfig[]>;
19
- export declare function initCommand(presetNames: string[], options?: InitOptions): Promise<void>;
20
- /** Headless init for CI/automation — bypasses TUI */
14
+ export declare function initCommand(descriptionParts: string[], options?: InitOptions): Promise<void>;
21
15
  export declare const initCommandHeadless: typeof initCommand;
16
+ export declare function initWithNL(projectDir: string, options: InitOptions): Promise<void>;