oh-my-harness 0.22.0 → 0.24.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 +43 -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 +10 -2
- package/dist/catalog/blocks/commit-typecheck-gate.js +10 -2
- package/dist/catalog/blocks/force-push-guard.js +4 -0
- package/dist/catalog/blocks/harness-guard.js +42 -3
- package/dist/catalog/blocks/index.js +4 -0
- 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/stop-test-gate.d.ts +2 -0
- package/dist/catalog/blocks/stop-test-gate.js +81 -0
- package/dist/catalog/blocks/stop-uncommitted-warn.d.ts +2 -0
- package/dist/catalog/blocks/stop-uncommitted-warn.js +30 -0
- package/dist/catalog/blocks/tdd-guard.js +4 -0
- package/dist/catalog/types.d.ts +15 -2
- package/dist/catalog/types.js +2 -0
- package/dist/cli/commands/explain.d.ts +28 -0
- package/dist/cli/commands/explain.js +83 -0
- package/dist/cli/commands/modify.d.ts +8 -0
- package/dist/cli/commands/modify.js +77 -0
- package/dist/cli/commands/test.js +1 -0
- package/dist/cli/harness-tester.js +10 -0
- package/dist/cli/index.js +24 -0
- package/dist/cli/stats/data.d.ts +2 -0
- package/dist/cli/stats/data.js +10 -2
- package/dist/core/harness-converter-v2.js +3 -1
- package/dist/core/harness-schema.d.ts +5 -0
- package/dist/core/merged-config.d.ts +2 -0
- package/dist/core/presets.js +2 -0
- package/dist/generators/codex-config.js +1 -1
- package/dist/generators/hooks.d.ts +2 -2
- package/dist/generators/hooks.js +64 -4
- package/dist/generators/pi-extension.js +1 -1
- package/dist/nl/typesafe-chooser.d.ts +1 -1
- package/dist/nl/typesafe-chooser.js +1 -0
- package/dist/nl/typesafe-modify.d.ts +51 -0
- package/dist/nl/typesafe-modify.js +118 -0
- 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,44 @@ 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
|
+
- `omh modify "..."` later edits the same way: Jev answers enable / disable / ask / keep per block, you confirm the change set, `omh sync` runs. Mark an entry `locked: true` in harness.yaml and modify will refuse to remove or weaken it.
|
|
103
|
+
- Interactive `omh init` offers both: "Describe your project (Jev picks the blocks)" and "Use a preset (no AI)".
|
|
104
|
+
- Descriptions in English work best; other languages are handled but with lower confidence, so include the specifics.
|
|
105
|
+
|
|
106
|
+
The always-on guards (harness self-protection, `--no-verify`, force-push) are added to every preset and every Jev result.
|
|
70
107
|
|
|
71
108
|
---
|
|
72
109
|
|
|
@@ -83,6 +120,8 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
|
|
|
83
120
|
| 🪝 `harness-guard` | security | Blocks shell writes to the harness's own hooks and config (always on) |
|
|
84
121
|
| 🚫 `no-verify-guard` | git | Blocks `--no-verify` / `-n` / hooksPath overrides on commit and push (always on) |
|
|
85
122
|
| 💥 `force-push-guard` | git | Blocks force pushes to protected branches (always on) |
|
|
123
|
+
| 🛑 `stop-test-gate` | quality | When the agent tries to end its turn with failing tests, sends it back (retry-capped) |
|
|
124
|
+
| 📝 `stop-uncommitted-warn` | git | Lists uncommitted changes as a system message when the turn ends |
|
|
86
125
|
| 📁 `path-guard` | file-protection | Blocks writes to protected paths |
|
|
87
126
|
| 🔐 `lockfile-guard` | file-protection | Prevents manual lockfile edits |
|
|
88
127
|
| 🤫 `secret-file-guard` | security | Blocks edits to .env, credentials |
|
|
@@ -155,6 +194,8 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
|
|
|
155
194
|
# 🚀 Initialize
|
|
156
195
|
omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
|
|
157
196
|
omh init --preset strict # No provider: minimal | safe | strict
|
|
197
|
+
omh explain # Why were the last tool calls blocked, and what to do about it
|
|
198
|
+
omh modify "no auto PRs, TDD guard asks" # Edit harness.yaml from a sentence (Jev decides per block; locked: true entries stay)
|
|
158
199
|
omh init # Interactive TUI (import existing harness.yaml)
|
|
159
200
|
|
|
160
201
|
# 📋 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,13 +17,21 @@ 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)
|
|
23
27
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
24
28
|
if _omh_cmd_matches "$COMMAND" git commit; then
|
|
25
29
|
FP=$(_omh_tree_fingerprint)
|
|
26
|
-
|
|
30
|
+
GATE_CMD=$(cat <<'OMH_GATE_CMD'
|
|
31
|
+
{{{testCommand}}}
|
|
32
|
+
OMH_GATE_CMD
|
|
33
|
+
)
|
|
34
|
+
if _omh_gate_cached commit-test-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
|
|
27
35
|
echo "oh-my-harness: tests already passed on this exact tree; skipping" >&2
|
|
28
36
|
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
29
37
|
exit 0
|
|
@@ -35,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
|
|
|
35
43
|
_emit_decision "block" "$REASON"
|
|
36
44
|
exit 0
|
|
37
45
|
fi
|
|
38
|
-
_omh_gate_record commit-test-gate "$FP"
|
|
46
|
+
_omh_gate_record commit-test-gate "$FP" "$GATE_CMD"
|
|
39
47
|
fi
|
|
40
48
|
exit 0`,
|
|
41
49
|
};
|
|
@@ -17,13 +17,21 @@ 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)
|
|
23
27
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
24
28
|
if _omh_cmd_matches "$COMMAND" git commit; then
|
|
25
29
|
FP=$(_omh_tree_fingerprint)
|
|
26
|
-
|
|
30
|
+
GATE_CMD=$(cat <<'OMH_GATE_CMD'
|
|
31
|
+
{{{typecheckCommand}}}
|
|
32
|
+
OMH_GATE_CMD
|
|
33
|
+
)
|
|
34
|
+
if _omh_gate_cached commit-typecheck-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
|
|
27
35
|
echo "oh-my-harness: typecheck already passed on this exact tree; skipping" >&2
|
|
28
36
|
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
29
37
|
exit 0
|
|
@@ -35,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
|
|
|
35
43
|
_emit_decision "block" "$REASON"
|
|
36
44
|
exit 0
|
|
37
45
|
fi
|
|
38
|
-
_omh_gate_record commit-typecheck-gate "$FP"
|
|
46
|
+
_omh_gate_record commit-typecheck-gate "$FP" "$GATE_CMD"
|
|
39
47
|
fi
|
|
40
48
|
exit 0`,
|
|
41
49
|
};
|
|
@@ -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
|
|
@@ -19,6 +19,8 @@ import { loopGuard } from "./loop-guard.js";
|
|
|
19
19
|
import { harnessGuard } from "./harness-guard.js";
|
|
20
20
|
import { noVerifyGuard } from "./no-verify-guard.js";
|
|
21
21
|
import { forcePushGuard } from "./force-push-guard.js";
|
|
22
|
+
import { stopTestGate } from "./stop-test-gate.js";
|
|
23
|
+
import { stopUncommittedWarn } from "./stop-uncommitted-warn.js";
|
|
22
24
|
export const builtinBlocks = [
|
|
23
25
|
branchGuard,
|
|
24
26
|
commitTestGate,
|
|
@@ -41,4 +43,6 @@ export const builtinBlocks = [
|
|
|
41
43
|
harnessGuard,
|
|
42
44
|
noVerifyGuard,
|
|
43
45
|
forcePushGuard,
|
|
46
|
+
stopTestGate,
|
|
47
|
+
stopUncommittedWarn,
|
|
44
48
|
];
|
|
@@ -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)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Stop-event gate (#117): when the agent is about to end its turn with a red
|
|
2
|
+
// test suite, hand the failure back so it keeps working instead of finishing
|
|
3
|
+
// on a broken tree. Two safety valves: Claude's stop_hook_active flag (a Stop
|
|
4
|
+
// hook that blocked once must not block the follow-up stop) and a per-session
|
|
5
|
+
// retry cap kept in .omh/state, which also covers runtimes without that flag.
|
|
6
|
+
// The tree-fingerprint cache shared with commit-test-gate means a suite that
|
|
7
|
+
// just passed on this exact tree is not run again.
|
|
8
|
+
export const stopTestGate = {
|
|
9
|
+
id: "stop-test-gate",
|
|
10
|
+
name: "Stop Test Gate",
|
|
11
|
+
description: "Runs tests when the agent tries to end its turn and sends it back to work while they fail",
|
|
12
|
+
category: "quality",
|
|
13
|
+
event: "Stop",
|
|
14
|
+
canBlock: true,
|
|
15
|
+
params: [
|
|
16
|
+
{ name: "testCommand", type: "string", description: "Test command to run before the turn may end", required: true },
|
|
17
|
+
{
|
|
18
|
+
name: "maxRetries",
|
|
19
|
+
type: "number",
|
|
20
|
+
description: "How many times per session the turn may be sent back before the gate gives up and lets it end",
|
|
21
|
+
default: 2,
|
|
22
|
+
required: false,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "cacheTtlSeconds",
|
|
26
|
+
type: "number",
|
|
27
|
+
description: "Skip the run when the working tree is unchanged since the last pass and that pass is younger than this many seconds (0 disables)",
|
|
28
|
+
default: 600,
|
|
29
|
+
required: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
tags: ["stop", "test", "quality", "gate"],
|
|
33
|
+
explain: {
|
|
34
|
+
allowOnce: "make the test command pass; after maxRetries send-backs in one session the gate lets the turn end anyway",
|
|
35
|
+
change: "harness.yaml > hooks > stop-test-gate: change testCommand, maxRetries or cacheTtlSeconds, or remove it, then omh sync",
|
|
36
|
+
},
|
|
37
|
+
template: `#!/bin/bash
|
|
38
|
+
set -euo pipefail
|
|
39
|
+
INPUT=$(cat)
|
|
40
|
+
# A Stop hook that already sent the agent back must not block the follow-up stop.
|
|
41
|
+
[[ "$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null)" == "true" ]] && exit 0
|
|
42
|
+
|
|
43
|
+
SESSION=$(echo "$INPUT" | jq -r '.session_id // "default"' 2>/dev/null | tr -c 'A-Za-z0-9_-' '_')
|
|
44
|
+
STATE_DIR="\${_OMH_STATE_DIR:-.omh/state}"
|
|
45
|
+
COUNT_FILE="$STATE_DIR/stop-test-gate-\${SESSION}.count"
|
|
46
|
+
COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
|
|
47
|
+
[[ "$COUNT" =~ ^[0-9]+$ ]] || COUNT=0
|
|
48
|
+
if [[ "$COUNT" -ge {{maxRetries}} ]]; then
|
|
49
|
+
_log_event "allow" "stop-test-gate: retry cap ({{maxRetries}}) reached for this session; letting the turn end"
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
FP=$(_omh_tree_fingerprint)
|
|
54
|
+
GATE_CMD=$(cat <<'OMH_GATE_CMD'
|
|
55
|
+
{{{testCommand}}}
|
|
56
|
+
OMH_GATE_CMD
|
|
57
|
+
)
|
|
58
|
+
if _omh_gate_cached stop-test-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
|
|
59
|
+
rm -f "$COUNT_FILE"
|
|
60
|
+
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
STATUS=0
|
|
65
|
+
set -o pipefail
|
|
66
|
+
ESC=$'\\x1b'
|
|
67
|
+
OUTPUT=$(NO_COLOR=1 FORCE_COLOR=0 {{{testCommand}}} 2>&1 | sed "s/\${ESC}\\[[0-9;]*[A-Za-z]//g") || STATUS=$?
|
|
68
|
+
if [[ "$STATUS" -ne 0 ]]; then
|
|
69
|
+
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
|
70
|
+
echo $((COUNT + 1)) > "$COUNT_FILE"
|
|
71
|
+
TAIL=$(printf '%s\\n' "$OUTPUT" | tail -n 15)
|
|
72
|
+
REASON="oh-my-harness: the test suite is failing; fix it before ending the turn (attempt $((COUNT + 1))/{{maxRetries}}):
|
|
73
|
+
$TAIL"
|
|
74
|
+
_log_event "block" "$REASON"
|
|
75
|
+
jq -cn --arg r "$REASON" '{decision:"block", reason:$r, hookSpecificOutput:{hookEventName:"Stop", decision:"block", reason:$r}}'
|
|
76
|
+
exit 0
|
|
77
|
+
fi
|
|
78
|
+
rm -f "$COUNT_FILE"
|
|
79
|
+
_omh_gate_record stop-test-gate "$FP" "$GATE_CMD"
|
|
80
|
+
exit 0`,
|
|
81
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Stop-event notice (#117): when the turn ends with uncommitted changes, say
|
|
2
|
+
// so as a system message. Never blocks; the point is that the human sees what
|
|
3
|
+
// was left behind instead of discovering it later.
|
|
4
|
+
export const stopUncommittedWarn = {
|
|
5
|
+
id: "stop-uncommitted-warn",
|
|
6
|
+
name: "Stop Uncommitted Warning",
|
|
7
|
+
description: "When the agent ends its turn with uncommitted changes, lists them as a system message (never blocks)",
|
|
8
|
+
category: "git",
|
|
9
|
+
event: "Stop",
|
|
10
|
+
canBlock: false,
|
|
11
|
+
params: [],
|
|
12
|
+
tags: ["stop", "git", "notice"],
|
|
13
|
+
template: `#!/bin/bash
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
INPUT=$(cat)
|
|
16
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
17
|
+
if ! CHANGES=$(git status --porcelain 2>&1); then
|
|
18
|
+
MSG="oh-my-harness: could not read git status, so uncommitted changes may be unreported: $(printf '%s' "$CHANGES" | head -n 3)"
|
|
19
|
+
_log_event "allow" "$MSG"
|
|
20
|
+
jq -cn --arg m "$MSG" '{systemMessage:$m}'
|
|
21
|
+
exit 0
|
|
22
|
+
fi
|
|
23
|
+
[[ -z "$CHANGES" ]] && exit 0
|
|
24
|
+
N=$(printf '%s\\n' "$CHANGES" | wc -l | tr -d ' ')
|
|
25
|
+
MSG="oh-my-harness: $N uncommitted change(s) left behind:
|
|
26
|
+
$(printf '%s\\n' "$CHANGES" | head -n 20)"
|
|
27
|
+
_log_event "allow" "$MSG"
|
|
28
|
+
jq -cn --arg m "$MSG" '{systemMessage:$m}'
|
|
29
|
+
exit 0`,
|
|
30
|
+
};
|
|
@@ -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,11 +19,20 @@ 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;
|
|
25
32
|
params: Record<string, unknown>;
|
|
26
33
|
mode?: "block" | "ask";
|
|
34
|
+
/** `omh modify` may never remove or weaken a locked entry (#118). */
|
|
35
|
+
locked?: boolean;
|
|
27
36
|
}
|
|
28
37
|
export declare const ParamDefinitionSchema: z.ZodObject<{
|
|
29
38
|
name: z.ZodString;
|
|
@@ -75,7 +84,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
|
|
|
75
84
|
tags: z.ZodArray<z.ZodString, "many">;
|
|
76
85
|
}, "strip", z.ZodTypeAny, {
|
|
77
86
|
id: string;
|
|
78
|
-
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "
|
|
87
|
+
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "Stop" | "SubagentStop" | "UserPromptSubmit" | "PreCompact" | "PostCompact" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
|
|
79
88
|
name: string;
|
|
80
89
|
params: {
|
|
81
90
|
name: string;
|
|
@@ -92,7 +101,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
|
|
|
92
101
|
matcher?: string | undefined;
|
|
93
102
|
}, {
|
|
94
103
|
id: string;
|
|
95
|
-
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "
|
|
104
|
+
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "Stop" | "SubagentStop" | "UserPromptSubmit" | "PreCompact" | "PostCompact" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
|
|
96
105
|
name: string;
|
|
97
106
|
params: {
|
|
98
107
|
name: string;
|
|
@@ -112,12 +121,16 @@ export declare const HookEntrySchema: z.ZodObject<{
|
|
|
112
121
|
block: z.ZodString;
|
|
113
122
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
114
123
|
mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
|
|
124
|
+
/** `omh modify` may never remove or weaken a locked entry (#118). */
|
|
125
|
+
locked: z.ZodOptional<z.ZodBoolean>;
|
|
115
126
|
}, "strip", z.ZodTypeAny, {
|
|
116
127
|
block: string;
|
|
117
128
|
params: Record<string, unknown>;
|
|
118
129
|
mode: "block" | "ask";
|
|
130
|
+
locked?: boolean | undefined;
|
|
119
131
|
}, {
|
|
120
132
|
block: string;
|
|
121
133
|
params?: Record<string, unknown> | undefined;
|
|
122
134
|
mode?: "block" | "ask" | undefined;
|
|
135
|
+
locked?: boolean | undefined;
|
|
123
136
|
}>;
|
package/dist/catalog/types.js
CHANGED
|
@@ -22,4 +22,6 @@ export const HookEntrySchema = z.object({
|
|
|
22
22
|
block: z.string(),
|
|
23
23
|
params: z.record(z.unknown()).default({}),
|
|
24
24
|
mode: z.enum(["block", "ask"]).default("block"),
|
|
25
|
+
/** `omh modify` may never remove or weaken a locked entry (#118). */
|
|
26
|
+
locked: z.boolean().optional(),
|
|
25
27
|
});
|
|
@@ -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
|
+
}>;
|