oh-my-harness 0.10.2 → 0.11.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 (48) hide show
  1. package/README.md +25 -17
  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 +15 -2
  9. package/dist/catalog/blocks/lockfile-guard.js +3 -2
  10. package/dist/catalog/blocks/path-guard.js +15 -10
  11. package/dist/catalog/blocks/secret-file-guard.js +3 -2
  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 +2 -2
  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 +37 -2
  19. package/dist/cli/commands/update.d.ts +18 -0
  20. package/dist/cli/commands/update.js +78 -0
  21. package/dist/cli/event-logger.d.ts +1 -0
  22. package/dist/cli/event-logger.js +4 -5
  23. package/dist/cli/harness-tester.js +5 -4
  24. package/dist/cli/index.js +12 -0
  25. package/dist/cli/installer-detect.d.ts +8 -0
  26. package/dist/cli/installer-detect.js +72 -0
  27. package/dist/cli/version-checker.d.ts +5 -0
  28. package/dist/cli/version-checker.js +27 -0
  29. package/dist/cli/version-notifier.d.ts +7 -0
  30. package/dist/cli/version-notifier.js +26 -0
  31. package/dist/core/generator.js +23 -9
  32. package/dist/core/harness-converter-v2.js +1 -1
  33. package/dist/generators/agents-md.d.ts +6 -0
  34. package/dist/generators/agents-md.js +5 -0
  35. package/dist/generators/claude-md.js +2 -35
  36. package/dist/generators/codex-config.d.ts +35 -0
  37. package/dist/generators/codex-config.js +102 -0
  38. package/dist/generators/hooks.js +80 -30
  39. package/dist/generators/managed-md.d.ts +2 -0
  40. package/dist/generators/managed-md.js +23 -0
  41. package/dist/utils/fs-helpers.d.ts +3 -0
  42. package/dist/utils/fs-helpers.js +38 -0
  43. package/dist/utils/paths.d.ts +11 -0
  44. package/dist/utils/paths.js +11 -0
  45. package/dist/utils/state-migration.d.ts +5 -0
  46. package/dist/utils/state-migration.js +131 -0
  47. package/package.json +4 -1
  48. package/presets/android/preset.yaml +150 -0
package/README.md CHANGED
@@ -57,6 +57,7 @@ oh-my-harness init --preset nextjs fastapi
57
57
 
58
58
  # Short alias works too
59
59
  omh init "React app with TDD"
60
+ omh init --preset android # Android/Kotlin with Hilt, JUnit, Gradle
60
61
  omh catalog list
61
62
  omh test # Dry-run verify your harness
62
63
  omh stats # TUI analytics dashboard
@@ -69,22 +70,28 @@ omh stats # TUI analytics dashboard
69
70
  └── config.json # AI provider config (global, not per-project)
70
71
 
71
72
  your-project/
72
- ├── CLAUDE.md # TDD rules, coding standards
73
+ ├── CLAUDE.md # Claude Code instructions (TDD rules, standards)
74
+ ├── AGENTS.md # Codex CLI instructions (same managed sections)
73
75
  ├── 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
76
+ ├── .omh/ # Single source of truth — hooks + state
77
+ ├── hooks/
78
+ │ │ ├── catalog-branch-guard.sh # Blocks commits on merged branches
79
+ ├── catalog-tdd-guard.sh # Enforces test-first workflow
80
+ ├── catalog-commit-test-gate.sh # Tests must pass before commit
81
+ ├── catalog-path-guard.sh # Protects build outputs
82
+ ├── catalog-command-guard.sh # Blocks dangerous commands
83
+ ├── catalog-lint-on-save.sh # Auto-lint on save
84
+ │ └── catalog-auto-pr.sh # Auto-create PR after push
85
+ │ ├── state/ # gitignored log/runtime data
86
+ │ ├── events.jsonl # Unified hook event log (powers omh stats)
87
+ │ └── tdd-edits.json # TDD guard working state
88
+ └── manifest.json # Generated-files manifest
89
+ ├── .claude/
90
+ │ ├── settings.json # Claude permissions + hooks → .omh/hooks/*.sh
91
+ │ └── oh-my-harness.json # Active preset tracking
92
+ └── .codex/
93
+ ├── config.toml # [features] codex_hooks = true
94
+ └── hooks.json # Codex hooks → .omh/hooks/*.sh (same scripts)
88
95
  ```
89
96
 
90
97
  ---
@@ -176,7 +183,7 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
176
183
  | 🎨 `format-on-save` | auto-fix | Auto-format on file save |
177
184
  | 🧪 `test-on-save` | auto-fix | Auto-run tests on file save |
178
185
  | 🔀 `auto-pr` | automation | Auto-create PR after push |
179
- | 🧪 `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS/Python) |
186
+ | 🧪 `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS/Python/Kotlin/Java) |
180
187
  | 🔒 `sql-guard` | security | Blocks dangerous SQL operations |
181
188
  | 🌳 `worktree-setup` | monorepo | Supports monorepo worktree patterns |
182
189
  | 🗜️ `compact-context` | maintenance | Re-injects context on session start |
@@ -396,8 +403,9 @@ oh-my-harness/
396
403
  - [x] Multi-provider AI support — Claude API, OpenAI, Gemini
397
404
  - [x] Interactive model selection per provider
398
405
  - [x] GitHub star prompt — first-time only
406
+ - [x] Codex emitter — `AGENTS.md` + `.codex/hooks.json` + `.codex/config.toml`
407
+ - [x] Unified `.omh/` layout — single source of truth for hooks & state across runtimes
399
408
  - [ ] Cursor (`.cursor/rules/`) emitter
400
- - [ ] Codex (`AGENTS.md`) emitter
401
409
  - [ ] GitHub Copilot emitter
402
410
  - [ ] Community preset registry
403
411
  - [ ] `omh modify "change X"` — NL config editing
@@ -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,6 +19,13 @@ 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
@@ -29,8 +36,14 @@ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path //
29
36
  PATTERN='{{{filePattern}}}'
30
37
  BASENAME=$(basename "$FILE_PATH")
31
38
  if [[ "$BASENAME" == $PATTERN ]]; then
32
- echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
33
- {{{command}}} "$FILE_PATH" >&2 2>&1 || true
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
46
+ fi
34
47
  fi
35
48
  exit 0`,
36
49
  };
@@ -25,8 +25,9 @@ BASENAME=$(basename "$FILE_PATH")
25
25
  LOCKFILES=({{#each lockfiles}}"{{{this}}}" {{/each}})
26
26
  for LOCKFILE in "\${LOCKFILES[@]}"; do
27
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.\\"}"
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"
30
31
  exit 0
31
32
  fi
32
33
  done
@@ -23,15 +23,17 @@ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path //
23
23
  # Normalize path to prevent directory traversal attacks (e.g., ./foo/../dist/secret.js -> dist/secret.js)
24
24
  if command -v python3 >/dev/null 2>&1; then
25
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\\\"}"
26
+ REASON="oh-my-harness: path normalization unavailable for non-canonical path"
27
+ _log_event "block" "$REASON"
28
+ _emit_decision "block" "$REASON"
28
29
  exit 0
29
30
  fi
30
31
  else
31
32
  case "$FILE_PATH" in
32
33
  /*|../*|*/../*|*/..|./*|*/./*|*/.)
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\\\"}"
34
+ REASON="oh-my-harness: path normalization unavailable for non-canonical path"
35
+ _log_event "block" "$REASON"
36
+ _emit_decision "block" "$REASON"
35
37
  exit 0
36
38
  ;;
37
39
  esac
@@ -41,21 +43,24 @@ BLOCKED_PATHS=({{#each blockedPaths}}"{{{this}}}" {{/each}})
41
43
  for BLOCKED in "\${BLOCKED_PATHS[@]}"; do
42
44
  if [[ "$BLOCKED" == */ ]]; then
43
45
  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
+ REASON="oh-my-harness: file path matches blocked directory: $BLOCKED"
47
+ _log_event "block" "$REASON"
48
+ _emit_decision "block" "$REASON"
46
49
  exit 0
47
50
  fi
48
51
  elif [[ "$BLOCKED" == \\** ]]; then
49
52
  PATTERN="\${BLOCKED#\\*}"
50
53
  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\\\"}"
54
+ REASON="oh-my-harness: file path matches blocked pattern: $BLOCKED"
55
+ _log_event "block" "$REASON"
56
+ _emit_decision "block" "$REASON"
53
57
  exit 0
54
58
  fi
55
59
  else
56
60
  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\\\"}"
61
+ REASON="oh-my-harness: file path matches blocked path: $BLOCKED"
62
+ _log_event "block" "$REASON"
63
+ _emit_decision "block" "$REASON"
59
64
  exit 0
60
65
  fi
61
66
  fi
@@ -25,8 +25,9 @@ BASENAME=$(basename "$FILE_PATH")
25
25
  PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
26
  for PATTERN in "\${PATTERNS[@]}"; do
27
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\\"}"
28
+ REASON="oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN"
29
+ _log_event "block" "$REASON"
30
+ _emit_decision "block" "$REASON"
30
31
  exit 0
31
32
  fi
32
33
  done
@@ -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",
@@ -85,7 +85,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
85
85
  template: string;
86
86
  name: string;
87
87
  tags: string[];
88
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "PreCompact" | "PostCompact" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "WorktreeRemove";
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
89
  category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
90
90
  canBlock: boolean;
91
91
  matcher?: string | undefined;
@@ -102,7 +102,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
102
102
  template: string;
103
103
  name: string;
104
104
  tags: string[];
105
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "PreCompact" | "PostCompact" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "WorktreeRemove";
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
106
  category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
107
107
  canBlock: boolean;
108
108
  matcher?: string | undefined;
@@ -61,7 +61,7 @@ export async function checkHarnessCommands(hooks, projectDir) {
61
61
  const fs = await import("node:fs/promises");
62
62
  const path = await import("node:path");
63
63
  for (const hook of hooks) {
64
- const scriptPath = hook.command.replace(/^bash\s+/, "").replace(/^"|"$/g, "");
64
+ const scriptPath = hook.command.replace(/^bash\s+/, "").replace(/^['"]|['"]$/g, "");
65
65
  const fullPath = path.join(projectDir, scriptPath);
66
66
  let content;
67
67
  try {
@@ -7,7 +7,9 @@ export interface DoctorResult {
7
7
  checks: {
8
8
  stateFile: boolean;
9
9
  claudeMd: boolean;
10
+ agentsMd: boolean;
10
11
  settingsJson: boolean;
12
+ codexConfig: boolean;
11
13
  hooksExecutable: boolean;
12
14
  };
13
15
  messages: string[];
@@ -1,12 +1,16 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { parse } from "smol-toml";
4
+ import { OMH_HOOKS_DIR } from "../../utils/paths.js";
3
5
  export async function doctorCommand(options = {}) {
4
6
  const projectDir = options.projectDir ?? process.cwd();
5
7
  const messages = [];
6
8
  const checks = {
7
9
  stateFile: false,
8
10
  claudeMd: false,
11
+ agentsMd: false,
9
12
  settingsJson: false,
13
+ codexConfig: false,
10
14
  hooksExecutable: false,
11
15
  };
12
16
  // 1. Check .claude/oh-my-harness.json exists
@@ -48,8 +52,39 @@ export async function doctorCommand(options = {}) {
48
52
  messages.push("FAIL: .claude/settings.json is not valid JSON.");
49
53
  }
50
54
  }
51
- // 4. Check hook scripts exist and are executable
52
- const hooksDir = path.join(projectDir, ".claude", "hooks");
55
+ // 4. AGENTS.md (Codex)
56
+ const agentsMdPath = path.join(projectDir, "AGENTS.md");
57
+ try {
58
+ await fs.access(agentsMdPath);
59
+ checks.agentsMd = true;
60
+ }
61
+ catch {
62
+ messages.push("FAIL: AGENTS.md not found.");
63
+ }
64
+ // 5. .codex/hooks.json + .codex/config.toml — both must exist AND parse.
65
+ const codexHooksPath = path.join(projectDir, ".codex", "hooks.json");
66
+ const codexTomlPath = path.join(projectDir, ".codex", "config.toml");
67
+ try {
68
+ JSON.parse(await fs.readFile(codexHooksPath, "utf-8"));
69
+ const tomlRaw = await fs.readFile(codexTomlPath, "utf-8");
70
+ const parsed = parse(tomlRaw);
71
+ if (parsed.features?.codex_hooks !== true) {
72
+ messages.push("FAIL: .codex/config.toml missing [features] codex_hooks = true.");
73
+ }
74
+ else {
75
+ checks.codexConfig = true;
76
+ }
77
+ }
78
+ catch (err) {
79
+ if (err.code === "ENOENT") {
80
+ messages.push("FAIL: .codex/hooks.json or .codex/config.toml not found.");
81
+ }
82
+ else {
83
+ messages.push("FAIL: .codex/hooks.json or .codex/config.toml is invalid or unreadable.");
84
+ }
85
+ }
86
+ // 6. Check hook scripts exist and are executable
87
+ const hooksDir = path.join(projectDir, OMH_HOOKS_DIR);
53
88
  try {
54
89
  const files = await fs.readdir(hooksDir);
55
90
  const scripts = files.filter((f) => f.endsWith(".sh"));
@@ -0,0 +1,18 @@
1
+ export interface UpdateOptions {
2
+ yes?: boolean;
3
+ dryRun?: boolean;
4
+ }
5
+ export interface UpdateDeps {
6
+ fetchLatest?: (name: string) => Promise<string | null>;
7
+ spawn?: (cmd: string, args: string[]) => {
8
+ status: number;
9
+ };
10
+ env?: NodeJS.ProcessEnv;
11
+ }
12
+ export interface UpdateResult {
13
+ exitCode: number;
14
+ ran: boolean;
15
+ command?: string;
16
+ latestVersion?: string;
17
+ }
18
+ export declare function updateCommand(currentVersion: string, options?: UpdateOptions, deps?: UpdateDeps): Promise<UpdateResult>;
@@ -0,0 +1,78 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { detectInstaller } from "../installer-detect.js";
3
+ import { fetchLatestVersion } from "../version-checker.js";
4
+ const PKG_NAME = "oh-my-harness";
5
+ function defaultSpawn(cmd, args) {
6
+ const result = spawnSync(cmd, args, { stdio: "inherit" });
7
+ return { status: result.status ?? 1 };
8
+ }
9
+ export async function updateCommand(currentVersion, options = {}, deps = {}) {
10
+ const fetchLatest = deps.fetchLatest ?? fetchLatestVersion;
11
+ const spawn = deps.spawn ?? defaultSpawn;
12
+ const env = deps.env ?? process.env;
13
+ console.log(`Current version: ${currentVersion}`);
14
+ console.log("Checking for updates…");
15
+ const latest = await fetchLatest(PKG_NAME);
16
+ if (!latest) {
17
+ console.log("Could not reach npm registry. Check your network and try again.");
18
+ return { exitCode: 1, ran: false };
19
+ }
20
+ if (latest === currentVersion) {
21
+ console.log(`Already up to date (${currentVersion}).`);
22
+ return { exitCode: 0, ran: false, latestVersion: latest };
23
+ }
24
+ console.log(`Update available: ${currentVersion} → ${latest}`);
25
+ const info = detectInstaller(env);
26
+ console.log(`Detected installer: ${info.installer}`);
27
+ if (info.notes) {
28
+ console.log(info.notes);
29
+ }
30
+ console.log(`Update command: ${info.updateCommand}`);
31
+ if (info.isEphemeral) {
32
+ console.log("Run the command above to install globally.");
33
+ return {
34
+ exitCode: 0,
35
+ ran: false,
36
+ command: info.updateCommand,
37
+ latestVersion: latest,
38
+ };
39
+ }
40
+ if (options.dryRun) {
41
+ return {
42
+ exitCode: 0,
43
+ ran: false,
44
+ command: info.updateCommand,
45
+ latestVersion: latest,
46
+ };
47
+ }
48
+ if (!options.yes && process.stdout.isTTY && process.stdin.isTTY) {
49
+ const { confirm } = await import("@inquirer/prompts");
50
+ const proceed = await confirm({
51
+ message: `Run "${info.updateCommand}"?`,
52
+ default: true,
53
+ });
54
+ if (!proceed) {
55
+ console.log("Cancelled.");
56
+ return {
57
+ exitCode: 0,
58
+ ran: false,
59
+ command: info.updateCommand,
60
+ latestVersion: latest,
61
+ };
62
+ }
63
+ }
64
+ const [cmd, ...args] = info.updateCommand.split(" ");
65
+ const { status } = spawn(cmd, args);
66
+ if (status === 0) {
67
+ console.log(`Updated to ${latest}.`);
68
+ }
69
+ else {
70
+ console.log(`Update failed with exit code ${status}. Try running the command manually.`);
71
+ }
72
+ return {
73
+ exitCode: status,
74
+ ran: true,
75
+ command: info.updateCommand,
76
+ latestVersion: latest,
77
+ };
78
+ }
@@ -5,6 +5,7 @@ export interface HookEvent {
5
5
  decision: "block" | "allow" | "error";
6
6
  reason?: string;
7
7
  tool?: string;
8
+ meta?: Record<string, unknown>;
8
9
  }
9
10
  export declare function appendEvent(projectDir: string, hookEvent: HookEvent): Promise<void>;
10
11
  export declare function readEvents(projectDir: string): Promise<HookEvent[]>;
@@ -1,15 +1,14 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- const STATE_DIR = ".claude/hooks/.state";
4
- const EVENTS_FILE = "events.jsonl";
3
+ import { OMH_STATE_DIR, OMH_EVENTS_FILE } from "../utils/paths.js";
5
4
  export async function appendEvent(projectDir, hookEvent) {
6
- const stateDir = path.join(projectDir, STATE_DIR);
5
+ const stateDir = path.join(projectDir, OMH_STATE_DIR);
7
6
  await fs.mkdir(stateDir, { recursive: true });
8
- const filePath = path.join(stateDir, EVENTS_FILE);
7
+ const filePath = path.join(stateDir, OMH_EVENTS_FILE);
9
8
  await fs.appendFile(filePath, JSON.stringify(hookEvent) + "\n", "utf-8");
10
9
  }
11
10
  export async function readEvents(projectDir) {
12
- const filePath = path.join(projectDir, STATE_DIR, EVENTS_FILE);
11
+ const filePath = path.join(projectDir, OMH_STATE_DIR, OMH_EVENTS_FILE);
13
12
  let content;
14
13
  try {
15
14
  content = await fs.readFile(filePath, "utf-8");