oh-my-harness 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +43 -2
  2. package/dist/catalog/blocks/branch-guard.js +37 -3
  3. package/dist/catalog/blocks/command-guard.js +4 -0
  4. package/dist/catalog/blocks/commit-test-gate.js +10 -2
  5. package/dist/catalog/blocks/commit-typecheck-gate.js +10 -2
  6. package/dist/catalog/blocks/force-push-guard.js +4 -0
  7. package/dist/catalog/blocks/harness-guard.js +42 -3
  8. package/dist/catalog/blocks/index.js +4 -0
  9. package/dist/catalog/blocks/lockfile-guard.js +4 -0
  10. package/dist/catalog/blocks/loop-guard.js +4 -0
  11. package/dist/catalog/blocks/no-verify-guard.js +4 -0
  12. package/dist/catalog/blocks/path-guard.js +4 -0
  13. package/dist/catalog/blocks/secret-file-guard.js +4 -0
  14. package/dist/catalog/blocks/sql-guard.js +4 -0
  15. package/dist/catalog/blocks/stop-test-gate.d.ts +2 -0
  16. package/dist/catalog/blocks/stop-test-gate.js +81 -0
  17. package/dist/catalog/blocks/stop-uncommitted-warn.d.ts +2 -0
  18. package/dist/catalog/blocks/stop-uncommitted-warn.js +30 -0
  19. package/dist/catalog/blocks/tdd-guard.js +4 -0
  20. package/dist/catalog/types.d.ts +15 -2
  21. package/dist/catalog/types.js +2 -0
  22. package/dist/cli/commands/explain.d.ts +28 -0
  23. package/dist/cli/commands/explain.js +83 -0
  24. package/dist/cli/commands/modify.d.ts +8 -0
  25. package/dist/cli/commands/modify.js +77 -0
  26. package/dist/cli/commands/test.js +1 -0
  27. package/dist/cli/harness-tester.js +10 -0
  28. package/dist/cli/index.js +24 -0
  29. package/dist/cli/stats/data.d.ts +2 -0
  30. package/dist/cli/stats/data.js +10 -2
  31. package/dist/core/harness-converter-v2.js +3 -1
  32. package/dist/core/harness-schema.d.ts +5 -0
  33. package/dist/core/merged-config.d.ts +2 -0
  34. package/dist/core/presets.js +2 -0
  35. package/dist/generators/codex-config.js +1 -1
  36. package/dist/generators/hooks.d.ts +2 -2
  37. package/dist/generators/hooks.js +64 -4
  38. package/dist/generators/pi-extension.js +1 -1
  39. package/dist/nl/typesafe-chooser.d.ts +1 -1
  40. package/dist/nl/typesafe-chooser.js +1 -0
  41. package/dist/nl/typesafe-modify.d.ts +51 -0
  42. package/dist/nl/typesafe-modify.js +118 -0
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -33,7 +33,7 @@ That is the whole setup. Your agent now hits a wall when it tries to:
33
33
  | save a file | ✅ auto-lint |
34
34
  | push a branch | ✅ auto-PR |
35
35
 
36
- Every decision is logged to `.omh/state/events.jsonl` — `omh stats` shows what your agent tried and what got stopped.
36
+ Every decision is logged to `.omh/state/events.jsonl` — `omh stats` shows what your agent tried and what got stopped, and `omh explain` tells you in plain language why the last few calls were blocked and how to allow once or change the rule.
37
37
 
38
38
  ## Why not just write CLAUDE.md?
39
39
 
@@ -66,7 +66,44 @@ omh diff # Preview what `omh sync` would change
66
66
  omh sync --check # Fail (exit 1) if generated files are out of date — CI gate
67
67
  ```
68
68
 
69
- 📖 Deeper: [what gets generated & CI drift check](docs/generated-files.md) · [how it works, project detector, AI providers](docs/how-it-works.md)
69
+ 📖 Deeper: [what gets generated & CI drift check](docs/generated-files.md) · [how it works, project detector, presets & Jev, AI providers](docs/how-it-works.md)
70
+
71
+ ---
72
+
73
+ ## 🎛️ Presets and the Jev chooser
74
+
75
+ You do not need a chat model to set up a harness.
76
+
77
+ **Presets** are deterministic and offline. The project detector fills in test, lint and typecheck commands and build directories; rule text is templated; a block whose required parameter cannot be detected is left out rather than half-configured.
78
+
79
+ | Preset | What it enables |
80
+ |--------|-----------------|
81
+ | `minimal` | dangerous-command guard, main-branch guard, build-output guard |
82
+ | `safe` | minimal + tests and typecheck must pass before a commit, lockfile and secret-file guards, lint on save |
83
+ | `strict` | safe + test-first (TDD) on every source edit |
84
+
85
+ ```bash
86
+ omh init --preset safe # confirm prompt; add -y to skip it
87
+ ```
88
+
89
+ **Jev** picks the blocks for you from a description. [Jev](https://docs.typesafe.ai/introduction) is TypeSafe's System One model: it does not generate text, it answers typed questions with calibrated probabilities. `omh init "description"` sends the description plus the detector facts and asks, in one call, "should block X be enabled?" for every selectable block and "how strict?" once. Probabilities at or above 0.65 enable a block, at or below 0.35 disable it, anything in between keeps the preset default. Rule text and free-form params never come from the model.
90
+
91
+ ```bash
92
+ export TYPESAFE_API_KEY=... # or put it in the project's .env
93
+ omh init "Next.js + FastAPI, TDD enforced, no auto PRs"
94
+ # Jev (jev-latest) chose: strictness=strict, 1716 input tokens
95
+ # enabled: ... tdd-guard, sql-guard ...
96
+ # disabled: auto-pr
97
+ ```
98
+
99
+ - Get a key from the [TypeSafe console](https://typesafe.ai) (early access at the time of writing). One init costs a fraction of a cent (input $0.042 per million tokens, output free) and takes about half a second.
100
+ - `--preset` always wins and never calls Jev. No key and no LLM provider? `omh init "description"` falls back to `safe` and says so.
101
+ - `omh doctor` and `omh config --show` tell you whether the chooser is active. Remove the key to go back to the LLM providers or presets.
102
+ - `omh modify "..."` later edits the same way: Jev answers enable / disable / ask / keep per block, you confirm the change set, `omh sync` runs. Mark an entry `locked: true` in harness.yaml and modify will refuse to remove or weaken it.
103
+ - Interactive `omh init` offers both: "Describe your project (Jev picks the blocks)" and "Use a preset (no AI)".
104
+ - Descriptions in English work best; other languages are handled but with lower confidence, so include the specifics.
105
+
106
+ The always-on guards (harness self-protection, `--no-verify`, force-push) are added to every preset and every Jev result.
70
107
 
71
108
  ---
72
109
 
@@ -83,6 +120,8 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
83
120
  | 🪝 `harness-guard` | security | Blocks shell writes to the harness's own hooks and config (always on) |
84
121
  | 🚫 `no-verify-guard` | git | Blocks `--no-verify` / `-n` / hooksPath overrides on commit and push (always on) |
85
122
  | 💥 `force-push-guard` | git | Blocks force pushes to protected branches (always on) |
123
+ | 🛑 `stop-test-gate` | quality | When the agent tries to end its turn with failing tests, sends it back (retry-capped) |
124
+ | 📝 `stop-uncommitted-warn` | git | Lists uncommitted changes as a system message when the turn ends |
86
125
  | 📁 `path-guard` | file-protection | Blocks writes to protected paths |
87
126
  | 🔐 `lockfile-guard` | file-protection | Prevents manual lockfile edits |
88
127
  | 🤫 `secret-file-guard` | security | Blocks edits to .env, credentials |
@@ -155,6 +194,8 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
155
194
  # 🚀 Initialize
156
195
  omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
157
196
  omh init --preset strict # No provider: minimal | safe | strict
197
+ omh explain # Why were the last tool calls blocked, and what to do about it
198
+ omh modify "no auto PRs, TDD guard asks" # Edit harness.yaml from a sentence (Jev decides per block; locked: true entries stay)
158
199
  omh init # Interactive TUI (import existing harness.yaml)
159
200
 
160
201
  # 📋 Catalog
@@ -10,12 +10,46 @@ export const branchGuard = {
10
10
  { name: "mainBranch", type: "string", description: "Main branch name", default: "main", required: false },
11
11
  ],
12
12
  tags: ["git", "branch", "merge", "guard"],
13
+ explain: {
14
+ allowOnce: "create a feature branch (git switch -c feat/x) and commit there",
15
+ change: "harness.yaml > hooks > branch-guard > params.mainBranch, or remove the hook, then omh sync",
16
+ },
13
17
  template: `#!/bin/bash
14
18
  set -euo pipefail
15
19
  INPUT=$(cat)
16
20
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
21
  if _omh_cmd_matches "$COMMAND" git commit || _omh_cmd_matches "$COMMAND" git push; then
18
- BRANCH=$(git branch --show-current 2>/dev/null)
22
+ # Where does the commit/push run? Follow cd / pushd and git -C (#133). A repo
23
+ # outside this project is not ours to guard; an unresolvable cwd is treated
24
+ # as the project itself (fail closed).
25
+ GIT_CWD=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' -v root="$_OMH_PROJECT_ROOT" -v home="\${HOME:-}" "\${_OMH_AWK_PATHLIB}"'
26
+ BEGIN { cwd = root }
27
+ found != "" { next }
28
+ { i = 1
29
+ while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
30
+ if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
31
+ if (i > NF) next
32
+ if ($i == "cd" || $i == "pushd" || $i == "popd") { cwd = omh_cd_cmd(cwd, i, home); next }
33
+ if ($i != "git") next
34
+ c = cwd; ex = 0; j = i + 1
35
+ while (j <= NF && $j ~ /^-/) {
36
+ if ($j == "-C") { if ($(j + 1) ~ /^\\//) ex = 1; c = omh_cd(c, $(j + 1), home); j++ }
37
+ else if ($j == "-c") j++
38
+ j++
39
+ }
40
+ if (j <= NF && ($j == "commit" || $j == "push")) { found = c; explicit = ex } }
41
+ END { print found "\\t" explicit }')
42
+ GIT_EXPLICIT="\${GIT_CWD#* }"; GIT_CWD="\${GIT_CWD%% *}"
43
+ [[ "$GIT_CWD" == "?" || -z "$GIT_CWD" ]] && GIT_CWD="$_OMH_PROJECT_ROOT"
44
+ # ';' / newline chaining: a failed cd would leave the commit in this project, so judge it
45
+ # here, unless the target came from an explicit absolute git -C.
46
+ [[ "$(_omh_seq_unsafe "$COMMAND")" == "1" && "$GIT_EXPLICIT" != "1" ]] && GIT_CWD="$_OMH_PROJECT_ROOT"
47
+ case "$GIT_CWD" in
48
+ "$_OMH_PROJECT_ROOT"|"$_OMH_PROJECT_ROOT"/*) ;;
49
+ *) exit 0 ;;
50
+ esac
51
+ # A subdirectory of the project is the same repository; if it does not exist yet, ask the root.
52
+ BRANCH=$(git -C "$GIT_CWD" branch --show-current 2>/dev/null || git -C "$_OMH_PROJECT_ROOT" branch --show-current 2>/dev/null)
19
53
  [[ -z "$BRANCH" ]] && exit 0
20
54
  MAIN='{{mainBranch}}'
21
55
  if [[ "$BRANCH" == "$MAIN" ]] || [[ "$BRANCH" == "master" && "$MAIN" == "main" ]]; then
@@ -32,8 +66,8 @@ if _omh_cmd_matches "$COMMAND" git commit || _omh_cmd_matches "$COMMAND" git pus
32
66
  fi
33
67
  fi
34
68
  if [[ "$MERGED" -eq 0 ]]; then
35
- git fetch origin "$MAIN" --quiet >/dev/null 2>&1 || true
36
- if git branch -r --merged "origin/$MAIN" 2>/dev/null | grep -qE "origin/\${BRANCH}$"; then
69
+ git -C "$GIT_CWD" fetch origin "$MAIN" --quiet >/dev/null 2>&1 || true
70
+ if git -C "$GIT_CWD" branch -r --merged "origin/$MAIN" 2>/dev/null | grep -qE "origin/\${BRANCH}$"; then
37
71
  MERGED=1
38
72
  fi
39
73
  fi
@@ -16,6 +16,10 @@ export const commandGuard = {
16
16
  },
17
17
  ],
18
18
  tags: ["security", "bash", "guard", "dangerous"],
19
+ explain: {
20
+ allowOnce: "rewrite the command without the blocked pattern (a narrower path, no sudo, no -R 777)",
21
+ change: "harness.yaml > hooks > command-guard > params.patterns: remove or edit the pattern, then omh sync",
22
+ },
19
23
  template: `#!/bin/bash
20
24
  set -euo pipefail
21
25
  INPUT=$(cat)
@@ -17,13 +17,21 @@ export const commitTestGate = {
17
17
  },
18
18
  ],
19
19
  tags: ["git", "test", "quality", "guard"],
20
+ explain: {
21
+ allowOnce: "make the test command pass, or commit only after fixing the failing test",
22
+ change: "harness.yaml > hooks > commit-test-gate: change testCommand or cacheTtlSeconds, set mode: ask, or remove it, then omh sync",
23
+ },
20
24
  template: `#!/bin/bash
21
25
  set -euo pipefail
22
26
  INPUT=$(cat)
23
27
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
24
28
  if _omh_cmd_matches "$COMMAND" git commit; then
25
29
  FP=$(_omh_tree_fingerprint)
26
- 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
27
35
  echo "oh-my-harness: tests already passed on this exact tree; skipping" >&2
28
36
  _log_event "allow" "cached: tree unchanged since last passing run"
29
37
  exit 0
@@ -35,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
35
43
  _emit_decision "block" "$REASON"
36
44
  exit 0
37
45
  fi
38
- _omh_gate_record commit-test-gate "$FP"
46
+ _omh_gate_record commit-test-gate "$FP" "$GATE_CMD"
39
47
  fi
40
48
  exit 0`,
41
49
  };
@@ -17,13 +17,21 @@ export const commitTypecheckGate = {
17
17
  },
18
18
  ],
19
19
  tags: ["git", "typecheck", "quality", "guard"],
20
+ explain: {
21
+ allowOnce: "fix the type errors the typecheck command reports",
22
+ change: "harness.yaml > hooks > commit-typecheck-gate: change typecheckCommand, set mode: ask, or remove it, then omh sync",
23
+ },
20
24
  template: `#!/bin/bash
21
25
  set -euo pipefail
22
26
  INPUT=$(cat)
23
27
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
24
28
  if _omh_cmd_matches "$COMMAND" git commit; then
25
29
  FP=$(_omh_tree_fingerprint)
26
- 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
27
35
  echo "oh-my-harness: typecheck already passed on this exact tree; skipping" >&2
28
36
  _log_event "allow" "cached: tree unchanged since last passing run"
29
37
  exit 0
@@ -35,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
35
43
  _emit_decision "block" "$REASON"
36
44
  exit 0
37
45
  fi
38
- _omh_gate_record commit-typecheck-gate "$FP"
46
+ _omh_gate_record commit-typecheck-gate "$FP" "$GATE_CMD"
39
47
  fi
40
48
  exit 0`,
41
49
  };
@@ -32,6 +32,10 @@ export const forcePushGuard = {
32
32
  },
33
33
  ],
34
34
  tags: ["git", "push", "guard", "force"],
35
+ explain: {
36
+ allowOnce: "push to a feature branch and open a pull request; use --force-with-lease if you really need to rewrite your own branch",
37
+ change: "harness.yaml > hooks > force-push-guard > params.protected / allowLease, or remove it, then omh sync",
38
+ },
35
39
  template: `#!/bin/bash
36
40
  set -euo pipefail
37
41
  INPUT=$(cat)
@@ -6,6 +6,10 @@
6
6
  // ls, diff, sed without -i) stay allowed so the agent can inspect its own rules, and `omh` is
7
7
  // never a writer here, so `omh sync` / `omh hook add` remain the sanctioned
8
8
  // way to change the harness.
9
+ // Scope (#133): by default only paths that resolve inside this project root
10
+ // count, following cd / pushd, ~, .. and absolute paths, so another checkout's
11
+ // .omh is not this harness's business. Tokens that cannot be resolved ($VAR,
12
+ // backticks, unknown cwd) fall back to name matching and stay fail-closed.
9
13
  // ponytail: harness.yaml itself is deliberately not protected (it is the
10
14
  // user's knob); per-hook `locked: true` is tracked in #118. `rm -rf .` / `*`
11
15
  // from the project root are left to command-guard patterns.
@@ -25,8 +29,19 @@ export const harnessGuard = {
25
29
  default: [],
26
30
  required: false,
27
31
  },
32
+ {
33
+ name: "scope",
34
+ type: "string",
35
+ description: "project: only paths that resolve inside this project root are protected (cd and absolute paths are followed); any: match the protected names anywhere in the command",
36
+ default: "project",
37
+ required: false,
38
+ },
28
39
  ],
29
40
  tags: ["security", "bash", "guard", "harness", "self-protection"],
41
+ explain: {
42
+ allowOnce: "edit harness.yaml and run omh sync; hook scripts and runtime config are generated, not hand-edited",
43
+ change: "harness.yaml > hooks > harness-guard: scope: any / project, extraPaths, mode: ask, or remove it, then omh sync",
44
+ },
30
45
  template: `#!/bin/bash
31
46
  set -euo pipefail
32
47
  INPUT=$(cat)
@@ -41,12 +56,15 @@ WRITERS="rm mv cp tee chmod chown chgrp truncate dd ln touch install rsync shred
41
56
  GIT_WRITERS="checkout restore clean rm mv"
42
57
 
43
58
  HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
44
- -v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v writers="$WRITERS" -v gitw="$GIT_WRITERS" '
59
+ -v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v writers="$WRITERS" -v gitw="$GIT_WRITERS" \\
60
+ -v root="$_OMH_PROJECT_ROOT" -v home="\${HOME:-}" -v scope='{{scope}}' -v seq_unsafe="$(_omh_seq_unsafe "$COMMAND")" "\${_OMH_AWK_PATHLIB}"'
45
61
  BEGIN {
62
+ cwd = root
46
63
  np = split(protected, P, "|")
47
64
  nw = split(writers, W, " "); for (k = 1; k <= nw; k++) isw[W[k]] = 1
48
65
  ng = split(gitw, G, " "); for (k = 1; k <= ng; k++) isg[G[k]] = 1
49
66
  }
67
+ # name-based matching: scope=any, and the fallback for tokens that cannot be resolved
50
68
  function hits(t, k, p, L) {
51
69
  sub(/^\\.\\//, "", t)
52
70
  for (k = 1; k <= np; k++) {
@@ -59,13 +77,34 @@ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
59
77
  }
60
78
  return ""
61
79
  }
80
+ # project scope: the token must resolve (through cd, ~, .., absolute paths) to a protected path under root
81
+ function check_at(base, t, a, rel, k, p, L) {
82
+ a = omh_abs(base, t, home)
83
+ if (a == "") return hits(t)
84
+ if (!omh_under(a, root)) return ""
85
+ rel = (a == root) ? "" : substr(a, length(root) + 2)
86
+ for (k = 1; k <= np; k++) {
87
+ p = P[k]; if (p == "") continue
88
+ sub(/^\\.\\//, "", p); L = length(p)
89
+ if (rel == p || substr(rel, 1, L + 1) == p "/") return p
90
+ }
91
+ return ""
92
+ }
93
+ function check(t, p) {
94
+ if (scope == "any") return hits(t)
95
+ p = check_at(cwd, t)
96
+ # with semicolon or newline chaining a failed cd leaves the shell where it was: judge from the root too
97
+ if (p == "" && seq_unsafe == "1" && cwd != root && t !~ /^\\//) p = check_at(root, t)
98
+ return p
99
+ }
62
100
  found != "" { next }
63
101
  {
64
- if ($1 == "__omh_redirect__") { p = hits($2); if (p != "") found = "redirect into " p; next }
102
+ if ($1 == "__omh_redirect__") { p = check($2); if (p != "") found = "redirect into " p; next }
65
103
  i = 1
66
104
  while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
67
105
  if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
68
106
  if (i > NF) next
107
+ if ($i == "cd" || $i == "pushd" || $i == "popd") { cwd = omh_cd_cmd(cwd, i, home); next }
69
108
  a0 = $i
70
109
  write = 0
71
110
  if (isw[a0]) write = 1
@@ -82,7 +121,7 @@ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
82
121
  }
83
122
  else if (a0 == "find") { for (k = i; k <= NF; k++) if ($k == "-delete" || $k == "-exec" || $k == "-execdir") write = 1 }
84
123
  if (!write) next
85
- for (k = i + 1; k <= NF; k++) { p = hits($k); if (p != "") { found = a0 " on " p; next } }
124
+ for (k = i + 1; k <= NF; k++) { p = check($k); if (p != "") { found = a0 " on " p; next } }
86
125
  }
87
126
  END { if (found != "") print found }')
88
127
  if [[ -n "$HIT" ]]; then
@@ -19,6 +19,8 @@ import { loopGuard } from "./loop-guard.js";
19
19
  import { harnessGuard } from "./harness-guard.js";
20
20
  import { noVerifyGuard } from "./no-verify-guard.js";
21
21
  import { forcePushGuard } from "./force-push-guard.js";
22
+ import { stopTestGate } from "./stop-test-gate.js";
23
+ import { stopUncommittedWarn } from "./stop-uncommitted-warn.js";
22
24
  export const builtinBlocks = [
23
25
  branchGuard,
24
26
  commitTestGate,
@@ -41,4 +43,6 @@ export const builtinBlocks = [
41
43
  harnessGuard,
42
44
  noVerifyGuard,
43
45
  forcePushGuard,
46
+ stopTestGate,
47
+ stopUncommittedWarn,
44
48
  ];
@@ -16,6 +16,10 @@ export const lockfileGuard = {
16
16
  },
17
17
  ],
18
18
  tags: ["security", "file", "lockfile", "guard"],
19
+ explain: {
20
+ allowOnce: "change the dependency with the package manager (npm/pnpm/yarn/pip add or remove) so it rewrites the lockfile",
21
+ change: "harness.yaml > hooks > lockfile-guard > params.lockfiles: remove the file, or remove the hook, then omh sync",
22
+ },
19
23
  template: `#!/bin/bash
20
24
  set -euo pipefail
21
25
  INPUT=$(cat)
@@ -30,6 +30,10 @@ export const loopGuard = {
30
30
  default: [],
31
31
  },
32
32
  ],
33
+ explain: {
34
+ allowOnce: "the loop must not write its own work orders or architect-only paths; do that from an architect session",
35
+ change: "harness.yaml > loop.architectOnly / loop.workOrders, or disable the loop, then omh sync",
36
+ },
33
37
  template: `#!/bin/bash
34
38
  set -euo pipefail
35
39
  INPUT=$(cat)
@@ -18,6 +18,10 @@ export const noVerifyGuard = {
18
18
  canBlock: true,
19
19
  params: [],
20
20
  tags: ["git", "hooks", "guard", "bypass"],
21
+ explain: {
22
+ allowOnce: "fix what the git hook reports and commit or push again without --no-verify",
23
+ change: "harness.yaml > hooks > no-verify-guard: set mode: ask or remove it, then omh sync",
24
+ },
21
25
  template: `#!/bin/bash
22
26
  set -euo pipefail
23
27
  INPUT=$(cat)
@@ -22,6 +22,10 @@ export const pathGuard = {
22
22
  },
23
23
  ],
24
24
  tags: ["security", "file", "path", "guard"],
25
+ explain: {
26
+ allowOnce: "write to a source path instead; build output and dependency directories are regenerated by their tools",
27
+ change: "harness.yaml > hooks > path-guard > params.blockedPaths: remove the path (protectHarness: false lifts the self-protection), then omh sync",
28
+ },
25
29
  template: `#!/bin/bash
26
30
  set -euo pipefail
27
31
  INPUT=$(cat)
@@ -16,6 +16,10 @@ export const secretFileGuard = {
16
16
  },
17
17
  ],
18
18
  tags: ["security", "secrets", "credentials", "guard"],
19
+ explain: {
20
+ allowOnce: "edit the secret file yourself outside the agent",
21
+ change: "harness.yaml > hooks > secret-file-guard > params.patterns: remove the pattern, or remove the hook, then omh sync",
22
+ },
19
23
  template: `#!/bin/bash
20
24
  set -euo pipefail
21
25
  INPUT=$(cat)
@@ -16,6 +16,10 @@ export const sqlGuard = {
16
16
  },
17
17
  ],
18
18
  tags: ["security", "sql", "guard", "database"],
19
+ explain: {
20
+ allowOnce: "run the statement yourself outside the agent, or use a narrower statement",
21
+ change: "harness.yaml > hooks > sql-guard > params.patterns: remove or edit the pattern, then omh sync",
22
+ },
19
23
  template: `#!/bin/bash
20
24
  set -euo pipefail
21
25
  INPUT=$(cat)
@@ -0,0 +1,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
+ };
@@ -22,6 +22,10 @@ export const tddGuard = {
22
22
  default: "\\.(test|spec)\\.(ts|tsx|js|jsx)$",
23
23
  },
24
24
  ],
25
+ explain: {
26
+ allowOnce: "edit or create the matching test file first (the guard records test edits per session), then edit the source",
27
+ change: "harness.yaml > hooks > tdd-guard: narrow srcPattern/testPattern, set mode: ask, or remove it, then omh sync",
28
+ },
25
29
  template: `#!/bin/bash
26
30
  set -euo pipefail
27
31
  INPUT=$(cat)
@@ -19,11 +19,20 @@ export interface BuildingBlock {
19
19
  params: ParamDefinition[];
20
20
  template: string;
21
21
  tags: string[];
22
+ /** Plain-language remedies shown by `omh explain` (#115). */
23
+ explain?: {
24
+ /** How to get past this block one time without changing the rule. */
25
+ allowOnce?: string;
26
+ /** Where the rule lives and how to change it. */
27
+ change?: string;
28
+ };
22
29
  }
23
30
  export interface HookEntry {
24
31
  block: string;
25
32
  params: Record<string, unknown>;
26
33
  mode?: "block" | "ask";
34
+ /** `omh modify` may never remove or weaken a locked entry (#118). */
35
+ locked?: boolean;
27
36
  }
28
37
  export declare const ParamDefinitionSchema: z.ZodObject<{
29
38
  name: z.ZodString;
@@ -75,7 +84,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
75
84
  tags: z.ZodArray<z.ZodString, "many">;
76
85
  }, "strip", z.ZodTypeAny, {
77
86
  id: string;
78
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "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";
79
88
  name: string;
80
89
  params: {
81
90
  name: string;
@@ -92,7 +101,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
92
101
  matcher?: string | undefined;
93
102
  }, {
94
103
  id: string;
95
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "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";
96
105
  name: string;
97
106
  params: {
98
107
  name: string;
@@ -112,12 +121,16 @@ export declare const HookEntrySchema: z.ZodObject<{
112
121
  block: z.ZodString;
113
122
  params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
114
123
  mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
124
+ /** `omh modify` may never remove or weaken a locked entry (#118). */
125
+ locked: z.ZodOptional<z.ZodBoolean>;
115
126
  }, "strip", z.ZodTypeAny, {
116
127
  block: string;
117
128
  params: Record<string, unknown>;
118
129
  mode: "block" | "ask";
130
+ locked?: boolean | undefined;
119
131
  }, {
120
132
  block: string;
121
133
  params?: Record<string, unknown> | undefined;
122
134
  mode?: "block" | "ask" | undefined;
135
+ locked?: boolean | undefined;
123
136
  }>;
@@ -22,4 +22,6 @@ export const HookEntrySchema = z.object({
22
22
  block: z.string(),
23
23
  params: z.record(z.unknown()).default({}),
24
24
  mode: z.enum(["block", "ask"]).default("block"),
25
+ /** `omh modify` may never remove or weaken a locked entry (#118). */
26
+ locked: z.boolean().optional(),
25
27
  });
@@ -0,0 +1,28 @@
1
+ import { type HookEvent } from "../event-logger.js";
2
+ import type { BuildingBlock } from "../../catalog/types.js";
3
+ export interface ExplainOptions {
4
+ projectDir?: string;
5
+ /** How many block decisions to show, newest first. Default 5. */
6
+ last?: number;
7
+ json?: boolean;
8
+ }
9
+ export interface ExplainEntry {
10
+ ts: string;
11
+ block: string;
12
+ hook: string;
13
+ reason: string;
14
+ allowOnce?: string;
15
+ change: string;
16
+ }
17
+ /**
18
+ * catalog-<id>.sh -> id. Legacy scripts were named harness-<name>.sh, so a
19
+ * harness- prefix is stripped too, but only when what is left is not itself a
20
+ * block id: catalog-harness-guard.sh must stay harness-guard.
21
+ */
22
+ export declare function blockIdFromHook(hook: string, knownIds?: Set<string>): string;
23
+ export declare function toEntry(ev: HookEvent, blocks: BuildingBlock[]): ExplainEntry;
24
+ export declare function formatExplanation(ev: HookEvent, blocks: BuildingBlock[], now?: number): string;
25
+ export declare function explainCommand(options?: ExplainOptions): Promise<{
26
+ exitCode: number;
27
+ entries: ExplainEntry[];
28
+ }>;