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 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) ~ /^\\//) 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
  }
@@ -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,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
+ };
@@ -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",
@@ -7,4 +7,5 @@ export interface DepCheck {
7
7
  installed: boolean;
8
8
  version?: string;
9
9
  }
10
+ export declare function commandExists(command: string): Promise<boolean>;
10
11
  export declare function checkDependencies(): Promise<DepCheck[]>;
@@ -35,7 +35,7 @@ const DEPS = [
35
35
  installHint: "brew install node",
36
36
  },
37
37
  ];
38
- async function commandExists(command) {
38
+ export async function commandExists(command) {
39
39
  try {
40
40
  await execFileAsync("which", [command]);
41
41
  return true;
@@ -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
- const hookPath = path.join(projectDir, testCase.hookScript);
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({
@@ -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 {};
@@ -98,7 +98,13 @@ const RULES = {
98
98
  ].join("\n"),
99
99
  },
100
100
  };
101
- export function buildPresetHarness(preset, facts, meta = {}) {
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",
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {