oh-my-harness 0.22.0 → 0.23.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 -2
- package/dist/catalog/blocks/branch-guard.js +37 -3
- package/dist/catalog/blocks/command-guard.js +4 -0
- package/dist/catalog/blocks/commit-test-gate.js +4 -0
- package/dist/catalog/blocks/commit-typecheck-gate.js +4 -0
- package/dist/catalog/blocks/force-push-guard.js +4 -0
- package/dist/catalog/blocks/harness-guard.js +42 -3
- package/dist/catalog/blocks/lockfile-guard.js +4 -0
- package/dist/catalog/blocks/loop-guard.js +4 -0
- package/dist/catalog/blocks/no-verify-guard.js +4 -0
- package/dist/catalog/blocks/path-guard.js +4 -0
- package/dist/catalog/blocks/secret-file-guard.js +4 -0
- package/dist/catalog/blocks/sql-guard.js +4 -0
- package/dist/catalog/blocks/tdd-guard.js +4 -0
- package/dist/catalog/types.d.ts +7 -0
- package/dist/cli/commands/explain.d.ts +28 -0
- package/dist/cli/commands/explain.js +83 -0
- package/dist/cli/index.js +12 -0
- package/dist/cli/stats/data.d.ts +2 -0
- package/dist/cli/stats/data.js +10 -2
- package/dist/generators/hooks.js +53 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ That is the whole setup. Your agent now hits a wall when it tries to:
|
|
|
33
33
|
| save a file | ✅ auto-lint |
|
|
34
34
|
| push a branch | ✅ auto-PR |
|
|
35
35
|
|
|
36
|
-
Every decision is logged to `.omh/state/events.jsonl` — `omh stats` shows what your agent tried and what got stopped.
|
|
36
|
+
Every decision is logged to `.omh/state/events.jsonl` — `omh stats` shows what your agent tried and what got stopped, and `omh explain` tells you in plain language why the last few calls were blocked and how to allow once or change the rule.
|
|
37
37
|
|
|
38
38
|
## Why not just write CLAUDE.md?
|
|
39
39
|
|
|
@@ -66,7 +66,43 @@ omh diff # Preview what `omh sync` would change
|
|
|
66
66
|
omh sync --check # Fail (exit 1) if generated files are out of date — CI gate
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
📖 Deeper: [what gets generated & CI drift check](docs/generated-files.md) · [how it works, project detector, AI providers](docs/how-it-works.md)
|
|
69
|
+
📖 Deeper: [what gets generated & CI drift check](docs/generated-files.md) · [how it works, project detector, presets & Jev, AI providers](docs/how-it-works.md)
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 🎛️ Presets and the Jev chooser
|
|
74
|
+
|
|
75
|
+
You do not need a chat model to set up a harness.
|
|
76
|
+
|
|
77
|
+
**Presets** are deterministic and offline. The project detector fills in test, lint and typecheck commands and build directories; rule text is templated; a block whose required parameter cannot be detected is left out rather than half-configured.
|
|
78
|
+
|
|
79
|
+
| Preset | What it enables |
|
|
80
|
+
|--------|-----------------|
|
|
81
|
+
| `minimal` | dangerous-command guard, main-branch guard, build-output guard |
|
|
82
|
+
| `safe` | minimal + tests and typecheck must pass before a commit, lockfile and secret-file guards, lint on save |
|
|
83
|
+
| `strict` | safe + test-first (TDD) on every source edit |
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
omh init --preset safe # confirm prompt; add -y to skip it
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Jev** picks the blocks for you from a description. [Jev](https://docs.typesafe.ai/introduction) is TypeSafe's System One model: it does not generate text, it answers typed questions with calibrated probabilities. `omh init "description"` sends the description plus the detector facts and asks, in one call, "should block X be enabled?" for every selectable block and "how strict?" once. Probabilities at or above 0.65 enable a block, at or below 0.35 disable it, anything in between keeps the preset default. Rule text and free-form params never come from the model.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
export TYPESAFE_API_KEY=... # or put it in the project's .env
|
|
93
|
+
omh init "Next.js + FastAPI, TDD enforced, no auto PRs"
|
|
94
|
+
# Jev (jev-latest) chose: strictness=strict, 1716 input tokens
|
|
95
|
+
# enabled: ... tdd-guard, sql-guard ...
|
|
96
|
+
# disabled: auto-pr
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- Get a key from the [TypeSafe console](https://typesafe.ai) (early access at the time of writing). One init costs a fraction of a cent (input $0.042 per million tokens, output free) and takes about half a second.
|
|
100
|
+
- `--preset` always wins and never calls Jev. No key and no LLM provider? `omh init "description"` falls back to `safe` and says so.
|
|
101
|
+
- `omh doctor` and `omh config --show` tell you whether the chooser is active. Remove the key to go back to the LLM providers or presets.
|
|
102
|
+
- Interactive `omh init` offers both: "Describe your project (Jev picks the blocks)" and "Use a preset (no AI)".
|
|
103
|
+
- Descriptions in English work best; other languages are handled but with lower confidence, so include the specifics.
|
|
104
|
+
|
|
105
|
+
The always-on guards (harness self-protection, `--no-verify`, force-push) are added to every preset and every Jev result.
|
|
70
106
|
|
|
71
107
|
---
|
|
72
108
|
|
|
@@ -155,6 +191,7 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
|
|
|
155
191
|
# 🚀 Initialize
|
|
156
192
|
omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
|
|
157
193
|
omh init --preset strict # No provider: minimal | safe | strict
|
|
194
|
+
omh explain # Why were the last tool calls blocked, and what to do about it
|
|
158
195
|
omh init # Interactive TUI (import existing harness.yaml)
|
|
159
196
|
|
|
160
197
|
# 📋 Catalog
|
|
@@ -10,12 +10,46 @@ export const branchGuard = {
|
|
|
10
10
|
{ name: "mainBranch", type: "string", description: "Main branch name", default: "main", required: false },
|
|
11
11
|
],
|
|
12
12
|
tags: ["git", "branch", "merge", "guard"],
|
|
13
|
+
explain: {
|
|
14
|
+
allowOnce: "create a feature branch (git switch -c feat/x) and commit there",
|
|
15
|
+
change: "harness.yaml > hooks > branch-guard > params.mainBranch, or remove the hook, then omh sync",
|
|
16
|
+
},
|
|
13
17
|
template: `#!/bin/bash
|
|
14
18
|
set -euo pipefail
|
|
15
19
|
INPUT=$(cat)
|
|
16
20
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
17
21
|
if _omh_cmd_matches "$COMMAND" git commit || _omh_cmd_matches "$COMMAND" git push; then
|
|
18
|
-
|
|
22
|
+
# Where does the commit/push run? Follow cd / pushd and git -C (#133). A repo
|
|
23
|
+
# outside this project is not ours to guard; an unresolvable cwd is treated
|
|
24
|
+
# as the project itself (fail closed).
|
|
25
|
+
GIT_CWD=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' -v root="$_OMH_PROJECT_ROOT" -v home="\${HOME:-}" "\${_OMH_AWK_PATHLIB}"'
|
|
26
|
+
BEGIN { cwd = root }
|
|
27
|
+
found != "" { next }
|
|
28
|
+
{ i = 1
|
|
29
|
+
while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
|
|
30
|
+
if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
|
|
31
|
+
if (i > NF) next
|
|
32
|
+
if ($i == "cd" || $i == "pushd" || $i == "popd") { cwd = omh_cd_cmd(cwd, i, home); next }
|
|
33
|
+
if ($i != "git") next
|
|
34
|
+
c = cwd; ex = 0; j = i + 1
|
|
35
|
+
while (j <= NF && $j ~ /^-/) {
|
|
36
|
+
if ($j == "-C") { if ($(j + 1) ~ /^\\//) ex = 1; c = omh_cd(c, $(j + 1), home); j++ }
|
|
37
|
+
else if ($j == "-c") j++
|
|
38
|
+
j++
|
|
39
|
+
}
|
|
40
|
+
if (j <= NF && ($j == "commit" || $j == "push")) { found = c; explicit = ex } }
|
|
41
|
+
END { print found "\\t" explicit }')
|
|
42
|
+
GIT_EXPLICIT="\${GIT_CWD#* }"; GIT_CWD="\${GIT_CWD%% *}"
|
|
43
|
+
[[ "$GIT_CWD" == "?" || -z "$GIT_CWD" ]] && GIT_CWD="$_OMH_PROJECT_ROOT"
|
|
44
|
+
# ';' / newline chaining: a failed cd would leave the commit in this project, so judge it
|
|
45
|
+
# here, unless the target came from an explicit absolute git -C.
|
|
46
|
+
[[ "$(_omh_seq_unsafe "$COMMAND")" == "1" && "$GIT_EXPLICIT" != "1" ]] && GIT_CWD="$_OMH_PROJECT_ROOT"
|
|
47
|
+
case "$GIT_CWD" in
|
|
48
|
+
"$_OMH_PROJECT_ROOT"|"$_OMH_PROJECT_ROOT"/*) ;;
|
|
49
|
+
*) exit 0 ;;
|
|
50
|
+
esac
|
|
51
|
+
# A subdirectory of the project is the same repository; if it does not exist yet, ask the root.
|
|
52
|
+
BRANCH=$(git -C "$GIT_CWD" branch --show-current 2>/dev/null || git -C "$_OMH_PROJECT_ROOT" branch --show-current 2>/dev/null)
|
|
19
53
|
[[ -z "$BRANCH" ]] && exit 0
|
|
20
54
|
MAIN='{{mainBranch}}'
|
|
21
55
|
if [[ "$BRANCH" == "$MAIN" ]] || [[ "$BRANCH" == "master" && "$MAIN" == "main" ]]; then
|
|
@@ -32,8 +66,8 @@ if _omh_cmd_matches "$COMMAND" git commit || _omh_cmd_matches "$COMMAND" git pus
|
|
|
32
66
|
fi
|
|
33
67
|
fi
|
|
34
68
|
if [[ "$MERGED" -eq 0 ]]; then
|
|
35
|
-
git fetch origin "$MAIN" --quiet >/dev/null 2>&1 || true
|
|
36
|
-
if git branch -r --merged "origin/$MAIN" 2>/dev/null | grep -qE "origin/\${BRANCH}$"; then
|
|
69
|
+
git -C "$GIT_CWD" fetch origin "$MAIN" --quiet >/dev/null 2>&1 || true
|
|
70
|
+
if git -C "$GIT_CWD" branch -r --merged "origin/$MAIN" 2>/dev/null | grep -qE "origin/\${BRANCH}$"; then
|
|
37
71
|
MERGED=1
|
|
38
72
|
fi
|
|
39
73
|
fi
|
|
@@ -16,6 +16,10 @@ export const commandGuard = {
|
|
|
16
16
|
},
|
|
17
17
|
],
|
|
18
18
|
tags: ["security", "bash", "guard", "dangerous"],
|
|
19
|
+
explain: {
|
|
20
|
+
allowOnce: "rewrite the command without the blocked pattern (a narrower path, no sudo, no -R 777)",
|
|
21
|
+
change: "harness.yaml > hooks > command-guard > params.patterns: remove or edit the pattern, then omh sync",
|
|
22
|
+
},
|
|
19
23
|
template: `#!/bin/bash
|
|
20
24
|
set -euo pipefail
|
|
21
25
|
INPUT=$(cat)
|
|
@@ -17,6 +17,10 @@ export const commitTestGate = {
|
|
|
17
17
|
},
|
|
18
18
|
],
|
|
19
19
|
tags: ["git", "test", "quality", "guard"],
|
|
20
|
+
explain: {
|
|
21
|
+
allowOnce: "make the test command pass, or commit only after fixing the failing test",
|
|
22
|
+
change: "harness.yaml > hooks > commit-test-gate: change testCommand or cacheTtlSeconds, set mode: ask, or remove it, then omh sync",
|
|
23
|
+
},
|
|
20
24
|
template: `#!/bin/bash
|
|
21
25
|
set -euo pipefail
|
|
22
26
|
INPUT=$(cat)
|
|
@@ -17,6 +17,10 @@ export const commitTypecheckGate = {
|
|
|
17
17
|
},
|
|
18
18
|
],
|
|
19
19
|
tags: ["git", "typecheck", "quality", "guard"],
|
|
20
|
+
explain: {
|
|
21
|
+
allowOnce: "fix the type errors the typecheck command reports",
|
|
22
|
+
change: "harness.yaml > hooks > commit-typecheck-gate: change typecheckCommand, set mode: ask, or remove it, then omh sync",
|
|
23
|
+
},
|
|
20
24
|
template: `#!/bin/bash
|
|
21
25
|
set -euo pipefail
|
|
22
26
|
INPUT=$(cat)
|
|
@@ -32,6 +32,10 @@ export const forcePushGuard = {
|
|
|
32
32
|
},
|
|
33
33
|
],
|
|
34
34
|
tags: ["git", "push", "guard", "force"],
|
|
35
|
+
explain: {
|
|
36
|
+
allowOnce: "push to a feature branch and open a pull request; use --force-with-lease if you really need to rewrite your own branch",
|
|
37
|
+
change: "harness.yaml > hooks > force-push-guard > params.protected / allowLease, or remove it, then omh sync",
|
|
38
|
+
},
|
|
35
39
|
template: `#!/bin/bash
|
|
36
40
|
set -euo pipefail
|
|
37
41
|
INPUT=$(cat)
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// ls, diff, sed without -i) stay allowed so the agent can inspect its own rules, and `omh` is
|
|
7
7
|
// never a writer here, so `omh sync` / `omh hook add` remain the sanctioned
|
|
8
8
|
// way to change the harness.
|
|
9
|
+
// Scope (#133): by default only paths that resolve inside this project root
|
|
10
|
+
// count, following cd / pushd, ~, .. and absolute paths, so another checkout's
|
|
11
|
+
// .omh is not this harness's business. Tokens that cannot be resolved ($VAR,
|
|
12
|
+
// backticks, unknown cwd) fall back to name matching and stay fail-closed.
|
|
9
13
|
// ponytail: harness.yaml itself is deliberately not protected (it is the
|
|
10
14
|
// user's knob); per-hook `locked: true` is tracked in #118. `rm -rf .` / `*`
|
|
11
15
|
// from the project root are left to command-guard patterns.
|
|
@@ -25,8 +29,19 @@ export const harnessGuard = {
|
|
|
25
29
|
default: [],
|
|
26
30
|
required: false,
|
|
27
31
|
},
|
|
32
|
+
{
|
|
33
|
+
name: "scope",
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "project: only paths that resolve inside this project root are protected (cd and absolute paths are followed); any: match the protected names anywhere in the command",
|
|
36
|
+
default: "project",
|
|
37
|
+
required: false,
|
|
38
|
+
},
|
|
28
39
|
],
|
|
29
40
|
tags: ["security", "bash", "guard", "harness", "self-protection"],
|
|
41
|
+
explain: {
|
|
42
|
+
allowOnce: "edit harness.yaml and run omh sync; hook scripts and runtime config are generated, not hand-edited",
|
|
43
|
+
change: "harness.yaml > hooks > harness-guard: scope: any / project, extraPaths, mode: ask, or remove it, then omh sync",
|
|
44
|
+
},
|
|
30
45
|
template: `#!/bin/bash
|
|
31
46
|
set -euo pipefail
|
|
32
47
|
INPUT=$(cat)
|
|
@@ -41,12 +56,15 @@ WRITERS="rm mv cp tee chmod chown chgrp truncate dd ln touch install rsync shred
|
|
|
41
56
|
GIT_WRITERS="checkout restore clean rm mv"
|
|
42
57
|
|
|
43
58
|
HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
|
|
44
|
-
-v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v writers="$WRITERS" -v gitw="$GIT_WRITERS"
|
|
59
|
+
-v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v writers="$WRITERS" -v gitw="$GIT_WRITERS" \\
|
|
60
|
+
-v root="$_OMH_PROJECT_ROOT" -v home="\${HOME:-}" -v scope='{{scope}}' -v seq_unsafe="$(_omh_seq_unsafe "$COMMAND")" "\${_OMH_AWK_PATHLIB}"'
|
|
45
61
|
BEGIN {
|
|
62
|
+
cwd = root
|
|
46
63
|
np = split(protected, P, "|")
|
|
47
64
|
nw = split(writers, W, " "); for (k = 1; k <= nw; k++) isw[W[k]] = 1
|
|
48
65
|
ng = split(gitw, G, " "); for (k = 1; k <= ng; k++) isg[G[k]] = 1
|
|
49
66
|
}
|
|
67
|
+
# name-based matching: scope=any, and the fallback for tokens that cannot be resolved
|
|
50
68
|
function hits(t, k, p, L) {
|
|
51
69
|
sub(/^\\.\\//, "", t)
|
|
52
70
|
for (k = 1; k <= np; k++) {
|
|
@@ -59,13 +77,34 @@ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
|
|
|
59
77
|
}
|
|
60
78
|
return ""
|
|
61
79
|
}
|
|
80
|
+
# project scope: the token must resolve (through cd, ~, .., absolute paths) to a protected path under root
|
|
81
|
+
function check_at(base, t, a, rel, k, p, L) {
|
|
82
|
+
a = omh_abs(base, t, home)
|
|
83
|
+
if (a == "") return hits(t)
|
|
84
|
+
if (!omh_under(a, root)) return ""
|
|
85
|
+
rel = (a == root) ? "" : substr(a, length(root) + 2)
|
|
86
|
+
for (k = 1; k <= np; k++) {
|
|
87
|
+
p = P[k]; if (p == "") continue
|
|
88
|
+
sub(/^\\.\\//, "", p); L = length(p)
|
|
89
|
+
if (rel == p || substr(rel, 1, L + 1) == p "/") return p
|
|
90
|
+
}
|
|
91
|
+
return ""
|
|
92
|
+
}
|
|
93
|
+
function check(t, p) {
|
|
94
|
+
if (scope == "any") return hits(t)
|
|
95
|
+
p = check_at(cwd, t)
|
|
96
|
+
# with semicolon or newline chaining a failed cd leaves the shell where it was: judge from the root too
|
|
97
|
+
if (p == "" && seq_unsafe == "1" && cwd != root && t !~ /^\\//) p = check_at(root, t)
|
|
98
|
+
return p
|
|
99
|
+
}
|
|
62
100
|
found != "" { next }
|
|
63
101
|
{
|
|
64
|
-
if ($1 == "__omh_redirect__") { p =
|
|
102
|
+
if ($1 == "__omh_redirect__") { p = check($2); if (p != "") found = "redirect into " p; next }
|
|
65
103
|
i = 1
|
|
66
104
|
while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
|
|
67
105
|
if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
|
|
68
106
|
if (i > NF) next
|
|
107
|
+
if ($i == "cd" || $i == "pushd" || $i == "popd") { cwd = omh_cd_cmd(cwd, i, home); next }
|
|
69
108
|
a0 = $i
|
|
70
109
|
write = 0
|
|
71
110
|
if (isw[a0]) write = 1
|
|
@@ -82,7 +121,7 @@ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
|
|
|
82
121
|
}
|
|
83
122
|
else if (a0 == "find") { for (k = i; k <= NF; k++) if ($k == "-delete" || $k == "-exec" || $k == "-execdir") write = 1 }
|
|
84
123
|
if (!write) next
|
|
85
|
-
for (k = i + 1; k <= NF; k++) { p =
|
|
124
|
+
for (k = i + 1; k <= NF; k++) { p = check($k); if (p != "") { found = a0 " on " p; next } }
|
|
86
125
|
}
|
|
87
126
|
END { if (found != "") print found }')
|
|
88
127
|
if [[ -n "$HIT" ]]; then
|
|
@@ -16,6 +16,10 @@ export const lockfileGuard = {
|
|
|
16
16
|
},
|
|
17
17
|
],
|
|
18
18
|
tags: ["security", "file", "lockfile", "guard"],
|
|
19
|
+
explain: {
|
|
20
|
+
allowOnce: "change the dependency with the package manager (npm/pnpm/yarn/pip add or remove) so it rewrites the lockfile",
|
|
21
|
+
change: "harness.yaml > hooks > lockfile-guard > params.lockfiles: remove the file, or remove the hook, then omh sync",
|
|
22
|
+
},
|
|
19
23
|
template: `#!/bin/bash
|
|
20
24
|
set -euo pipefail
|
|
21
25
|
INPUT=$(cat)
|
|
@@ -30,6 +30,10 @@ export const loopGuard = {
|
|
|
30
30
|
default: [],
|
|
31
31
|
},
|
|
32
32
|
],
|
|
33
|
+
explain: {
|
|
34
|
+
allowOnce: "the loop must not write its own work orders or architect-only paths; do that from an architect session",
|
|
35
|
+
change: "harness.yaml > loop.architectOnly / loop.workOrders, or disable the loop, then omh sync",
|
|
36
|
+
},
|
|
33
37
|
template: `#!/bin/bash
|
|
34
38
|
set -euo pipefail
|
|
35
39
|
INPUT=$(cat)
|
|
@@ -18,6 +18,10 @@ export const noVerifyGuard = {
|
|
|
18
18
|
canBlock: true,
|
|
19
19
|
params: [],
|
|
20
20
|
tags: ["git", "hooks", "guard", "bypass"],
|
|
21
|
+
explain: {
|
|
22
|
+
allowOnce: "fix what the git hook reports and commit or push again without --no-verify",
|
|
23
|
+
change: "harness.yaml > hooks > no-verify-guard: set mode: ask or remove it, then omh sync",
|
|
24
|
+
},
|
|
21
25
|
template: `#!/bin/bash
|
|
22
26
|
set -euo pipefail
|
|
23
27
|
INPUT=$(cat)
|
|
@@ -22,6 +22,10 @@ export const pathGuard = {
|
|
|
22
22
|
},
|
|
23
23
|
],
|
|
24
24
|
tags: ["security", "file", "path", "guard"],
|
|
25
|
+
explain: {
|
|
26
|
+
allowOnce: "write to a source path instead; build output and dependency directories are regenerated by their tools",
|
|
27
|
+
change: "harness.yaml > hooks > path-guard > params.blockedPaths: remove the path (protectHarness: false lifts the self-protection), then omh sync",
|
|
28
|
+
},
|
|
25
29
|
template: `#!/bin/bash
|
|
26
30
|
set -euo pipefail
|
|
27
31
|
INPUT=$(cat)
|
|
@@ -16,6 +16,10 @@ export const secretFileGuard = {
|
|
|
16
16
|
},
|
|
17
17
|
],
|
|
18
18
|
tags: ["security", "secrets", "credentials", "guard"],
|
|
19
|
+
explain: {
|
|
20
|
+
allowOnce: "edit the secret file yourself outside the agent",
|
|
21
|
+
change: "harness.yaml > hooks > secret-file-guard > params.patterns: remove the pattern, or remove the hook, then omh sync",
|
|
22
|
+
},
|
|
19
23
|
template: `#!/bin/bash
|
|
20
24
|
set -euo pipefail
|
|
21
25
|
INPUT=$(cat)
|
|
@@ -16,6 +16,10 @@ export const sqlGuard = {
|
|
|
16
16
|
},
|
|
17
17
|
],
|
|
18
18
|
tags: ["security", "sql", "guard", "database"],
|
|
19
|
+
explain: {
|
|
20
|
+
allowOnce: "run the statement yourself outside the agent, or use a narrower statement",
|
|
21
|
+
change: "harness.yaml > hooks > sql-guard > params.patterns: remove or edit the pattern, then omh sync",
|
|
22
|
+
},
|
|
19
23
|
template: `#!/bin/bash
|
|
20
24
|
set -euo pipefail
|
|
21
25
|
INPUT=$(cat)
|
|
@@ -22,6 +22,10 @@ export const tddGuard = {
|
|
|
22
22
|
default: "\\.(test|spec)\\.(ts|tsx|js|jsx)$",
|
|
23
23
|
},
|
|
24
24
|
],
|
|
25
|
+
explain: {
|
|
26
|
+
allowOnce: "edit or create the matching test file first (the guard records test edits per session), then edit the source",
|
|
27
|
+
change: "harness.yaml > hooks > tdd-guard: narrow srcPattern/testPattern, set mode: ask, or remove it, then omh sync",
|
|
28
|
+
},
|
|
25
29
|
template: `#!/bin/bash
|
|
26
30
|
set -euo pipefail
|
|
27
31
|
INPUT=$(cat)
|
package/dist/catalog/types.d.ts
CHANGED
|
@@ -19,6 +19,13 @@ export interface BuildingBlock {
|
|
|
19
19
|
params: ParamDefinition[];
|
|
20
20
|
template: string;
|
|
21
21
|
tags: string[];
|
|
22
|
+
/** Plain-language remedies shown by `omh explain` (#115). */
|
|
23
|
+
explain?: {
|
|
24
|
+
/** How to get past this block one time without changing the rule. */
|
|
25
|
+
allowOnce?: string;
|
|
26
|
+
/** Where the rule lives and how to change it. */
|
|
27
|
+
change?: string;
|
|
28
|
+
};
|
|
22
29
|
}
|
|
23
30
|
export interface HookEntry {
|
|
24
31
|
block: string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type HookEvent } from "../event-logger.js";
|
|
2
|
+
import type { BuildingBlock } from "../../catalog/types.js";
|
|
3
|
+
export interface ExplainOptions {
|
|
4
|
+
projectDir?: string;
|
|
5
|
+
/** How many block decisions to show, newest first. Default 5. */
|
|
6
|
+
last?: number;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface ExplainEntry {
|
|
10
|
+
ts: string;
|
|
11
|
+
block: string;
|
|
12
|
+
hook: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
allowOnce?: string;
|
|
15
|
+
change: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* catalog-<id>.sh -> id. Legacy scripts were named harness-<name>.sh, so a
|
|
19
|
+
* harness- prefix is stripped too, but only when what is left is not itself a
|
|
20
|
+
* block id: catalog-harness-guard.sh must stay harness-guard.
|
|
21
|
+
*/
|
|
22
|
+
export declare function blockIdFromHook(hook: string, knownIds?: Set<string>): string;
|
|
23
|
+
export declare function toEntry(ev: HookEvent, blocks: BuildingBlock[]): ExplainEntry;
|
|
24
|
+
export declare function formatExplanation(ev: HookEvent, blocks: BuildingBlock[], now?: number): string;
|
|
25
|
+
export declare function explainCommand(options?: ExplainOptions): Promise<{
|
|
26
|
+
exitCode: number;
|
|
27
|
+
entries: ExplainEntry[];
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { readEvents } from "../event-logger.js";
|
|
3
|
+
import { builtinBlocks } from "../../catalog/blocks/index.js";
|
|
4
|
+
const GENERIC_CHANGE = (id) => `harness.yaml > hooks > ${id}: set mode: ask, adjust its params, or remove the entry, then run omh sync`;
|
|
5
|
+
const UNKNOWN_HOOK_CHANGE = "this hook is not a catalog block: look it up in .claude/settings.json (or harness.yaml if you added it there)";
|
|
6
|
+
const BUILTIN_IDS = new Set(builtinBlocks.map((b) => b.id));
|
|
7
|
+
/**
|
|
8
|
+
* catalog-<id>.sh -> id. Legacy scripts were named harness-<name>.sh, so a
|
|
9
|
+
* harness- prefix is stripped too, but only when what is left is not itself a
|
|
10
|
+
* block id: catalog-harness-guard.sh must stay harness-guard.
|
|
11
|
+
*/
|
|
12
|
+
export function blockIdFromHook(hook, knownIds = BUILTIN_IDS) {
|
|
13
|
+
const id = hook.replace(/\.sh$/, "").replace(/^catalog-/, "");
|
|
14
|
+
if (id.startsWith("harness-") && !knownIds.has(id))
|
|
15
|
+
return id.slice("harness-".length);
|
|
16
|
+
return id;
|
|
17
|
+
}
|
|
18
|
+
function relativeTime(ts, now) {
|
|
19
|
+
const diff = Math.max(0, now - Date.parse(ts));
|
|
20
|
+
const s = Math.round(diff / 1000);
|
|
21
|
+
if (s < 60)
|
|
22
|
+
return `${s}s ago`;
|
|
23
|
+
const m = Math.round(s / 60);
|
|
24
|
+
if (m < 60)
|
|
25
|
+
return `${m} min ago`;
|
|
26
|
+
const h = Math.round(m / 60);
|
|
27
|
+
if (h < 48)
|
|
28
|
+
return `${h} h ago`;
|
|
29
|
+
return `${Math.round(h / 24)} d ago`;
|
|
30
|
+
}
|
|
31
|
+
export function toEntry(ev, blocks) {
|
|
32
|
+
const id = blockIdFromHook(ev.hook);
|
|
33
|
+
const block = blocks.find((b) => b.id === id);
|
|
34
|
+
const reason = (ev.reason ?? "").replace(/^oh-my-harness:\s*/, "");
|
|
35
|
+
return {
|
|
36
|
+
ts: ev.ts,
|
|
37
|
+
block: id,
|
|
38
|
+
hook: ev.hook,
|
|
39
|
+
reason,
|
|
40
|
+
allowOnce: block?.explain?.allowOnce,
|
|
41
|
+
change: block ? (block.explain?.change ?? GENERIC_CHANGE(id)) : UNKNOWN_HOOK_CHANGE,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function formatExplanation(ev, blocks, now = Date.now()) {
|
|
45
|
+
const e = toEntry(ev, blocks);
|
|
46
|
+
const lines = [
|
|
47
|
+
`${chalk.dim(relativeTime(e.ts, now).padEnd(11))}${chalk.bold(e.block)} ${chalk.red("blocked")}`,
|
|
48
|
+
` Because: ${e.reason || "(no reason recorded)"}`,
|
|
49
|
+
];
|
|
50
|
+
if (e.allowOnce)
|
|
51
|
+
lines.push(` To allow once: ${e.allowOnce}`);
|
|
52
|
+
lines.push(` To change the rule: ${e.change}`);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
export async function explainCommand(options = {}) {
|
|
56
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
57
|
+
const last = options.last && options.last > 0 ? options.last : 5;
|
|
58
|
+
let events = [];
|
|
59
|
+
try {
|
|
60
|
+
events = await readEvents(projectDir);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
events = [];
|
|
64
|
+
}
|
|
65
|
+
const blocked = events.filter((e) => e.decision === "block").slice(-last).reverse();
|
|
66
|
+
const entries = blocked.map((e) => toEntry(e, builtinBlocks));
|
|
67
|
+
if (options.json) {
|
|
68
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
69
|
+
return { exitCode: 0, entries };
|
|
70
|
+
}
|
|
71
|
+
if (blocked.length === 0) {
|
|
72
|
+
console.log("No blocked tool calls in .omh/state/events.jsonl yet.");
|
|
73
|
+
return { exitCode: 0, entries };
|
|
74
|
+
}
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
console.log(chalk.bold(`Last ${blocked.length} blocked tool call${blocked.length === 1 ? "" : "s"}:`));
|
|
77
|
+
console.log("");
|
|
78
|
+
for (const e of blocked) {
|
|
79
|
+
console.log(formatExplanation(e, builtinBlocks, now));
|
|
80
|
+
console.log("");
|
|
81
|
+
}
|
|
82
|
+
return { exitCode: 0, entries };
|
|
83
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -35,6 +35,18 @@ export function createCli() {
|
|
|
35
35
|
process.exitCode = 1;
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
|
+
program
|
|
39
|
+
.command("explain")
|
|
40
|
+
.description("Show the last blocked tool calls in plain language, with how to allow once and how to change the rule")
|
|
41
|
+
.option("-d, --project-dir <dir>", "Project directory")
|
|
42
|
+
.option("-n, --last <count>", "How many blocks to show (default 5)", (v) => Number.parseInt(v, 10))
|
|
43
|
+
.option("--json", "Machine-readable output")
|
|
44
|
+
.action(async (options) => {
|
|
45
|
+
const { explainCommand } = await import("./commands/explain.js");
|
|
46
|
+
const result = await explainCommand(options);
|
|
47
|
+
if (result.exitCode !== 0)
|
|
48
|
+
process.exitCode = result.exitCode;
|
|
49
|
+
});
|
|
38
50
|
program
|
|
39
51
|
.command("doctor")
|
|
40
52
|
.description("Validate harness configuration health")
|
package/dist/cli/stats/data.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface StatsData {
|
|
|
34
34
|
dateRange: DateRange;
|
|
35
35
|
}
|
|
36
36
|
export declare function deduplicateBlocks(blocks: BlockStats[]): BlockStats[];
|
|
37
|
+
/** catalog-<id>.sh -> id; legacy harness-<name>.sh -> name, unless the name is itself a block id (harness-guard). */
|
|
38
|
+
export declare function normalizeHookName(hook: string): string;
|
|
37
39
|
export declare function getActiveBlocks(hookEntries: HookEntry[], allBlocks: BuildingBlock[]): {
|
|
38
40
|
block: BuildingBlock;
|
|
39
41
|
params: Record<string, unknown>;
|
package/dist/cli/stats/data.js
CHANGED
|
@@ -26,6 +26,14 @@ export function deduplicateBlocks(blocks) {
|
|
|
26
26
|
}
|
|
27
27
|
return [...map.values()];
|
|
28
28
|
}
|
|
29
|
+
const BUILTIN_IDS = new Set(builtinBlocks.map((b) => b.id));
|
|
30
|
+
/** catalog-<id>.sh -> id; legacy harness-<name>.sh -> name, unless the name is itself a block id (harness-guard). */
|
|
31
|
+
export function normalizeHookName(hook) {
|
|
32
|
+
const id = hook.replace(/\.sh$/, "").replace(/^catalog-/, "");
|
|
33
|
+
if (id.startsWith("harness-") && !BUILTIN_IDS.has(id))
|
|
34
|
+
return id.slice("harness-".length);
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
29
37
|
export function getActiveBlocks(hookEntries, allBlocks) {
|
|
30
38
|
const result = [];
|
|
31
39
|
for (const entry of hookEntries) {
|
|
@@ -37,7 +45,7 @@ export function getActiveBlocks(hookEntries, allBlocks) {
|
|
|
37
45
|
return result;
|
|
38
46
|
}
|
|
39
47
|
export function getDormantBlocks(activeBlocks, events) {
|
|
40
|
-
const hitHooks = new Set(events.map(e => e.hook
|
|
48
|
+
const hitHooks = new Set(events.map(e => normalizeHookName(e.hook)));
|
|
41
49
|
return activeBlocks
|
|
42
50
|
.filter(ab => !hitHooks.has(ab.block.id))
|
|
43
51
|
.map(ab => ab.block.id);
|
|
@@ -76,7 +84,7 @@ export function getDateFilteredEvents(events, range) {
|
|
|
76
84
|
export function getBlockDetail(blockId, events, allBlocks, params = {}) {
|
|
77
85
|
const block = allBlocks.find(b => b.id === blockId);
|
|
78
86
|
const hookEvents = events.filter(e => {
|
|
79
|
-
const normalized = e.hook
|
|
87
|
+
const normalized = normalizeHookName(e.hook);
|
|
80
88
|
return normalized === blockId;
|
|
81
89
|
});
|
|
82
90
|
const blockCount = hookEvents.filter(e => e.decision === "block").length;
|
package/dist/generators/hooks.js
CHANGED
|
@@ -203,7 +203,59 @@ _omh_gate_record() {
|
|
|
203
203
|
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
|
204
204
|
printf '%s %s\\n' "$fp" "$(date +%s)" > "$file"
|
|
205
205
|
}`;
|
|
206
|
-
|
|
206
|
+
// Path resolution shared by guards that must know whether a token points
|
|
207
|
+
// inside THIS project (#133). Exposed as a bash variable holding awk function
|
|
208
|
+
// definitions; templates concatenate it in front of their own awk program:
|
|
209
|
+
// awk -v root=... "${_OMH_AWK_PATHLIB}"'BEGIN { ... }'
|
|
210
|
+
// omh_abs returns "" when the token cannot be resolved ($VAR, backticks,
|
|
211
|
+
// unknown cwd); callers fall back to name matching, so unknown stays fail-closed.
|
|
212
|
+
const OMH_AWK_PATHLIB = String.raw `
|
|
213
|
+
function omh_norm(p, n, parts, out, i, depth, res, s) {
|
|
214
|
+
n = split(p, parts, "/"); depth = 0
|
|
215
|
+
for (i = 1; i <= n; i++) {
|
|
216
|
+
s = parts[i]
|
|
217
|
+
if (s == "" || s == ".") continue
|
|
218
|
+
if (s == "..") { if (depth > 0) depth--; continue }
|
|
219
|
+
out[++depth] = s
|
|
220
|
+
}
|
|
221
|
+
res = ""; for (i = 1; i <= depth; i++) res = res "/" out[i]
|
|
222
|
+
return res == "" ? "/" : res
|
|
223
|
+
}
|
|
224
|
+
function omh_abs(cwd, t, home) {
|
|
225
|
+
if (t == "" || index(t, "$") > 0 || index(t, "\140") > 0) return ""
|
|
226
|
+
if (t ~ /^~(\/|$)/) { if (home == "") return ""; t = home substr(t, 2) }
|
|
227
|
+
else if (t !~ /^\//) { if (cwd == "?") return ""; t = cwd "/" t }
|
|
228
|
+
return omh_norm(t)
|
|
229
|
+
}
|
|
230
|
+
function omh_cd(cwd, arg, home, a) {
|
|
231
|
+
if (arg == "" || arg == "-") return "?"
|
|
232
|
+
a = omh_abs(cwd, arg, home)
|
|
233
|
+
return a == "" ? "?" : a
|
|
234
|
+
}
|
|
235
|
+
# cd / pushd / popd with option skipping (cd -P dir) and a directory stack.
|
|
236
|
+
# Returns the new cwd; the caller keeps the stack (omh_stack, omh_sp) between calls.
|
|
237
|
+
function omh_cd_cmd(cwd, i, home, k, arg) {
|
|
238
|
+
# pushd -n (no cd), pushd/popd +N or -N (stack rotation) and popd with options
|
|
239
|
+
# change the stack in ways we do not model: unknown cwd, fail closed.
|
|
240
|
+
for (k = i + 1; k <= NF; k++) {
|
|
241
|
+
if ($i != "cd" && ($k == "-n" || $k ~ /^[+-][0-9]+$/)) { omh_sp = 0; return "?" }
|
|
242
|
+
}
|
|
243
|
+
if ($i == "popd") { return (omh_sp > 0) ? omh_stack[omh_sp--] : "?" }
|
|
244
|
+
arg = ""
|
|
245
|
+
for (k = i + 1; k <= NF; k++) { if ($k ~ /^-./ && $k != "-") continue; arg = $k; break }
|
|
246
|
+
if ($i == "pushd") omh_stack[++omh_sp] = cwd
|
|
247
|
+
return omh_cd(cwd, arg, home)
|
|
248
|
+
}
|
|
249
|
+
function omh_under(a, root) {
|
|
250
|
+
return a == root || substr(a, 1, length(root) + 1) == root "/"
|
|
251
|
+
}`;
|
|
252
|
+
const OMH_CMD_HELPERS = `_OMH_AWK_PATHLIB='${OMH_AWK_PATHLIB}'
|
|
253
|
+
# 1 when the command chains with ';' or newlines: a failed cd then leaves the next
|
|
254
|
+
# command in the OLD directory, so relative paths must also be judged from there.
|
|
255
|
+
_omh_seq_unsafe() { case "\${1:-}" in *";"*|*$'\\n'*) echo 1 ;; *) echo 0 ;; esac; }
|
|
256
|
+
# The project this hook belongs to: the parent of .omh/state (absolute, symlinks resolved).
|
|
257
|
+
_OMH_PROJECT_ROOT="$(cd "$(dirname "$(dirname "$_OMH_STATE_DIR")")" 2>/dev/null && pwd -P || pwd -P)"
|
|
258
|
+
${OMH_TREE_FINGERPRINT}
|
|
207
259
|
_omh_tokenize() {
|
|
208
260
|
printf '%s\\n' "\${1:-}" | awk '${OMH_CMD_TOKENIZER_AWK}'
|
|
209
261
|
}
|