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