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.
- package/README.md +39 -34
- package/dist/bin/oh-my-harness.js +5 -0
- package/dist/catalog/blocks/branch-guard.js +6 -4
- package/dist/catalog/blocks/command-guard.js +3 -2
- package/dist/catalog/blocks/commit-test-gate.js +3 -2
- package/dist/catalog/blocks/commit-typecheck-gate.js +3 -2
- package/dist/catalog/blocks/config-audit.js +5 -14
- package/dist/catalog/blocks/lint-on-save.js +41 -7
- package/dist/catalog/blocks/lockfile-guard.js +27 -9
- package/dist/catalog/blocks/path-guard.js +59 -34
- package/dist/catalog/blocks/secret-file-guard.js +27 -9
- package/dist/catalog/blocks/sql-guard.js +3 -1
- package/dist/catalog/blocks/tdd-guard.js +11 -10
- package/dist/catalog/converter.js +2 -1
- package/dist/catalog/types.d.ts +22 -22
- package/dist/cli/command-checker.js +1 -1
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +40 -2
- package/dist/cli/commands/hook.js +7 -1
- package/dist/cli/commands/init.d.ts +3 -8
- package/dist/cli/commands/init.js +15 -131
- package/dist/cli/commands/sync.js +2 -0
- package/dist/cli/commands/test.js +1 -1
- package/dist/cli/commands/update.d.ts +18 -0
- package/dist/cli/commands/update.js +78 -0
- package/dist/cli/event-logger.d.ts +1 -0
- package/dist/cli/event-logger.js +4 -5
- package/dist/cli/harness-tester.js +5 -4
- package/dist/cli/index.js +18 -16
- package/dist/cli/installer-detect.d.ts +8 -0
- package/dist/cli/installer-detect.js +72 -0
- package/dist/cli/tui/init-flow.d.ts +0 -2
- package/dist/cli/tui/init-flow.js +69 -305
- package/dist/cli/version-checker.d.ts +5 -0
- package/dist/cli/version-checker.js +27 -0
- package/dist/cli/version-notifier.d.ts +7 -0
- package/dist/cli/version-notifier.js +26 -0
- package/dist/core/generator.d.ts +1 -1
- package/dist/core/generator.js +23 -9
- package/dist/core/harness-converter-v2.d.ts +4 -5
- package/dist/core/harness-converter-v2.js +52 -2
- package/dist/core/harness-defaults.d.ts +28 -0
- package/dist/core/harness-defaults.js +78 -0
- package/dist/core/harness-schema.d.ts +47 -32
- package/dist/core/harness-schema.js +11 -4
- package/dist/core/merged-config.d.ts +38 -0
- package/dist/core/merged-config.js +1 -0
- package/dist/generators/agents-md.d.ts +6 -0
- package/dist/generators/agents-md.js +5 -0
- package/dist/generators/claude-md.d.ts +1 -1
- package/dist/generators/claude-md.js +2 -35
- package/dist/generators/codex-config.d.ts +35 -0
- package/dist/generators/codex-config.js +109 -0
- package/dist/generators/hooks.d.ts +1 -1
- package/dist/generators/hooks.js +80 -30
- package/dist/generators/managed-md.d.ts +2 -0
- package/dist/generators/managed-md.js +23 -0
- package/dist/generators/settings.d.ts +1 -1
- package/dist/utils/fs-helpers.d.ts +3 -0
- package/dist/utils/fs-helpers.js +38 -0
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +11 -0
- package/dist/utils/state-migration.d.ts +5 -0
- package/dist/utils/state-migration.js +131 -0
- package/package.json +4 -1
- package/dist/cli/commands/add.d.ts +0 -5
- package/dist/cli/commands/add.js +0 -41
- package/dist/cli/commands/remove.d.ts +0 -5
- package/dist/cli/commands/remove.js +0 -89
- package/dist/core/config-merger.d.ts +0 -2
- package/dist/core/config-merger.js +0 -87
- package/dist/core/harness-converter.d.ts +0 -20
- package/dist/core/harness-converter.js +0 -91
- package/dist/core/preset-loader.d.ts +0 -3
- package/dist/core/preset-loader.js +0 -18
- package/dist/core/preset-registry.d.ts +0 -14
- package/dist/core/preset-registry.js +0 -39
- package/dist/core/preset-types.d.ts +0 -756
- package/dist/core/preset-types.js +0 -55
- package/presets/_base/preset.yaml +0 -103
- package/presets/actix/preset.yaml +0 -55
- package/presets/cargo/preset.yaml +0 -11
- package/presets/cpp/preset.yaml +0 -50
- package/presets/csharp/preset.yaml +0 -42
- package/presets/dart/preset.yaml +0 -43
- package/presets/django/preset.yaml +0 -72
- package/presets/elixir/preset.yaml +0 -45
- package/presets/express/preset.yaml +0 -61
- package/presets/fastapi/preset.yaml +0 -122
- package/presets/flask/preset.yaml +0 -65
- package/presets/flutter/preset.yaml +0 -64
- package/presets/gin/preset.yaml +0 -57
- package/presets/go/preset.yaml +0 -43
- package/presets/gradle/preset.yaml +0 -13
- package/presets/java/preset.yaml +0 -48
- package/presets/javascript/preset.yaml +0 -44
- package/presets/laravel/preset.yaml +0 -70
- package/presets/maven/preset.yaml +0 -13
- package/presets/nextjs/preset.yaml +0 -109
- package/presets/nextjs-fastapi/preset.yaml +0 -54
- package/presets/npm/preset.yaml +0 -12
- package/presets/phoenix/preset.yaml +0 -64
- package/presets/php/preset.yaml +0 -46
- package/presets/pip/preset.yaml +0 -13
- package/presets/pipenv/preset.yaml +0 -12
- package/presets/pnpm/preset.yaml +0 -12
- package/presets/python/preset.yaml +0 -48
- package/presets/rails/preset.yaml +0 -65
- package/presets/react/preset.yaml +0 -61
- package/presets/ruby/preset.yaml +0 -45
- package/presets/rust/preset.yaml +0 -42
- package/presets/scala/preset.yaml +0 -44
- package/presets/springboot/preset.yaml +0 -61
- package/presets/swift/preset.yaml +0 -44
- package/presets/terraform/preset.yaml +0 -65
- package/presets/typescript/preset.yaml +0 -46
- package/presets/uv/preset.yaml +0 -12
- package/presets/vue/preset.yaml +0 -62
- package/presets/yarn/preset.yaml +0 -12
- 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
|
|
56
|
+
oh-my-harness init "React app with TDD"
|
|
57
57
|
|
|
58
58
|
# Short alias works too
|
|
59
|
-
omh init "
|
|
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,
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 │ │
|
|
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
|
|
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 `.
|
|
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,
|
|
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
|
|
347
|
-
│ │ ├──
|
|
348
|
-
│ │ ├──
|
|
349
|
-
│ │ └──
|
|
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
|
-
|
|
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
|
-
- [ ]
|
|
401
|
-
- [ ]
|
|
402
|
-
- [ ] Community
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if [[ "$
|
|
32
|
-
echo "
|
|
33
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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="
|
|
38
|
-
HISTORY_FILE="\$STATE_DIR/
|
|
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" "
|
|
69
|
-
|
|
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
|
-
|
|
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" "
|
|
93
|
-
|
|
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 =
|
|
32
|
+
const scriptPath = `${OMH_HOOKS_DIR}/${scriptName}`;
|
|
32
33
|
scripts.set(scriptPath, scriptContent);
|
|
33
34
|
const hookEntry = {
|
|
34
35
|
type: "command",
|