oh-my-harness 0.24.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 +20 -0
- package/dist/catalog/blocks/branch-guard.js +1 -1
- package/dist/catalog/blocks/index.js +4 -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/cli/commands/doctor.js +4 -0
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/test.js +2 -0
- package/dist/cli/deps-checker.d.ts +1 -0
- package/dist/cli/deps-checker.js +1 -1
- package/dist/cli/harness-tester.js +3 -1
- 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 +22 -0
- package/dist/core/harness-schema.d.ts +12 -0
- package/dist/core/harness-schema.js +4 -0
- package/dist/core/presets.d.ts +4 -0
- package/dist/core/presets.js +10 -1
- package/dist/generators/hooks.js +2 -0
- package/dist/nl/typesafe-chooser.d.ts +2 -0
- package/dist/nl/typesafe-chooser.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,24 @@ omh init "Next.js + FastAPI, TDD enforced, no auto PRs"
|
|
|
105
105
|
|
|
106
106
|
The always-on guards (harness self-protection, `--no-verify`, force-push) are added to every preset and every Jev result.
|
|
107
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
|
+
|
|
108
126
|
---
|
|
109
127
|
|
|
110
128
|
## 🧱 Building Block Catalog
|
|
@@ -122,6 +140,8 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
|
|
|
122
140
|
| 💥 `force-push-guard` | git | Blocks force pushes to protected branches (always on) |
|
|
123
141
|
| 🛑 `stop-test-gate` | quality | When the agent tries to end its turn with failing tests, sends it back (retry-capped) |
|
|
124
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) |
|
|
125
145
|
| 📁 `path-guard` | file-protection | Blocks writes to protected paths |
|
|
126
146
|
| 🔐 `lockfile-guard` | file-protection | Prevents manual lockfile edits |
|
|
127
147
|
| 🤫 `secret-file-guard` | security | Blocks edits to .env, credentials |
|
|
@@ -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
|
}
|
|
@@ -21,6 +21,8 @@ import { noVerifyGuard } from "./no-verify-guard.js";
|
|
|
21
21
|
import { forcePushGuard } from "./force-push-guard.js";
|
|
22
22
|
import { stopTestGate } from "./stop-test-gate.js";
|
|
23
23
|
import { stopUncommittedWarn } from "./stop-uncommitted-warn.js";
|
|
24
|
+
import { semanticRuleGuard } from "./semantic-rule-guard.js";
|
|
25
|
+
import { semanticDiffGate } from "./semantic-diff-gate.js";
|
|
24
26
|
export const builtinBlocks = [
|
|
25
27
|
branchGuard,
|
|
26
28
|
commitTestGate,
|
|
@@ -45,4 +47,6 @@ export const builtinBlocks = [
|
|
|
45
47
|
forcePushGuard,
|
|
46
48
|
stopTestGate,
|
|
47
49
|
stopUncommittedWarn,
|
|
50
|
+
semanticRuleGuard,
|
|
51
|
+
semanticDiffGate,
|
|
48
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
|
+
};
|
|
@@ -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);
|
|
@@ -15,6 +15,8 @@ export function formatCategoryName(category) {
|
|
|
15
15
|
"harness-guard": "Harness self-protection",
|
|
16
16
|
"no-verify-guard": "Git hook bypass guard",
|
|
17
17
|
"stop-test-gate": "Stop test gate",
|
|
18
|
+
"semantic-rule-guard": "Semantic rule guard (Jev)",
|
|
19
|
+
"semantic-diff-gate": "Semantic diff gate (jgrep)",
|
|
18
20
|
"force-push-guard": "Force push guard",
|
|
19
21
|
"branch-guard": "Branch guard",
|
|
20
22
|
"lockfile-guard": "Lockfile guard",
|
package/dist/cli/deps-checker.js
CHANGED
|
@@ -12,6 +12,7 @@ export async function simulateHook(hookPath, input, cwd) {
|
|
|
12
12
|
}, 5000);
|
|
13
13
|
const child = spawn("bash", [hookPath], {
|
|
14
14
|
cwd,
|
|
15
|
+
env: { ...process.env, OMH_DRY_RUN: "1" },
|
|
15
16
|
stdio: ["pipe", "pipe", "pipe"],
|
|
16
17
|
});
|
|
17
18
|
let stdout = "";
|
|
@@ -72,7 +73,8 @@ export async function getRegisteredHooks(projectDir) {
|
|
|
72
73
|
}
|
|
73
74
|
// 테스트 실행
|
|
74
75
|
export async function runTestCase(projectDir, testCase) {
|
|
75
|
-
|
|
76
|
+
// settings.json registers hooks as `bash '/abs/path.sh'`, so hookScript is usually absolute
|
|
77
|
+
const hookPath = path.isAbsolute(testCase.hookScript) ? testCase.hookScript : path.join(projectDir, testCase.hookScript);
|
|
76
78
|
try {
|
|
77
79
|
await fs.access(hookPath);
|
|
78
80
|
}
|
|
@@ -3,7 +3,7 @@ import chalk from "chalk";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import yaml from "js-yaml";
|
|
6
|
-
import { checkDependencies } from "../deps-checker.js";
|
|
6
|
+
import { checkDependencies, commandExists } from "../deps-checker.js";
|
|
7
7
|
import { checkReferencedTools } from "../tool-checker.js";
|
|
8
8
|
import { writeHarnessState } from "../commands/init.js";
|
|
9
9
|
import { generate } from "../../core/generator.js";
|
|
@@ -161,7 +161,7 @@ export async function runInitTUI(options) {
|
|
|
161
161
|
],
|
|
162
162
|
});
|
|
163
163
|
handleCancel(preset);
|
|
164
|
-
harnessConfig = buildPresetHarness(preset, projectFacts);
|
|
164
|
+
harnessConfig = buildPresetHarness(preset, projectFacts, {}, { jgrep: await commandExists("jgrep") });
|
|
165
165
|
p.note(formatConfigSummary(harnessConfig), `Preset: ${preset}`);
|
|
166
166
|
const confirmed = await p.confirm({ message: "Proceed with this configuration?", initialValue: true });
|
|
167
167
|
handleCancel(confirmed);
|
|
@@ -193,7 +193,7 @@ export async function runInitTUI(options) {
|
|
|
193
193
|
const catalogRegistry = await createDefaultRegistry();
|
|
194
194
|
if (jevKey) {
|
|
195
195
|
const result = await chooseWithJev({ description: description, facts: projectFacts, blocks: catalogRegistry.list() }, { apiKey: jevKey });
|
|
196
|
-
const applied = harnessFromChoices(result, projectFacts, { description: description });
|
|
196
|
+
const applied = harnessFromChoices(result, projectFacts, { description: description }, { jgrep: await commandExists("jgrep") });
|
|
197
197
|
const { skipped, ...rest } = applied;
|
|
198
198
|
harnessConfig = rest;
|
|
199
199
|
genSpinner.stop(`Jev chose strictness=${result.strictness} (${result.usage?.input_tokens ?? "?"} input tokens)`);
|
|
@@ -3,12 +3,6 @@ import type { MergedConfig } from "./merged-config.js";
|
|
|
3
3
|
import type { CatalogRegistry } from "../catalog/registry.js";
|
|
4
4
|
import type { HookEntry } from "../catalog/types.js";
|
|
5
5
|
export declare const ALWAYS_ON_GUARDS: readonly ["harness-guard", "no-verify-guard", "force-push-guard"];
|
|
6
|
-
/**
|
|
7
|
-
* The hook entries a harness really runs: enforcement-derived + explicit +
|
|
8
|
-
* loop-guard (when the loop is on) + the always-on guards, in the order the
|
|
9
|
-
* generator emits them. `omh test` and `omh stats` use this so they see the
|
|
10
|
-
* same set (QA: they used to miss the always-on guards).
|
|
11
|
-
*/
|
|
12
6
|
export declare function effectiveHookEntries(harness: HarnessConfig, registry: {
|
|
13
7
|
has(id: string): boolean;
|
|
14
8
|
}): HookEntry[];
|
|
@@ -91,9 +91,30 @@ function addLoopGuard(entries, harness) {
|
|
|
91
91
|
* generator emits them. `omh test` and `omh stats` use this so they see the
|
|
92
92
|
* same set (QA: they used to miss the always-on guards).
|
|
93
93
|
*/
|
|
94
|
+
// Rules can carry their own enforcement (#144, #145): `enforce: true` sends
|
|
95
|
+
// the rule to semantic-rule-guard on every tool call; `lint:` sends a change
|
|
96
|
+
// description to semantic-diff-gate before commits. The blocks are added only
|
|
97
|
+
// when such rules exist; an explicit entry keeps its own params and mode.
|
|
98
|
+
function addRuleDerivedGuards(entries, harness, registry) {
|
|
99
|
+
const enforced = harness.rules.filter((r) => r.enforce).map((r) => `${r.title}: ${r.content}`);
|
|
100
|
+
const lints = harness.rules.map((r) => r.lint).filter((l) => Boolean(l && l.trim()));
|
|
101
|
+
for (const [block, rules] of [["semantic-rule-guard", enforced], ["semantic-diff-gate", lints]]) {
|
|
102
|
+
if (rules.length === 0 || !registry.has(block))
|
|
103
|
+
continue;
|
|
104
|
+
const explicit = entries.find((h) => h.block === block);
|
|
105
|
+
if (!explicit) {
|
|
106
|
+
entries.push({ block, params: { rules: [...rules] }, mode: "block" });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// An explicit entry (the strict preset writes one) keeps its params and mode; rule-derived texts are appended.
|
|
110
|
+
const own = Array.isArray(explicit.params.rules) ? explicit.params.rules : [];
|
|
111
|
+
explicit.params = { ...explicit.params, rules: [...own, ...rules.filter((r) => !own.includes(r))] };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
94
114
|
export function effectiveHookEntries(harness, registry) {
|
|
95
115
|
const entries = mergeEnforcementAndHooks(harness);
|
|
96
116
|
addLoopGuard(entries, harness);
|
|
117
|
+
addRuleDerivedGuards(entries, harness, registry);
|
|
97
118
|
addAlwaysOnGuards(entries, registry);
|
|
98
119
|
return entries;
|
|
99
120
|
}
|
|
@@ -125,6 +146,7 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
|
|
|
125
146
|
}
|
|
126
147
|
// Resolve registry — use provided one or create the default
|
|
127
148
|
const resolvedRegistry = registry ?? (await createDefaultRegistry());
|
|
149
|
+
addRuleDerivedGuards(allHookEntries, harness, resolvedRegistry);
|
|
128
150
|
addAlwaysOnGuards(allHookEntries, resolvedRegistry);
|
|
129
151
|
const catalogResult = await convertHookEntries(allHookEntries, resolvedRegistry, projectDir ?? ".");
|
|
130
152
|
// Convert hooksConfig entries from catalog into HookDefinition format.
|
|
@@ -59,16 +59,24 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
59
59
|
title: z.ZodString;
|
|
60
60
|
content: z.ZodString;
|
|
61
61
|
priority: z.ZodDefault<z.ZodNumber>;
|
|
62
|
+
/** Judge every Bash/Edit/Write call against this rule with Jev (semantic-rule-guard, #144). */
|
|
63
|
+
enforce: z.ZodOptional<z.ZodBoolean>;
|
|
64
|
+
/** English description of a change that must not be committed; checked on the staged diff with jgrep (semantic-diff-gate, #145). */
|
|
65
|
+
lint: z.ZodOptional<z.ZodString>;
|
|
62
66
|
}, "strip", z.ZodTypeAny, {
|
|
63
67
|
id: string;
|
|
64
68
|
content: string;
|
|
65
69
|
title: string;
|
|
66
70
|
priority: number;
|
|
71
|
+
enforce?: boolean | undefined;
|
|
72
|
+
lint?: string | undefined;
|
|
67
73
|
}, {
|
|
68
74
|
id: string;
|
|
69
75
|
content: string;
|
|
70
76
|
title: string;
|
|
71
77
|
priority?: number | undefined;
|
|
78
|
+
enforce?: boolean | undefined;
|
|
79
|
+
lint?: string | undefined;
|
|
72
80
|
}>, "many">>;
|
|
73
81
|
enforcement: z.ZodDefault<z.ZodObject<{
|
|
74
82
|
preCommit: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
@@ -248,6 +256,8 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
248
256
|
content: string;
|
|
249
257
|
title: string;
|
|
250
258
|
priority: number;
|
|
259
|
+
enforce?: boolean | undefined;
|
|
260
|
+
lint?: string | undefined;
|
|
251
261
|
}[];
|
|
252
262
|
enforcement: {
|
|
253
263
|
blockedPaths: string[];
|
|
@@ -313,6 +323,8 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
313
323
|
content: string;
|
|
314
324
|
title: string;
|
|
315
325
|
priority?: number | undefined;
|
|
326
|
+
enforce?: boolean | undefined;
|
|
327
|
+
lint?: string | undefined;
|
|
316
328
|
}[] | undefined;
|
|
317
329
|
enforcement?: {
|
|
318
330
|
blockedPaths?: string[] | undefined;
|
|
@@ -40,6 +40,10 @@ export const HarnessConfigSchema = z.object({
|
|
|
40
40
|
title: z.string(),
|
|
41
41
|
content: z.string(),
|
|
42
42
|
priority: z.number().default(50),
|
|
43
|
+
/** Judge every Bash/Edit/Write call against this rule with Jev (semantic-rule-guard, #144). */
|
|
44
|
+
enforce: z.boolean().optional(),
|
|
45
|
+
/** English description of a change that must not be committed; checked on the staged diff with jgrep (semantic-diff-gate, #145). */
|
|
46
|
+
lint: z.string().optional(),
|
|
43
47
|
})).default([]),
|
|
44
48
|
// Enforcement hooks
|
|
45
49
|
enforcement: z.object({
|
package/dist/core/presets.d.ts
CHANGED
|
@@ -10,8 +10,12 @@ type Params = Record<string, unknown>;
|
|
|
10
10
|
* catalog defaults are enough.
|
|
11
11
|
*/
|
|
12
12
|
export declare function defaultParamsFor(blockId: string, facts?: ProjectFacts): Params | null;
|
|
13
|
+
/** Generic change descriptions the strict preset lints with jgrep when it is installed (#145). */
|
|
14
|
+
export declare const STRICT_LINTS: string[];
|
|
13
15
|
export declare function buildPresetHarness(preset: PresetName, facts?: ProjectFacts, meta?: {
|
|
14
16
|
name?: string;
|
|
15
17
|
description?: string;
|
|
18
|
+
}, tools?: {
|
|
19
|
+
jgrep?: boolean;
|
|
16
20
|
}): HarnessConfig;
|
|
17
21
|
export {};
|
package/dist/core/presets.js
CHANGED
|
@@ -98,7 +98,13 @@ const RULES = {
|
|
|
98
98
|
].join("\n"),
|
|
99
99
|
},
|
|
100
100
|
};
|
|
101
|
-
|
|
101
|
+
/** Generic change descriptions the strict preset lints with jgrep when it is installed (#145). */
|
|
102
|
+
export const STRICT_LINTS = [
|
|
103
|
+
"catches an error and silently ignores it",
|
|
104
|
+
"hardcodes a secret, token or password",
|
|
105
|
+
"disables or skips a test instead of fixing it",
|
|
106
|
+
];
|
|
107
|
+
export function buildPresetHarness(preset, facts, meta = {}, tools = {}) {
|
|
102
108
|
const hooks = [];
|
|
103
109
|
for (const block of HOOKS_BY_PRESET[preset]) {
|
|
104
110
|
const params = defaultParamsFor(block, facts);
|
|
@@ -106,6 +112,9 @@ export function buildPresetHarness(preset, facts, meta = {}) {
|
|
|
106
112
|
continue;
|
|
107
113
|
hooks.push({ block, params, mode: "block" });
|
|
108
114
|
}
|
|
115
|
+
if (preset === "strict" && tools.jgrep) {
|
|
116
|
+
hooks.push({ block: "semantic-diff-gate", params: { rules: [...STRICT_LINTS] }, mode: "block" });
|
|
117
|
+
}
|
|
109
118
|
const stacks = (facts?.languages ?? []).map((language, i) => ({
|
|
110
119
|
name: language,
|
|
111
120
|
framework: facts?.frameworks?.[i] ?? facts?.frameworks?.[0] ?? "none",
|
package/dist/generators/hooks.js
CHANGED
|
@@ -315,6 +315,8 @@ _OMH_EVENT="${event}"
|
|
|
315
315
|
_OMH_DECISION_MODE="${mode}"
|
|
316
316
|
_OMH_LOGGED=0
|
|
317
317
|
_log_event() {
|
|
318
|
+
# omh test runs hooks as dry runs: decisions are reported, never recorded.
|
|
319
|
+
if [ -n "\${OMH_DRY_RUN:-}" ]; then _OMH_LOGGED=1; return 0; fi
|
|
318
320
|
# Build the JSONL record entirely through jq so every string field is
|
|
319
321
|
# JSON-escaped (quotes, backslashes, newlines, unicode). The previous
|
|
320
322
|
# printf+%s approach corrupted the line whenever reason or any other
|
|
@@ -64,6 +64,8 @@ export declare function applyChoices(base: HarnessConfig, result: Pick<ChooserRe
|
|
|
64
64
|
/** Preset to start from, then the chooser's decisions on top. */
|
|
65
65
|
export declare function harnessFromChoices(result: Pick<ChooserResult, "strictness" | "blocks">, facts: ProjectFacts | undefined, meta: {
|
|
66
66
|
description?: string;
|
|
67
|
+
}, tools?: {
|
|
68
|
+
jgrep?: boolean;
|
|
67
69
|
}): AppliedHarness;
|
|
68
70
|
/** TYPESAFE_API_KEY from the environment, else from a .env file in the project directory. */
|
|
69
71
|
export declare function resolveTypesafeApiKey(projectDir: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
@@ -110,8 +110,8 @@ export function applyChoices(base, result, facts) {
|
|
|
110
110
|
return { ...base, hooks, skipped };
|
|
111
111
|
}
|
|
112
112
|
/** Preset to start from, then the chooser's decisions on top. */
|
|
113
|
-
export function harnessFromChoices(result, facts, meta) {
|
|
114
|
-
const base = buildPresetHarness(result.strictness, facts, meta);
|
|
113
|
+
export function harnessFromChoices(result, facts, meta, tools = {}) {
|
|
114
|
+
const base = buildPresetHarness(result.strictness, facts, meta, tools);
|
|
115
115
|
return applyChoices(base, result, facts);
|
|
116
116
|
}
|
|
117
117
|
/** TYPESAFE_API_KEY from the environment, else from a .env file in the project directory. */
|