oh-my-harness 0.10.2 → 0.12.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 (120) hide show
  1. package/README.md +39 -34
  2. package/dist/bin/oh-my-harness.js +5 -0
  3. package/dist/catalog/blocks/branch-guard.js +6 -4
  4. package/dist/catalog/blocks/command-guard.js +3 -2
  5. package/dist/catalog/blocks/commit-test-gate.js +3 -2
  6. package/dist/catalog/blocks/commit-typecheck-gate.js +3 -2
  7. package/dist/catalog/blocks/config-audit.js +5 -14
  8. package/dist/catalog/blocks/lint-on-save.js +41 -7
  9. package/dist/catalog/blocks/lockfile-guard.js +27 -9
  10. package/dist/catalog/blocks/path-guard.js +59 -34
  11. package/dist/catalog/blocks/secret-file-guard.js +27 -9
  12. package/dist/catalog/blocks/sql-guard.js +3 -1
  13. package/dist/catalog/blocks/tdd-guard.js +11 -10
  14. package/dist/catalog/converter.js +2 -1
  15. package/dist/catalog/types.d.ts +22 -22
  16. package/dist/cli/command-checker.js +1 -1
  17. package/dist/cli/commands/doctor.d.ts +2 -0
  18. package/dist/cli/commands/doctor.js +40 -2
  19. package/dist/cli/commands/hook.js +7 -1
  20. package/dist/cli/commands/init.d.ts +3 -8
  21. package/dist/cli/commands/init.js +15 -131
  22. package/dist/cli/commands/sync.js +2 -0
  23. package/dist/cli/commands/test.js +1 -1
  24. package/dist/cli/commands/update.d.ts +18 -0
  25. package/dist/cli/commands/update.js +78 -0
  26. package/dist/cli/event-logger.d.ts +1 -0
  27. package/dist/cli/event-logger.js +4 -5
  28. package/dist/cli/harness-tester.js +5 -4
  29. package/dist/cli/index.js +18 -16
  30. package/dist/cli/installer-detect.d.ts +8 -0
  31. package/dist/cli/installer-detect.js +72 -0
  32. package/dist/cli/tui/init-flow.d.ts +0 -2
  33. package/dist/cli/tui/init-flow.js +69 -305
  34. package/dist/cli/version-checker.d.ts +5 -0
  35. package/dist/cli/version-checker.js +27 -0
  36. package/dist/cli/version-notifier.d.ts +7 -0
  37. package/dist/cli/version-notifier.js +26 -0
  38. package/dist/core/generator.d.ts +1 -1
  39. package/dist/core/generator.js +23 -9
  40. package/dist/core/harness-converter-v2.d.ts +4 -5
  41. package/dist/core/harness-converter-v2.js +52 -2
  42. package/dist/core/harness-defaults.d.ts +28 -0
  43. package/dist/core/harness-defaults.js +78 -0
  44. package/dist/core/harness-schema.d.ts +47 -32
  45. package/dist/core/harness-schema.js +11 -4
  46. package/dist/core/merged-config.d.ts +38 -0
  47. package/dist/core/merged-config.js +1 -0
  48. package/dist/generators/agents-md.d.ts +6 -0
  49. package/dist/generators/agents-md.js +5 -0
  50. package/dist/generators/claude-md.d.ts +1 -1
  51. package/dist/generators/claude-md.js +2 -35
  52. package/dist/generators/codex-config.d.ts +35 -0
  53. package/dist/generators/codex-config.js +109 -0
  54. package/dist/generators/hooks.d.ts +1 -1
  55. package/dist/generators/hooks.js +80 -30
  56. package/dist/generators/managed-md.d.ts +2 -0
  57. package/dist/generators/managed-md.js +23 -0
  58. package/dist/generators/settings.d.ts +1 -1
  59. package/dist/utils/fs-helpers.d.ts +3 -0
  60. package/dist/utils/fs-helpers.js +38 -0
  61. package/dist/utils/paths.d.ts +11 -0
  62. package/dist/utils/paths.js +11 -0
  63. package/dist/utils/state-migration.d.ts +5 -0
  64. package/dist/utils/state-migration.js +131 -0
  65. package/package.json +4 -1
  66. package/dist/cli/commands/add.d.ts +0 -5
  67. package/dist/cli/commands/add.js +0 -41
  68. package/dist/cli/commands/remove.d.ts +0 -5
  69. package/dist/cli/commands/remove.js +0 -89
  70. package/dist/core/config-merger.d.ts +0 -2
  71. package/dist/core/config-merger.js +0 -87
  72. package/dist/core/harness-converter.d.ts +0 -20
  73. package/dist/core/harness-converter.js +0 -91
  74. package/dist/core/preset-loader.d.ts +0 -3
  75. package/dist/core/preset-loader.js +0 -18
  76. package/dist/core/preset-registry.d.ts +0 -14
  77. package/dist/core/preset-registry.js +0 -39
  78. package/dist/core/preset-types.d.ts +0 -756
  79. package/dist/core/preset-types.js +0 -55
  80. package/presets/_base/preset.yaml +0 -103
  81. package/presets/actix/preset.yaml +0 -55
  82. package/presets/cargo/preset.yaml +0 -11
  83. package/presets/cpp/preset.yaml +0 -50
  84. package/presets/csharp/preset.yaml +0 -42
  85. package/presets/dart/preset.yaml +0 -43
  86. package/presets/django/preset.yaml +0 -72
  87. package/presets/elixir/preset.yaml +0 -45
  88. package/presets/express/preset.yaml +0 -61
  89. package/presets/fastapi/preset.yaml +0 -122
  90. package/presets/flask/preset.yaml +0 -65
  91. package/presets/flutter/preset.yaml +0 -64
  92. package/presets/gin/preset.yaml +0 -57
  93. package/presets/go/preset.yaml +0 -43
  94. package/presets/gradle/preset.yaml +0 -13
  95. package/presets/java/preset.yaml +0 -48
  96. package/presets/javascript/preset.yaml +0 -44
  97. package/presets/laravel/preset.yaml +0 -70
  98. package/presets/maven/preset.yaml +0 -13
  99. package/presets/nextjs/preset.yaml +0 -109
  100. package/presets/nextjs-fastapi/preset.yaml +0 -54
  101. package/presets/npm/preset.yaml +0 -12
  102. package/presets/phoenix/preset.yaml +0 -64
  103. package/presets/php/preset.yaml +0 -46
  104. package/presets/pip/preset.yaml +0 -13
  105. package/presets/pipenv/preset.yaml +0 -12
  106. package/presets/pnpm/preset.yaml +0 -12
  107. package/presets/python/preset.yaml +0 -48
  108. package/presets/rails/preset.yaml +0 -65
  109. package/presets/react/preset.yaml +0 -61
  110. package/presets/ruby/preset.yaml +0 -45
  111. package/presets/rust/preset.yaml +0 -42
  112. package/presets/scala/preset.yaml +0 -44
  113. package/presets/springboot/preset.yaml +0 -61
  114. package/presets/swift/preset.yaml +0 -44
  115. package/presets/terraform/preset.yaml +0 -65
  116. package/presets/typescript/preset.yaml +0 -46
  117. package/presets/uv/preset.yaml +0 -12
  118. package/presets/vue/preset.yaml +0 -62
  119. package/presets/yarn/preset.yaml +0 -12
  120. package/presets/zig/preset.yaml +0 -41
package/README.md CHANGED
@@ -53,10 +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"
59
+ omh init "Android Kotlin app with Hilt, JUnit, Gradle"
60
60
  omh catalog list
61
61
  omh test # Dry-run verify your harness
62
62
  omh stats # TUI analytics dashboard
@@ -69,22 +69,28 @@ omh stats # TUI analytics dashboard
69
69
  └── config.json # AI provider config (global, not per-project)
70
70
 
71
71
  your-project/
72
- ├── CLAUDE.md # TDD rules, coding standards
72
+ ├── CLAUDE.md # Claude Code instructions (TDD rules, standards)
73
+ ├── AGENTS.md # Codex CLI instructions (same managed sections)
73
74
  ├── harness.yaml # Your harness config (source of truth)
74
- └── .claude/
75
- ├── settings.json # Hook configs, permissions
76
- ├── hooks/
77
- │ ├── catalog-branch-guard.sh # Blocks commits on merged branches
78
- │ ├── catalog-tdd-guard.sh # Enforces test-first workflow
79
- │ ├── catalog-commit-test-gate.sh # Tests must pass before commit
80
- │ ├── catalog-path-guard.sh # Protects build outputs
81
- │ ├── catalog-command-guard.sh # Blocks dangerous commands
82
- ├── catalog-lint-on-save.sh # Auto-lint on save
83
- │ ├── catalog-auto-pr.sh # Auto-create PR after push
84
- └── .state/
85
- ├── events.jsonl # Hook event log (for analytics)
86
- └── edit-history.json # TDD guard state
87
- └── oh-my-harness.json # Active preset tracking
75
+ ├── .omh/ # Single source of truth — hooks + state
76
+ ├── hooks/
77
+ │ │ ├── catalog-branch-guard.sh # Blocks commits on merged branches
78
+ ├── catalog-tdd-guard.sh # Enforces test-first workflow
79
+ ├── catalog-commit-test-gate.sh # Tests must pass before commit
80
+ ├── catalog-path-guard.sh # Protects build outputs
81
+ ├── catalog-command-guard.sh # Blocks dangerous commands
82
+ ├── catalog-lint-on-save.sh # Auto-lint on save
83
+ │ └── catalog-auto-pr.sh # Auto-create PR after push
84
+ │ ├── state/ # gitignored log/runtime data
85
+ │ ├── events.jsonl # Unified hook event log (powers omh stats)
86
+ │ └── tdd-edits.json # TDD guard working state
87
+ └── manifest.json # Generated-files manifest
88
+ ├── .claude/
89
+ │ ├── settings.json # Claude permissions + hooks → .omh/hooks/*.sh
90
+ │ └── oh-my-harness.json # Harness init/sync state
91
+ └── .codex/
92
+ ├── config.toml # [features] codex_hooks = true, goals = true
93
+ └── hooks.json # Codex hooks → .omh/hooks/*.sh (same scripts)
88
94
  ```
89
95
 
90
96
  ---
@@ -95,7 +101,7 @@ your-project/
95
101
  ~/.omh/config.json ┌─────────────────────┐
96
102
  ┌────────────────┐ │ │
97
103
  │ • Claude CLI │──▶│ NL Processing │◀── "React + FastAPI
98
- │ • Claude API │ │ or --preset flag │ TDD enforced"
104
+ │ • Claude API │ │ (describe your │ TDD enforced"
99
105
  │ • OpenAI API │ │ │
100
106
  │ • Gemini API │ └────────┬────────────┘
101
107
  └────────────────┘ │
@@ -176,7 +182,7 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
176
182
  | 🎨 `format-on-save` | auto-fix | Auto-format on file save |
177
183
  | 🧪 `test-on-save` | auto-fix | Auto-run tests on file save |
178
184
  | 🔀 `auto-pr` | automation | Auto-create PR after push |
179
- | 🧪 `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS/Python) |
185
+ | 🧪 `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS/Python/Kotlin/Java) |
180
186
  | 🔒 `sql-guard` | security | Blocks dangerous SQL operations |
181
187
  | 🌳 `worktree-setup` | monorepo | Supports monorepo worktree patterns |
182
188
  | 🗜️ `compact-context` | maintenance | Re-injects context on session start |
@@ -218,7 +224,7 @@ hooks:
218
224
  ```bash
219
225
  # 🚀 Initialize
220
226
  omh init "your project description" # NL-powered (requires AI provider)
221
- omh init --preset nextjs fastapi # Preset-based (instant)
227
+ omh init # Interactive TUI (import existing harness.yaml)
222
228
 
223
229
  # 📋 Catalog
224
230
  omh catalog list # Browse all building blocks
@@ -229,9 +235,7 @@ omh hook add branch-guard # Add a hook
229
235
  omh hook remove auto-pr # Remove a hook
230
236
 
231
237
  # 🔄 Sync & manage
232
- omh sync # Regenerate from harness.yaml
233
- omh add nextjs # Add a preset
234
- omh remove fastapi # Remove a preset
238
+ omh sync # Regenerate all files from harness.yaml
235
239
 
236
240
  # 🩺 Verify & monitor
237
241
  omh doctor # Health check
@@ -307,7 +311,7 @@ Keyboard: `1/2/3` views, `↑/↓` scroll, `d` date filter, `r` reload, `q` quit
307
311
 
308
312
  ## 📊 Stateful Hook Logging
309
313
 
310
- Every hook invocation is recorded in `.claude/hooks/.state/events.jsonl`:
314
+ Every hook invocation is recorded in `.omh/state/events.jsonl`:
311
315
 
312
316
  ```jsonl
313
317
  {"ts":"2026-03-18T08:00:00Z","event":"PreToolUse","hook":"catalog-tdd-guard.sh","decision":"block","reason":"TDD — foo.test.* 테스트 파일을 먼저 수정하세요"}
@@ -331,7 +335,7 @@ oh-my-harness/
331
335
  │ │ ├── template-engine.ts # Handlebars rendering + applyDefaults
332
336
  │ │ └── converter.ts # HookEntry[] → rendered scripts
333
337
  │ ├── cli/
334
- │ │ ├── commands/ # init, add, remove, doctor, catalog, hook, sync, test
338
+ │ │ ├── commands/ # init, doctor, catalog, hook, sync, test
335
339
  │ │ ├── stats/ # TUI dashboard (ink/React)
336
340
  │ │ │ ├── App.tsx # App shell (tab bar, keyboard nav)
337
341
  │ │ │ ├── data.ts # Data aggregation layer
@@ -343,10 +347,10 @@ oh-my-harness/
343
347
  │ │ ├── provider-setup.ts # Provider configuration UI
344
348
  │ │ └── tool-checker.ts # Command executable checks
345
349
  │ ├── core/
346
- │ │ ├── harness-schema.ts # harness.yaml Zod schema
347
- │ │ ├── harness-converter.ts # enforcement→hooks + MergedConfig
348
- │ │ ├── generator.ts # Orchestrates all generators
349
- │ │ └── config-merger.ts # Multi-preset merge
350
+ │ │ ├── harness-schema.ts # harness.yaml Zod schema
351
+ │ │ ├── merged-config.ts # MergedConfig + HooksConfig interfaces
352
+ │ │ ├── harness-converter-v2.ts # harness.yaml MergedConfig (catalog pipeline)
353
+ │ │ └── generator.ts # Orchestrates all generators
350
354
  │ ├── generators/
351
355
  │ │ ├── claude-md.ts # CLAUDE.md with idempotent markers
352
356
  │ │ ├── hooks.ts # Hook scripts + event logger injection
@@ -369,8 +373,7 @@ oh-my-harness/
369
373
  │ │ └── gemini-api.ts
370
374
  │ ├── parse-intent.ts # LLM prompt integration
371
375
  │ └── prompt-templates.ts # NL prompt construction
372
- ├── presets/ # Built-in preset definitions
373
- └── tests/ # 873+ tests (unit + integration)
376
+ └── tests/ # 900+ tests (unit + integration)
374
377
  ```
375
378
 
376
379
  ---
@@ -396,10 +399,12 @@ oh-my-harness/
396
399
  - [x] Multi-provider AI support — Claude API, OpenAI, Gemini
397
400
  - [x] Interactive model selection per provider
398
401
  - [x] GitHub star prompt — first-time only
402
+ - [x] Codex emitter — `AGENTS.md` + `.codex/hooks.json` + `.codex/config.toml`
403
+ - [x] Unified `.omh/` layout — single source of truth for hooks & state across runtimes
399
404
  - [ ] Cursor (`.cursor/rules/`) emitter
400
- - [ ] Codex (`AGENTS.md`) emitter
401
- - [ ] GitHub Copilot emitter
402
- - [ ] Community preset registry
405
+ - [ ] PI (Process Isolation) emitter — sandboxed tool execution
406
+ - [ ] `ask` mode — request approval before executing risky tools
407
+ - [ ] Community harness.yaml registry — share and reuse configs
403
408
  - [ ] `omh modify "change X"` — NL config editing
404
409
 
405
410
  ---
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { createCli } from "../cli/index.js";
4
+ import { notifyIfUpdateAvailable } from "../cli/version-notifier.js";
5
+ const require = createRequire(import.meta.url);
6
+ const pkg = require("../../package.json");
7
+ notifyIfUpdateAvailable({ name: pkg.name, version: pkg.version });
3
8
  const cli = createCli();
4
9
  cli.parse(process.argv);
@@ -19,8 +19,9 @@ if echo "$COMMAND" | grep -qE "git commit|git push"; then
19
19
  [[ -z "$BRANCH" ]] && exit 0
20
20
  MAIN='{{mainBranch}}'
21
21
  if [[ "$BRANCH" == "$MAIN" ]] || [[ "$BRANCH" == "master" && "$MAIN" == "main" ]]; then
22
- _log_event "block" "oh-my-harness: direct commits to $BRANCH are blocked. Create a feature branch."
23
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: direct commits to $BRANCH are blocked. Create a feature branch.\\"}"
22
+ REASON="oh-my-harness: direct commits to $BRANCH are blocked. Create a feature branch."
23
+ _log_event "block" "$REASON"
24
+ _emit_decision "block" "$REASON"
24
25
  exit 0
25
26
  fi
26
27
  MERGED=0
@@ -37,8 +38,9 @@ if echo "$COMMAND" | grep -qE "git commit|git push"; then
37
38
  fi
38
39
  fi
39
40
  if [[ "$MERGED" -eq 1 ]]; then
40
- _log_event "block" "oh-my-harness: branch $BRANCH has already been merged to $MAIN. Create a new branch."
41
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: branch $BRANCH has already been merged to $MAIN. Create a new branch.\\"}"
41
+ REASON="oh-my-harness: branch $BRANCH has already been merged to $MAIN. Create a new branch."
42
+ _log_event "block" "$REASON"
43
+ _emit_decision "block" "$REASON"
42
44
  exit 0
43
45
  fi
44
46
  fi
@@ -25,8 +25,9 @@ NORMALIZED_CMD=$(printf '%s' "$COMMAND" | tr '[:space:]' ' ' | tr -s ' ')
25
25
  PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
26
  for PATTERN in "\${PATTERNS[@]}"; do
27
27
  if echo "$NORMALIZED_CMD" | grep -qF -- "$PATTERN"; then
28
- _log_event "block" "oh-my-harness: command matches blocked pattern: $PATTERN"
29
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: command matches blocked pattern: $PATTERN\\"}"
28
+ REASON="oh-my-harness: command matches blocked pattern: $PATTERN"
29
+ _log_event "block" "$REASON"
30
+ _emit_decision "block" "$REASON"
30
31
  exit 0
31
32
  fi
32
33
  done
@@ -17,8 +17,9 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
17
  if echo "$COMMAND" | grep -qE "git commit"; then
18
18
  echo "oh-my-harness: Running {{{testCommand}}} before commit..." >&2
19
19
  if ! {{{testCommand}}} >&2 2>&1; then
20
- _log_event "block" "oh-my-harness: pre-commit check failed"
21
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
20
+ REASON="oh-my-harness: pre-commit check failed"
21
+ _log_event "block" "$REASON"
22
+ _emit_decision "block" "$REASON"
22
23
  exit 0
23
24
  fi
24
25
  fi
@@ -17,8 +17,9 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
17
  if echo "$COMMAND" | grep -qE "git commit"; then
18
18
  echo "oh-my-harness: Running {{{typecheckCommand}}} before commit..." >&2
19
19
  if ! {{{typecheckCommand}}} >&2 2>&1; then
20
- _log_event "block" "oh-my-harness: pre-commit check failed"
21
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
20
+ REASON="oh-my-harness: pre-commit check failed"
21
+ _log_event "block" "$REASON"
22
+ _emit_decision "block" "$REASON"
22
23
  exit 0
23
24
  fi
24
25
  fi
@@ -1,29 +1,20 @@
1
1
  export const configAudit = {
2
2
  id: "config-audit",
3
3
  name: "Config Audit",
4
- description: "Logs configuration changes for audit trail",
4
+ description: "Logs configuration changes into the unified events.jsonl audit trail",
5
5
  category: "audit",
6
6
  event: "ConfigChange",
7
7
  matcher: "",
8
8
  canBlock: false,
9
- params: [
10
- {
11
- name: "logFile",
12
- type: "string",
13
- description: "Path to the audit log file",
14
- default: ".claude/hooks/.state/config-audit.log",
15
- required: false,
16
- },
17
- ],
9
+ params: [],
18
10
  tags: ["audit", "config", "logging"],
19
11
  template: `#!/bin/bash
20
12
  set -euo pipefail
21
13
  INPUT=$(cat)
22
- LOG_FILE="{{{logFile}}}"
23
- mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
24
14
  SOURCE=$(echo "$INPUT" | jq -r '.source // "unknown"' 2>/dev/null)
25
15
  FILE=$(echo "$INPUT" | jq -r '.file_path // "unknown"' 2>/dev/null)
26
- TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
27
- echo "{\\"ts\\":\\"$TS\\",\\"source\\":\\"$SOURCE\\",\\"file\\":\\"$FILE\\"}" >> "$LOG_FILE"
16
+ META=$(jq -nc --arg s "$SOURCE" --arg f "$FILE" '{source:$s,file:$f}' 2>/dev/null || true)
17
+ [ -z "$META" ] && META='{"source":"unknown","file":"unknown"}'
18
+ _log_event "allow" "" "$META"
28
19
  exit 0`,
29
20
  };
@@ -19,18 +19,52 @@ export const lintOnSave = {
19
19
  description: "Lint command to run (e.g. eslint --fix)",
20
20
  required: true,
21
21
  },
22
+ {
23
+ name: "scope",
24
+ type: "string",
25
+ description: "Lint scope: 'file' passes $FILE_PATH to command, 'module' runs command without file arg",
26
+ required: false,
27
+ default: "file",
28
+ },
22
29
  ],
23
30
  tags: ["lint", "auto-fix", "quality", "save"],
24
31
  template: `#!/bin/bash
25
32
  set -euo pipefail
26
33
  INPUT=$(cat)
27
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
28
- [[ -z "$FILE_PATH" ]] && exit 0
29
- PATTERN='{{{filePattern}}}'
30
- BASENAME=$(basename "$FILE_PATH")
31
- if [[ "$BASENAME" == $PATTERN ]]; then
32
- echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
33
- {{{command}}} "$FILE_PATH" >&2 2>&1 || 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')
47
+ fi
34
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
35
69
  exit 0`,
36
70
  };
@@ -19,16 +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
- _log_event "block" "oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead."
29
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead.\\"}"
30
- 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')
31
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
32
50
  done
33
51
  exit 0`,
34
52
  };
@@ -18,47 +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
- _log_event "block" "oh-my-harness: path normalization unavailable for non-canonical path"
27
- echo "{\\"decision\\": \\\"block\\\", \\\"reason\\\": \\\"oh-my-harness: path normalization unavailable for non-canonical path\\\"}"
28
- 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')
29
37
  fi
30
- else
31
- case "$FILE_PATH" in
32
- /*|../*|*/../*|*/..|./*|*/./*|*/.)
33
- _log_event "block" "oh-my-harness: path normalization unavailable for non-canonical path"
34
- echo "{\\"decision\\": \\\"block\\\", \\\"reason\\\": \\\"oh-my-harness: path normalization unavailable for non-canonical path\\\"}"
35
- exit 0
36
- ;;
37
- esac
38
- NORMALIZED="$FILE_PATH"
39
38
  fi
39
+ [[ \${#FILE_PATHS[@]} -eq 0 ]] && exit 0
40
+
40
41
  BLOCKED_PATHS=({{#each blockedPaths}}"{{{this}}}" {{/each}})
41
- for BLOCKED in "\${BLOCKED_PATHS[@]}"; do
42
- if [[ "$BLOCKED" == */ ]]; then
43
- if [[ "$NORMALIZED" == "$BLOCKED"* || "$NORMALIZED" == *"/$BLOCKED"* ]]; then
44
- _log_event "block" "oh-my-harness: file path matches blocked directory: $BLOCKED"
45
- echo "{\\"decision\\": \\\"block\\\", \\\"reason\\\": \\\"oh-my-harness: file path matches blocked directory: $BLOCKED\\\"}"
46
- exit 0
47
- fi
48
- elif [[ "$BLOCKED" == \\** ]]; then
49
- PATTERN="\${BLOCKED#\\*}"
50
- if [[ "$NORMALIZED" == *"$PATTERN" ]]; then
51
- _log_event "block" "oh-my-harness: file path matches blocked pattern: $BLOCKED"
52
- echo "{\\"decision\\": \\\"block\\\", \\\"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"
47
+ _log_event "block" "$REASON"
48
+ _emit_decision "block" "$REASON"
53
49
  exit 0
54
50
  fi
55
51
  else
56
- if [[ "$NORMALIZED" == "$BLOCKED" || "$NORMALIZED" == *"/$BLOCKED" ]]; then
57
- _log_event "block" "oh-my-harness: file path matches blocked path: $BLOCKED"
58
- echo "{\\"decision\\": \\\"block\\\", \\\"reason\\\": \\\"oh-my-harness: file path matches blocked path: $BLOCKED\\\"}"
59
- exit 0
60
- 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"
61
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
62
87
  done
63
88
  exit 0`,
64
89
  };
@@ -19,16 +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
- _log_event "block" "oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN"
29
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN\\"}"
30
- 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')
31
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
32
50
  done
33
51
  exit 0`,
34
52
  };
@@ -26,7 +26,9 @@ PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
26
  for PATTERN in "\${PATTERNS[@]}"; do
27
27
  PATTERN_LOWER=$(echo "$PATTERN" | tr '[:upper:]' '[:lower:]')
28
28
  if echo "$COMMAND_LOWER" | grep -qF -- "$PATTERN_LOWER"; then
29
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: SQL command matches blocked pattern: $PATTERN\\"}"
29
+ REASON="oh-my-harness: SQL command matches blocked pattern: $PATTERN"
30
+ _log_event "block" "$REASON"
31
+ _emit_decision "block" "$REASON"
30
32
  exit 0
31
33
  fi
32
34
  done
@@ -33,9 +33,9 @@ case "\$FILE_PATH" in
33
33
  *.json|*.yaml|*.yml|*.md|*.sh|*.css|*.html|*.svg|*.png|*.jpg) exit 0 ;;
34
34
  esac
35
35
 
36
- # edit-history 상태 파일
37
- STATE_DIR=".claude/hooks/.state"
38
- HISTORY_FILE="\$STATE_DIR/edit-history.json"
36
+ # edit-history 상태 파일 (logger wrapper가 _OMH_STATE_DIR 를 export)
37
+ STATE_DIR="\${_OMH_STATE_DIR:-.omh/state}"
38
+ HISTORY_FILE="\$STATE_DIR/tdd-edits.json"
39
39
  mkdir -p "\$STATE_DIR" 2>/dev/null || true
40
40
 
41
41
  TEST_RE='{{{testPattern}}}'
@@ -62,19 +62,20 @@ fi
62
62
 
63
63
  # 대응 테스트 파일 확인 — 확장자 제거
64
64
  BASENAME=$(basename "\$FILE_PATH" | sed -E 's/\\.[^.]+$//')
65
- TEST_SUFFIX=".test."
66
65
 
66
+ REASON="oh-my-harness: TDD — \${BASENAME} 에 대응하는 테스트 파일을 먼저 수정하세요"
67
67
  if [[ ! -f "\$HISTORY_FILE" ]]; then
68
- _log_event "block" "oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요"
69
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요\\"}"
68
+ _log_event "block" "\$REASON"
69
+ _emit_decision "block" "\$REASON"
70
70
  exit 0
71
71
  fi
72
72
 
73
73
  # edit-history에서 테스트 파일 검색 (tmp+mv로 원자적 쓰기, 크로스 플랫폼)
74
- if jq -e --arg b "\$BASENAME" '.edits[] | select(contains($b) and (contains(".test.") or contains(".spec.") or contains("test_")))' "\$HISTORY_FILE" >/dev/null 2>&1; then
74
+ # Supports JS/TS (.test. / .spec. / test_) and JVM (Test suffix, e.g. IoViewTest.kt) conventions
75
+ if jq -e --arg b "\$BASENAME" '.edits[] | select(contains($b) and (contains(".test.") or contains(".spec.") or contains("test_") or endswith("Test.kt") or endswith("Test.java")))' "\$HISTORY_FILE" >/dev/null 2>&1; then
75
76
  # 테스트 먼저 수정됨 → 매칭 테스트 기록 소비(제거) + 소스 기록 + 통과
76
77
  UPDATED=$(jq --arg b "\$BASENAME" --arg f "\$FILE_PATH" '
77
- .edits |= [.[] | select((contains($b) and (contains(".test.") or contains(".spec.") or contains("test_"))) | not)]
78
+ .edits |= [.[] | select((contains($b) and (contains(".test.") or contains(".spec.") or contains("test_") or endswith("Test.kt") or endswith("Test.java"))) | not)]
78
79
  | .edits += [$f] | .edits |= unique
79
80
  ' "\$HISTORY_FILE" 2>/dev/null) || true
80
81
  if [[ -n "\$UPDATED" ]]; then
@@ -89,8 +90,8 @@ if [[ "\$DECISION" == "allow" ]]; then
89
90
  exit 0
90
91
  fi
91
92
 
92
- _log_event "block" "oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요"
93
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요\\"}"
93
+ _log_event "block" "\$REASON"
94
+ _emit_decision "block" "\$REASON"
94
95
  exit 0`,
95
96
  tags: ["tdd", "workflow", "quality"],
96
97
  };
@@ -1,4 +1,5 @@
1
1
  import { renderTemplate, validateParams, applyDefaults } from "./template-engine.js";
2
+ import { OMH_HOOKS_DIR } from "../utils/paths.js";
2
3
  export async function convertHookEntries(entries, registry, _projectDir) {
3
4
  const hooksConfig = {};
4
5
  const scripts = new Map();
@@ -28,7 +29,7 @@ export async function convertHookEntries(entries, registry, _projectDir) {
28
29
  const count = blockInstanceCount.get(entry.block) ?? 0;
29
30
  blockInstanceCount.set(entry.block, count + 1);
30
31
  const scriptName = count === 0 ? `${entry.block}.sh` : `${entry.block}-${count}.sh`;
31
- const scriptPath = `.claude/hooks/${scriptName}`;
32
+ const scriptPath = `${OMH_HOOKS_DIR}/${scriptName}`;
32
33
  scripts.set(scriptPath, scriptContent);
33
34
  const hookEntry = {
34
35
  type: "command",