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.
Files changed (41) hide show
  1. package/README.md +24 -0
  2. package/dist/catalog/blocks/branch-guard.js +1 -1
  3. package/dist/catalog/blocks/commit-test-gate.js +6 -2
  4. package/dist/catalog/blocks/commit-typecheck-gate.js +6 -2
  5. package/dist/catalog/blocks/index.js +8 -0
  6. package/dist/catalog/blocks/semantic-diff-gate.d.ts +2 -0
  7. package/dist/catalog/blocks/semantic-diff-gate.js +118 -0
  8. package/dist/catalog/blocks/semantic-rule-guard.d.ts +2 -0
  9. package/dist/catalog/blocks/semantic-rule-guard.js +95 -0
  10. package/dist/catalog/blocks/stop-test-gate.d.ts +2 -0
  11. package/dist/catalog/blocks/stop-test-gate.js +81 -0
  12. package/dist/catalog/blocks/stop-uncommitted-warn.d.ts +2 -0
  13. package/dist/catalog/blocks/stop-uncommitted-warn.js +30 -0
  14. package/dist/catalog/types.d.ts +8 -2
  15. package/dist/catalog/types.js +2 -0
  16. package/dist/cli/commands/doctor.js +4 -0
  17. package/dist/cli/commands/init.js +5 -3
  18. package/dist/cli/commands/modify.d.ts +8 -0
  19. package/dist/cli/commands/modify.js +77 -0
  20. package/dist/cli/commands/test.js +3 -0
  21. package/dist/cli/deps-checker.d.ts +1 -0
  22. package/dist/cli/deps-checker.js +1 -1
  23. package/dist/cli/harness-tester.js +13 -1
  24. package/dist/cli/index.js +12 -0
  25. package/dist/cli/tui/init-flow.js +3 -3
  26. package/dist/core/harness-converter-v2.d.ts +0 -6
  27. package/dist/core/harness-converter-v2.js +25 -1
  28. package/dist/core/harness-schema.d.ts +17 -0
  29. package/dist/core/harness-schema.js +4 -0
  30. package/dist/core/merged-config.d.ts +2 -0
  31. package/dist/core/presets.d.ts +4 -0
  32. package/dist/core/presets.js +12 -1
  33. package/dist/generators/codex-config.js +1 -1
  34. package/dist/generators/hooks.d.ts +2 -2
  35. package/dist/generators/hooks.js +13 -3
  36. package/dist/generators/pi-extension.js +1 -1
  37. package/dist/nl/typesafe-chooser.d.ts +3 -1
  38. package/dist/nl/typesafe-chooser.js +3 -2
  39. package/dist/nl/typesafe-modify.d.ts +51 -0
  40. package/dist/nl/typesafe-modify.js +118 -0
  41. 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) ~ /^\\//) ex = 1; c = omh_cd(c, $(j + 1), home); j++ }
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
- if _omh_gate_cached commit-test-gate {{cacheTtlSeconds}} "$FP"; then
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
- if _omh_gate_cached commit-typecheck-gate {{cacheTtlSeconds}} "$FP"; then
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,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const semanticDiffGate: BuildingBlock;
@@ -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,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const semanticRuleGuard: BuildingBlock;
@@ -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,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const stopTestGate: BuildingBlock;
@@ -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,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const stopUncommittedWarn: BuildingBlock;
@@ -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
+ };
@@ -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" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
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" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
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
  }>;
@@ -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);
@@ -0,0 +1,8 @@
1
+ export interface ModifyOptions {
2
+ projectDir?: string;
3
+ yes?: boolean;
4
+ dryRun?: boolean;
5
+ }
6
+ export declare function modifyCommand(requestParts: string[], options?: ModifyOptions): Promise<{
7
+ exitCode: number;
8
+ }>;