oh-my-harness 0.23.0 → 0.25.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 +24 -0
- package/dist/catalog/blocks/branch-guard.js +1 -1
- package/dist/catalog/blocks/commit-test-gate.js +6 -2
- package/dist/catalog/blocks/commit-typecheck-gate.js +6 -2
- package/dist/catalog/blocks/index.js +8 -0
- package/dist/catalog/blocks/semantic-diff-gate.d.ts +2 -0
- package/dist/catalog/blocks/semantic-diff-gate.js +118 -0
- package/dist/catalog/blocks/semantic-rule-guard.d.ts +2 -0
- package/dist/catalog/blocks/semantic-rule-guard.js +95 -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/types.d.ts +8 -2
- package/dist/catalog/types.js +2 -0
- package/dist/cli/commands/doctor.js +4 -0
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/modify.d.ts +8 -0
- package/dist/cli/commands/modify.js +77 -0
- package/dist/cli/commands/test.js +3 -0
- package/dist/cli/deps-checker.d.ts +1 -0
- package/dist/cli/deps-checker.js +1 -1
- package/dist/cli/harness-tester.js +13 -1
- package/dist/cli/index.js +12 -0
- package/dist/cli/tui/init-flow.js +3 -3
- package/dist/core/harness-converter-v2.d.ts +0 -6
- package/dist/core/harness-converter-v2.js +25 -1
- package/dist/core/harness-schema.d.ts +17 -0
- package/dist/core/harness-schema.js +4 -0
- package/dist/core/merged-config.d.ts +2 -0
- package/dist/core/presets.d.ts +4 -0
- package/dist/core/presets.js +12 -1
- package/dist/generators/codex-config.js +1 -1
- package/dist/generators/hooks.d.ts +2 -2
- package/dist/generators/hooks.js +13 -3
- package/dist/generators/pi-extension.js +1 -1
- package/dist/nl/typesafe-chooser.d.ts +3 -1
- package/dist/nl/typesafe-chooser.js +3 -2
- 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
|
@@ -99,11 +99,30 @@ omh init "Next.js + FastAPI, TDD enforced, no auto PRs"
|
|
|
99
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
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
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.
|
|
102
103
|
- Interactive `omh init` offers both: "Describe your project (Jev picks the blocks)" and "Use a preset (no AI)".
|
|
103
104
|
- Descriptions in English work best; other languages are handled but with lower confidence, so include the specifics.
|
|
104
105
|
|
|
105
106
|
The always-on guards (harness self-protection, `--no-verify`, force-push) are added to every preset and every Jev result.
|
|
106
107
|
|
|
108
|
+
### Rules that enforce themselves
|
|
109
|
+
|
|
110
|
+
Two fields on a rule in `harness.yaml` turn prose into a check:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
rules:
|
|
114
|
+
- id: no-deps
|
|
115
|
+
title: No new dependencies
|
|
116
|
+
content: Do not add npm dependencies without asking.
|
|
117
|
+
enforce: true # every Bash/Edit/Write call is judged against this rule by Jev
|
|
118
|
+
- id: no-secrets
|
|
119
|
+
title: No secrets in code
|
|
120
|
+
content: Never commit tokens or passwords.
|
|
121
|
+
lint: hardcodes a secret, token or password # the staged diff is linted with jgrep before commit
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`enforce: true` adds `semantic-rule-guard`: one Jev question per enforced rule per tool call, block at p ≥ 0.9, ask between 0.6 and 0.9, allow below; without a key, or on an API error, it allows and logs `skipped`. `lint:` adds `semantic-diff-gate`, which runs `jgrep --diff --staged "<description>"` and blocks the commit on a hit with `file:line`. When the same command also stages (`git add … && git commit`, `git commit -a`), the gate previews the index that command would build, so nothing slips through because it was not staged yet. jgrep is optional: `npm i -g jevgrep && jgrep init`; the `strict` preset adds three generic lints when `omh init` finds it. Both layers are additive and never relax a deterministic guard.
|
|
125
|
+
|
|
107
126
|
---
|
|
108
127
|
|
|
109
128
|
## 🧱 Building Block Catalog
|
|
@@ -119,6 +138,10 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
|
|
|
119
138
|
| 🪝 `harness-guard` | security | Blocks shell writes to the harness's own hooks and config (always on) |
|
|
120
139
|
| 🚫 `no-verify-guard` | git | Blocks `--no-verify` / `-n` / hooksPath overrides on commit and push (always on) |
|
|
121
140
|
| 💥 `force-push-guard` | git | Blocks force pushes to protected branches (always on) |
|
|
141
|
+
| 🛑 `stop-test-gate` | quality | When the agent tries to end its turn with failing tests, sends it back (retry-capped) |
|
|
142
|
+
| 📝 `stop-uncommitted-warn` | git | Lists uncommitted changes as a system message when the turn ends |
|
|
143
|
+
| 🧠 `semantic-rule-guard` | security | Rules marked `enforce: true` are judged by Jev on every Bash/Edit/Write call |
|
|
144
|
+
| 🔎 `semantic-diff-gate` | quality | Lints the staged diff with [jgrep](https://github.com/kyu1204/jgrep) against `lint:` rule descriptions before commit (optional) |
|
|
122
145
|
| 📁 `path-guard` | file-protection | Blocks writes to protected paths |
|
|
123
146
|
| 🔐 `lockfile-guard` | file-protection | Prevents manual lockfile edits |
|
|
124
147
|
| 🤫 `secret-file-guard` | security | Blocks edits to .env, credentials |
|
|
@@ -192,6 +215,7 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
|
|
|
192
215
|
omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
|
|
193
216
|
omh init --preset strict # No provider: minimal | safe | strict
|
|
194
217
|
omh explain # Why were the last tool calls blocked, and what to do about it
|
|
218
|
+
omh modify "no auto PRs, TDD guard asks" # Edit harness.yaml from a sentence (Jev decides per block; locked: true entries stay)
|
|
195
219
|
omh init # Interactive TUI (import existing harness.yaml)
|
|
196
220
|
|
|
197
221
|
# 📋 Catalog
|
|
@@ -33,7 +33,7 @@ if _omh_cmd_matches "$COMMAND" git commit || _omh_cmd_matches "$COMMAND" git pus
|
|
|
33
33
|
if ($i != "git") next
|
|
34
34
|
c = cwd; ex = 0; j = i + 1
|
|
35
35
|
while (j <= NF && $j ~ /^-/) {
|
|
36
|
-
if ($j == "-C") { if ($(j + 1) ~
|
|
36
|
+
if ($j == "-C") { if ($(j + 1) ~ /^(\\/|~\\/)/) ex = 1; c = omh_cd(c, $(j + 1), home); j++ }
|
|
37
37
|
else if ($j == "-c") j++
|
|
38
38
|
j++
|
|
39
39
|
}
|
|
@@ -27,7 +27,11 @@ INPUT=$(cat)
|
|
|
27
27
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
28
28
|
if _omh_cmd_matches "$COMMAND" git commit; then
|
|
29
29
|
FP=$(_omh_tree_fingerprint)
|
|
30
|
-
|
|
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
|
|
31
35
|
echo "oh-my-harness: tests already passed on this exact tree; skipping" >&2
|
|
32
36
|
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
33
37
|
exit 0
|
|
@@ -39,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
|
|
|
39
43
|
_emit_decision "block" "$REASON"
|
|
40
44
|
exit 0
|
|
41
45
|
fi
|
|
42
|
-
_omh_gate_record commit-test-gate "$FP"
|
|
46
|
+
_omh_gate_record commit-test-gate "$FP" "$GATE_CMD"
|
|
43
47
|
fi
|
|
44
48
|
exit 0`,
|
|
45
49
|
};
|
|
@@ -27,7 +27,11 @@ INPUT=$(cat)
|
|
|
27
27
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
28
28
|
if _omh_cmd_matches "$COMMAND" git commit; then
|
|
29
29
|
FP=$(_omh_tree_fingerprint)
|
|
30
|
-
|
|
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
|
|
31
35
|
echo "oh-my-harness: typecheck already passed on this exact tree; skipping" >&2
|
|
32
36
|
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
33
37
|
exit 0
|
|
@@ -39,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
|
|
|
39
43
|
_emit_decision "block" "$REASON"
|
|
40
44
|
exit 0
|
|
41
45
|
fi
|
|
42
|
-
_omh_gate_record commit-typecheck-gate "$FP"
|
|
46
|
+
_omh_gate_record commit-typecheck-gate "$FP" "$GATE_CMD"
|
|
43
47
|
fi
|
|
44
48
|
exit 0`,
|
|
45
49
|
};
|
|
@@ -19,6 +19,10 @@ 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";
|
|
24
|
+
import { semanticRuleGuard } from "./semantic-rule-guard.js";
|
|
25
|
+
import { semanticDiffGate } from "./semantic-diff-gate.js";
|
|
22
26
|
export const builtinBlocks = [
|
|
23
27
|
branchGuard,
|
|
24
28
|
commitTestGate,
|
|
@@ -41,4 +45,8 @@ export const builtinBlocks = [
|
|
|
41
45
|
harnessGuard,
|
|
42
46
|
noVerifyGuard,
|
|
43
47
|
forcePushGuard,
|
|
48
|
+
stopTestGate,
|
|
49
|
+
stopUncommittedWarn,
|
|
50
|
+
semanticRuleGuard,
|
|
51
|
+
semanticDiffGate,
|
|
44
52
|
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Semantic lint of the staged diff before a commit (#145), powered by jgrep
|
|
2
|
+
// (https://github.com/kyu1204/jgrep): `jgrep --json --diff --staged "<rule>"`
|
|
3
|
+
// asks Jev, hunk by hunk, whether the change does what the rule describes.
|
|
4
|
+
// Any hit blocks the commit with file:line ranges. jgrep is optional by
|
|
5
|
+
// construction: it is called through PATH, never bundled; when it is not
|
|
6
|
+
// installed (or fails) the gate allows and logs why, so a harness without
|
|
7
|
+
// jgrep behaves exactly as before.
|
|
8
|
+
export const semanticDiffGate = {
|
|
9
|
+
id: "semantic-diff-gate",
|
|
10
|
+
name: "Semantic Diff Gate",
|
|
11
|
+
description: "Before git commit, lints the staged diff with jgrep against rule descriptions and blocks on a hit (needs jgrep on PATH)",
|
|
12
|
+
category: "quality",
|
|
13
|
+
event: "PreToolUse",
|
|
14
|
+
matcher: "Bash",
|
|
15
|
+
canBlock: true,
|
|
16
|
+
params: [
|
|
17
|
+
{ name: "rules", type: "string[]", description: "English descriptions of changes that must not be committed (filled from rules with a lint: field)", required: true },
|
|
18
|
+
{ name: "threshold", type: "number", description: "jgrep probability threshold for a hit", default: 0.85, required: false },
|
|
19
|
+
{ name: "jgrep", type: "string", description: "jgrep command", default: "jgrep", required: false },
|
|
20
|
+
],
|
|
21
|
+
tags: ["semantic", "jev", "jgrep", "diff", "lint", "gate"],
|
|
22
|
+
explain: {
|
|
23
|
+
allowOnce: "change the staged hunks jgrep pointed at (file:line in the reason), or unstage them",
|
|
24
|
+
change: "harness.yaml > rules: drop or edit the lint: description; threshold lives under hooks > semantic-diff-gate; uninstalling jgrep disables the gate, then omh sync",
|
|
25
|
+
},
|
|
26
|
+
template: `#!/bin/bash
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
INPUT=$(cat)
|
|
29
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
30
|
+
[[ -z "$COMMAND" ]] && exit 0
|
|
31
|
+
_omh_cmd_matches "$COMMAND" git commit || exit 0
|
|
32
|
+
|
|
33
|
+
JGREP='{{{jgrep}}}'
|
|
34
|
+
if ! command -v "$JGREP" >/dev/null 2>&1; then
|
|
35
|
+
_log_event "allow" "skipped: jgrep not found (npm i -g jevgrep && jgrep init to enable the semantic diff gate)"
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null) || { _log_event "allow" "skipped: not a git repository"; exit 0; }
|
|
39
|
+
|
|
40
|
+
# The agent usually stages and commits in one call, so at this point the index
|
|
41
|
+
# does not hold what the commit will contain. Preview it in a temporary index:
|
|
42
|
+
# copy the real one, replay every "git add ..." from the command (and
|
|
43
|
+
# "git add -u" for commit -a/--all), then let jgrep read that index through
|
|
44
|
+
# GIT_INDEX_FILE. The real index is never touched.
|
|
45
|
+
TMPIDX=$(mktemp)
|
|
46
|
+
if [[ -f "$GIT_DIR_PATH/index" ]]; then cp "$GIT_DIR_PATH/index" "$TMPIDX"; else rm -f "$TMPIDX"; fi
|
|
47
|
+
export GIT_INDEX_FILE="$TMPIDX"
|
|
48
|
+
# WIDEN=1 means the replay cannot be trusted (cd/pushd in the command, or a
|
|
49
|
+
# git add that fails here): stage every change instead, so nothing that
|
|
50
|
+
# the real command would stage is missed. Over-approximating can block on
|
|
51
|
+
# an unrelated dirty file; missing a file would let a secret through.
|
|
52
|
+
WIDEN=0
|
|
53
|
+
while IFS= read -r ADD_LINE; do
|
|
54
|
+
[[ -z "$ADD_LINE" ]] && continue
|
|
55
|
+
if [[ "$ADD_LINE" == "__widen__" ]]; then WIDEN=1; continue; fi
|
|
56
|
+
IFS=$'\\t' read -r -a ADD_ARGS <<< "$ADD_LINE"
|
|
57
|
+
git "\${ADD_ARGS[@]}" >/dev/null 2>&1 || WIDEN=1
|
|
58
|
+
done < <(_omh_simple_commands "$COMMAND" | awk -F '\\t' '
|
|
59
|
+
{ i = 1
|
|
60
|
+
while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
|
|
61
|
+
if (i > NF) next
|
|
62
|
+
if ($i == "cd" || $i == "pushd" || $i == "popd") { print "__widen__"; next }
|
|
63
|
+
if ($i != "git") next
|
|
64
|
+
i++; cdir = ""
|
|
65
|
+
while (i <= NF && $i ~ /^-/) { if ($i == "-c") i++; else if ($i == "-C") { i++; cdir = $i }; i++ }
|
|
66
|
+
if (i > NF) next
|
|
67
|
+
pre = (cdir == "" ? "" : "-C\\t" cdir "\\t")
|
|
68
|
+
if ($i == "add") {
|
|
69
|
+
line = ""
|
|
70
|
+
for (j = i + 1; j <= NF; j++) {
|
|
71
|
+
if ($j ~ /^-(p|i|e|-patch|-interactive|-edit)$/) next
|
|
72
|
+
line = line (line == "" ? "" : "\\t") $j
|
|
73
|
+
}
|
|
74
|
+
if (line != "") print pre "add\\t" line
|
|
75
|
+
} else if ($i == "commit") {
|
|
76
|
+
for (j = i + 1; j <= NF; j++) if ($j == "--all" || ($j ~ /^-[A-Za-z]+$/ && $j ~ /a/)) { print pre "add\\t-u"; break }
|
|
77
|
+
} }')
|
|
78
|
+
if [[ "$WIDEN" == "1" ]]; then git add -A >/dev/null 2>&1 || true; fi
|
|
79
|
+
_omh_sdg_done() { rm -f "$TMPIDX"; }
|
|
80
|
+
|
|
81
|
+
RULES_RAW=$(cat <<'OMH_RULES'
|
|
82
|
+
{{#each rules}}{{{this}}}
|
|
83
|
+
OMH_RULES_SEP
|
|
84
|
+
{{/each}}
|
|
85
|
+
OMH_RULES
|
|
86
|
+
)
|
|
87
|
+
HITS=""
|
|
88
|
+
N=0
|
|
89
|
+
ERR=$(mktemp)
|
|
90
|
+
while IFS= read -r RULE; do
|
|
91
|
+
[[ -z "$RULE" ]] && continue
|
|
92
|
+
N=$((N + 1))
|
|
93
|
+
STATUS=0
|
|
94
|
+
OUT=$("$JGREP" --json -t {{threshold}} --diff --staged "$RULE" 2>"$ERR") || STATUS=$?
|
|
95
|
+
case "$STATUS" in
|
|
96
|
+
0)
|
|
97
|
+
LINES=$(printf '%s' "$OUT" | jq -r '.[]? | " \\(.file):\\(.start)-\\(.end) (p=\\(.p))"' 2>/dev/null || true)
|
|
98
|
+
[[ -n "$LINES" ]] && HITS+="rule \\"$RULE\\":
|
|
99
|
+
$LINES
|
|
100
|
+
" ;;
|
|
101
|
+
1) ;;
|
|
102
|
+
*)
|
|
103
|
+
_log_event "allow" "jgrep failed (exit $STATUS): $(head -c 200 "$ERR" | tr '\\n' ' ')"
|
|
104
|
+
rm -f "$ERR"; _omh_sdg_done; exit 0 ;;
|
|
105
|
+
esac
|
|
106
|
+
done < <(printf '%s' "$RULES_RAW" | jq -Rs -r 'rtrimstr("\\nOMH_RULES_SEP") | split("\\nOMH_RULES_SEP\\n") | map(select(. != "")) | .[]')
|
|
107
|
+
rm -f "$ERR"; _omh_sdg_done
|
|
108
|
+
|
|
109
|
+
if [[ -n "$HITS" ]]; then
|
|
110
|
+
REASON="oh-my-harness: the staged diff matches a lint rule; fix these hunks before committing:
|
|
111
|
+
$HITS"
|
|
112
|
+
_log_event "block" "$REASON"
|
|
113
|
+
_emit_decision "block" "$REASON"
|
|
114
|
+
exit 0
|
|
115
|
+
fi
|
|
116
|
+
_log_event "allow" "no lint hits ($N rules)"
|
|
117
|
+
exit 0`,
|
|
118
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Rules become enforceable (#144). For every Bash / Edit / Write call, ask
|
|
2
|
+
// Jev one yes/no question per enforced rule ("does this tool call violate
|
|
3
|
+
// the rule?") in a single fan-out request and act on the calibrated
|
|
4
|
+
// probability: block at or above blockAbove, ask in the middle band (on
|
|
5
|
+
// runtimes with ask; elsewhere allow and log a warning), allow below.
|
|
6
|
+
// This layer is additive: no key, no curl, an API error or a timeout all
|
|
7
|
+
// mean "allow and log skipped". It never relaxes a deterministic guard.
|
|
8
|
+
// State sent to Jev is the tool input only (command, path, truncated text).
|
|
9
|
+
export const semanticRuleGuard = {
|
|
10
|
+
id: "semantic-rule-guard",
|
|
11
|
+
name: "Semantic Rule Guard",
|
|
12
|
+
description: "Judges every Bash/Edit/Write call against your enforced rules with Jev and blocks clear violations",
|
|
13
|
+
category: "security",
|
|
14
|
+
event: "PreToolUse",
|
|
15
|
+
matcher: "Bash|Edit|Write|MultiEdit",
|
|
16
|
+
canBlock: true,
|
|
17
|
+
params: [
|
|
18
|
+
{ name: "rules", type: "string[]", description: "Rule statements to enforce (filled from rules marked enforce: true)", required: true },
|
|
19
|
+
{ name: "blockAbove", type: "number", description: "Block when the violation probability is at or above this", default: 0.9, required: false },
|
|
20
|
+
{ name: "askAbove", type: "number", description: "Ask for confirmation when the probability is at or above this (below blockAbove)", default: 0.6, required: false },
|
|
21
|
+
],
|
|
22
|
+
tags: ["semantic", "jev", "rules", "guard"],
|
|
23
|
+
explain: {
|
|
24
|
+
allowOnce: "rewrite the call so it clearly does not do what the rule forbids, or answer the ask prompt",
|
|
25
|
+
change: "harness.yaml > rules: remove enforce: true from the rule, or edit its text; thresholds live under hooks > semantic-rule-guard (blockAbove / askAbove), then omh sync",
|
|
26
|
+
},
|
|
27
|
+
template: `#!/bin/bash
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
INPUT=$(cat)
|
|
30
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
31
|
+
case "$TOOL" in Bash|Edit|Write|MultiEdit) ;; *) exit 0 ;; esac
|
|
32
|
+
|
|
33
|
+
KEY="\${TYPESAFE_API_KEY:-}"
|
|
34
|
+
if [[ -z "$KEY" && -f "$_OMH_PROJECT_ROOT/.env" ]]; then
|
|
35
|
+
KEY=$(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?TYPESAFE_API_KEY[[:space:]]*=[[:space:]]*"?([^"#[:space:]]+)"?.*/\\2/p' "$_OMH_PROJECT_ROOT/.env" | head -n 1)
|
|
36
|
+
fi
|
|
37
|
+
if [[ -z "$KEY" ]]; then _log_event "allow" "skipped: no TYPESAFE_API_KEY"; exit 0; fi
|
|
38
|
+
command -v curl >/dev/null 2>&1 || { _log_event "allow" "skipped: curl not found"; exit 0; }
|
|
39
|
+
|
|
40
|
+
# Rules arrive through a quoted heredoc so any characters survive; one rule per separator.
|
|
41
|
+
RULES_RAW=$(cat <<'OMH_RULES'
|
|
42
|
+
{{#each rules}}{{{this}}}
|
|
43
|
+
OMH_RULES_SEP
|
|
44
|
+
{{/each}}
|
|
45
|
+
OMH_RULES
|
|
46
|
+
)
|
|
47
|
+
RULES_JSON=$(printf '%s' "$RULES_RAW" | jq -Rs 'rtrimstr("\\nOMH_RULES_SEP") | split("\\nOMH_RULES_SEP\\n") | map(select(. != ""))')
|
|
48
|
+
[[ "$(echo "$RULES_JSON" | jq 'length')" -gt 0 ]] || exit 0
|
|
49
|
+
|
|
50
|
+
STATE=$(echo "$INPUT" | jq -c '{
|
|
51
|
+
tool: .tool_name,
|
|
52
|
+
command: .tool_input.command,
|
|
53
|
+
description: .tool_input.description,
|
|
54
|
+
file_path: .tool_input.file_path,
|
|
55
|
+
old_string: ((.tool_input.old_string // "") | .[0:2000]),
|
|
56
|
+
new_string: ((.tool_input.new_string // "") | .[0:4000]),
|
|
57
|
+
content: ((.tool_input.content // "") | .[0:4000])
|
|
58
|
+
} | with_entries(select(.value != null and .value != ""))')
|
|
59
|
+
BODY=$(jq -cn --argjson state "$STATE" --argjson rules "$RULES_JSON" '{
|
|
60
|
+
model: "jev-latest", state: $state,
|
|
61
|
+
questions: ($rules | to_entries | map({
|
|
62
|
+
key: ("rule:" + ((.key + 1) | tostring)),
|
|
63
|
+
value: {type: "noul", instructions: ("Does this tool call violate the following project rule? Rule: " + .value + " Answer yes only if the call clearly does what the rule forbids.")}
|
|
64
|
+
}) | from_entries)}')
|
|
65
|
+
|
|
66
|
+
RESP=$(curl -sS -m 10 -X POST "\${OMH_TYPESAFE_ENDPOINT:-https://api.typesafe.ai/v1/systemone}" \\
|
|
67
|
+
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" --data-binary "$BODY" -w '\\n%{http_code}' 2>/dev/null) || RESP=$'\\n000'
|
|
68
|
+
CODE="\${RESP##*$'\\n'}"; RESP="\${RESP%$'\\n'*}"
|
|
69
|
+
if [[ "$CODE" != "200" ]]; then _log_event "allow" "skipped: TypeSafe API $CODE"; exit 0; fi
|
|
70
|
+
|
|
71
|
+
TOP=$(echo "$RESP" | jq -r '[.answers // {} | to_entries[] | select(.value.type == "noul") | {i: (.key | ltrimstr("rule:") | tonumber), p: .value.noul}] | max_by(.p) // empty | "\\(.i) \\(.p)"')
|
|
72
|
+
[[ -z "$TOP" ]] && { _log_event "allow" "skipped: no answers"; exit 0; }
|
|
73
|
+
IDX="\${TOP%% *}"; P="\${TOP#* }"
|
|
74
|
+
RULE=$(echo "$RULES_JSON" | jq -r --argjson i "$IDX" '.[$i - 1]')
|
|
75
|
+
PSHORT=$(printf '%.2f' "$P")
|
|
76
|
+
BLOCK_AT={{blockAbove}}
|
|
77
|
+
ASK_AT={{askAbove}}
|
|
78
|
+
if awk -v p="$P" -v t="$BLOCK_AT" 'BEGIN { exit !(p >= t) }'; then
|
|
79
|
+
REASON="oh-my-harness: this call violates an enforced rule (p=$PSHORT): $RULE"
|
|
80
|
+
_log_event "block" "$REASON"
|
|
81
|
+
_emit_decision "block" "$REASON"
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
if awk -v p="$P" -v t="$ASK_AT" 'BEGIN { exit !(p >= t) }'; then
|
|
85
|
+
REASON="oh-my-harness: this call may violate an enforced rule (p=$PSHORT): $RULE"
|
|
86
|
+
if printf '%s' "$INPUT" | jq -e 'has("transcript_path")' >/dev/null 2>&1; then
|
|
87
|
+
_log_event "allow" "ask: $REASON"
|
|
88
|
+
jq -cn --arg reason "$REASON" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$reason}}'
|
|
89
|
+
else
|
|
90
|
+
_log_event "allow" "warn: $REASON"
|
|
91
|
+
fi
|
|
92
|
+
exit 0
|
|
93
|
+
fi
|
|
94
|
+
exit 0`,
|
|
95
|
+
};
|
|
@@ -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
|
+
};
|
package/dist/catalog/types.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface HookEntry {
|
|
|
31
31
|
block: string;
|
|
32
32
|
params: Record<string, unknown>;
|
|
33
33
|
mode?: "block" | "ask";
|
|
34
|
+
/** `omh modify` may never remove or weaken a locked entry (#118). */
|
|
35
|
+
locked?: boolean;
|
|
34
36
|
}
|
|
35
37
|
export declare const ParamDefinitionSchema: z.ZodObject<{
|
|
36
38
|
name: z.ZodString;
|
|
@@ -82,7 +84,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
|
|
|
82
84
|
tags: z.ZodArray<z.ZodString, "many">;
|
|
83
85
|
}, "strip", z.ZodTypeAny, {
|
|
84
86
|
id: string;
|
|
85
|
-
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";
|
|
86
88
|
name: string;
|
|
87
89
|
params: {
|
|
88
90
|
name: string;
|
|
@@ -99,7 +101,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
|
|
|
99
101
|
matcher?: string | undefined;
|
|
100
102
|
}, {
|
|
101
103
|
id: string;
|
|
102
|
-
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";
|
|
103
105
|
name: string;
|
|
104
106
|
params: {
|
|
105
107
|
name: string;
|
|
@@ -119,12 +121,16 @@ export declare const HookEntrySchema: z.ZodObject<{
|
|
|
119
121
|
block: z.ZodString;
|
|
120
122
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
121
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>;
|
|
122
126
|
}, "strip", z.ZodTypeAny, {
|
|
123
127
|
block: string;
|
|
124
128
|
params: Record<string, unknown>;
|
|
125
129
|
mode: "block" | "ask";
|
|
130
|
+
locked?: boolean | undefined;
|
|
126
131
|
}, {
|
|
127
132
|
block: string;
|
|
128
133
|
params?: Record<string, unknown> | undefined;
|
|
129
134
|
mode?: "block" | "ask" | undefined;
|
|
135
|
+
locked?: boolean | undefined;
|
|
130
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
|
});
|
|
@@ -5,6 +5,7 @@ import { OMH_HOOKS_DIR } from "../../utils/paths.js";
|
|
|
5
5
|
import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
|
|
6
6
|
import { loadProviderConfig } from "../../nl/config-store.js";
|
|
7
7
|
import { resolveTypesafeApiKey } from "../../nl/typesafe-chooser.js";
|
|
8
|
+
import { commandExists } from "../deps-checker.js";
|
|
8
9
|
export async function doctorCommand(options = {}) {
|
|
9
10
|
const projectDir = options.projectDir ?? process.cwd();
|
|
10
11
|
const messages = [];
|
|
@@ -185,6 +186,9 @@ async function checkProviderConfig(messages, projectDir) {
|
|
|
185
186
|
const jev = Boolean(resolveTypesafeApiKey(projectDir));
|
|
186
187
|
if (jev) {
|
|
187
188
|
messages.push("INFO: Chooser: TypeSafe Jev (TYPESAFE_API_KEY found) — `omh init \"...\"` picks catalog blocks with it; no LLM provider needed.");
|
|
189
|
+
if (!(await commandExists("jgrep"))) {
|
|
190
|
+
messages.push("INFO: jgrep not found — `npm i -g jevgrep && jgrep init` enables semantic-diff-gate (strict preset, rules with lint:).");
|
|
191
|
+
}
|
|
188
192
|
}
|
|
189
193
|
const config = await loadProviderConfig();
|
|
190
194
|
if (!config) {
|
|
@@ -5,6 +5,7 @@ import { generate } from "../../core/generator.js";
|
|
|
5
5
|
import { generateHarnessConfig, providerConfigFromEnv } from "../../nl/parse-intent.js";
|
|
6
6
|
import { hasProviderConfig } from "../../nl/config-store.js";
|
|
7
7
|
import { buildPresetHarness, isPresetName, PRESET_NAMES } from "../../core/presets.js";
|
|
8
|
+
import { commandExists } from "../deps-checker.js";
|
|
8
9
|
import { chooseWithJev, harnessFromChoices, resolveTypesafeApiKey, TYPESAFE_MODEL } from "../../nl/typesafe-chooser.js";
|
|
9
10
|
import { detectProject } from "../../detector/project-detector.js";
|
|
10
11
|
import { harnessToMergedConfigV2 } from "../../core/harness-converter-v2.js";
|
|
@@ -93,9 +94,10 @@ export async function initWithNL(projectDir, options) {
|
|
|
93
94
|
const typesafeKey = resolveTypesafeApiKey(projectDir);
|
|
94
95
|
const registryBlocks = registry.list();
|
|
95
96
|
let harness;
|
|
97
|
+
const jgrep = await commandExists("jgrep");
|
|
96
98
|
if (preset) {
|
|
97
|
-
harness = buildPresetHarness(preset, facts, { description: description || undefined });
|
|
98
|
-
console.log(`preset: ${preset}`);
|
|
99
|
+
harness = buildPresetHarness(preset, facts, { description: description || undefined }, { jgrep });
|
|
100
|
+
console.log(`preset: ${preset}${preset === "strict" ? (jgrep ? " (jgrep found: semantic-diff-gate enabled)" : " (install jgrep to add the semantic diff gate: npm i -g jevgrep)") : ""}`);
|
|
99
101
|
}
|
|
100
102
|
else if (options.nlRunner) {
|
|
101
103
|
harness = await generateHarnessConfig(description, options.nlRunner, catalogBlocks, facts);
|
|
@@ -103,7 +105,7 @@ export async function initWithNL(projectDir, options) {
|
|
|
103
105
|
else if (typesafeKey && description) {
|
|
104
106
|
try {
|
|
105
107
|
const result = await chooseWithJev({ description, facts, blocks: registryBlocks }, { apiKey: typesafeKey });
|
|
106
|
-
const applied = harnessFromChoices(result, facts, { description });
|
|
108
|
+
const applied = harnessFromChoices(result, facts, { description }, { jgrep });
|
|
107
109
|
const on = [...result.blocks].filter(([, v]) => v === "on").map(([k]) => k);
|
|
108
110
|
const off = [...result.blocks].filter(([, v]) => v === "off").map(([k]) => k);
|
|
109
111
|
const undecided = [...result.blocks].filter(([, v]) => v === "undecided").map(([k]) => k);
|