oh-my-harness 0.22.0 → 0.23.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
@@ -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,43 @@ 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
+ - Interactive `omh init` offers both: "Describe your project (Jev picks the blocks)" and "Use a preset (no AI)".
103
+ - Descriptions in English work best; other languages are handled but with lower confidence, so include the specifics.
104
+
105
+ The always-on guards (harness self-protection, `--no-verify`, force-push) are added to every preset and every Jev result.
70
106
 
71
107
  ---
72
108
 
@@ -155,6 +191,7 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
155
191
  # 🚀 Initialize
156
192
  omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
157
193
  omh init --preset strict # No provider: minimal | safe | strict
194
+ omh explain # Why were the last tool calls blocked, and what to do about it
158
195
  omh init # Interactive TUI (import existing harness.yaml)
159
196
 
160
197
  # 📋 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,6 +17,10 @@ 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)
@@ -17,6 +17,10 @@ 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)
@@ -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
@@ -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)
@@ -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,6 +19,13 @@ 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;
@@ -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
+ }>;
@@ -0,0 +1,83 @@
1
+ import chalk from "chalk";
2
+ import { readEvents } from "../event-logger.js";
3
+ import { builtinBlocks } from "../../catalog/blocks/index.js";
4
+ const GENERIC_CHANGE = (id) => `harness.yaml > hooks > ${id}: set mode: ask, adjust its params, or remove the entry, then run omh sync`;
5
+ const UNKNOWN_HOOK_CHANGE = "this hook is not a catalog block: look it up in .claude/settings.json (or harness.yaml if you added it there)";
6
+ const BUILTIN_IDS = new Set(builtinBlocks.map((b) => b.id));
7
+ /**
8
+ * catalog-<id>.sh -> id. Legacy scripts were named harness-<name>.sh, so a
9
+ * harness- prefix is stripped too, but only when what is left is not itself a
10
+ * block id: catalog-harness-guard.sh must stay harness-guard.
11
+ */
12
+ export function blockIdFromHook(hook, knownIds = BUILTIN_IDS) {
13
+ const id = hook.replace(/\.sh$/, "").replace(/^catalog-/, "");
14
+ if (id.startsWith("harness-") && !knownIds.has(id))
15
+ return id.slice("harness-".length);
16
+ return id;
17
+ }
18
+ function relativeTime(ts, now) {
19
+ const diff = Math.max(0, now - Date.parse(ts));
20
+ const s = Math.round(diff / 1000);
21
+ if (s < 60)
22
+ return `${s}s ago`;
23
+ const m = Math.round(s / 60);
24
+ if (m < 60)
25
+ return `${m} min ago`;
26
+ const h = Math.round(m / 60);
27
+ if (h < 48)
28
+ return `${h} h ago`;
29
+ return `${Math.round(h / 24)} d ago`;
30
+ }
31
+ export function toEntry(ev, blocks) {
32
+ const id = blockIdFromHook(ev.hook);
33
+ const block = blocks.find((b) => b.id === id);
34
+ const reason = (ev.reason ?? "").replace(/^oh-my-harness:\s*/, "");
35
+ return {
36
+ ts: ev.ts,
37
+ block: id,
38
+ hook: ev.hook,
39
+ reason,
40
+ allowOnce: block?.explain?.allowOnce,
41
+ change: block ? (block.explain?.change ?? GENERIC_CHANGE(id)) : UNKNOWN_HOOK_CHANGE,
42
+ };
43
+ }
44
+ export function formatExplanation(ev, blocks, now = Date.now()) {
45
+ const e = toEntry(ev, blocks);
46
+ const lines = [
47
+ `${chalk.dim(relativeTime(e.ts, now).padEnd(11))}${chalk.bold(e.block)} ${chalk.red("blocked")}`,
48
+ ` Because: ${e.reason || "(no reason recorded)"}`,
49
+ ];
50
+ if (e.allowOnce)
51
+ lines.push(` To allow once: ${e.allowOnce}`);
52
+ lines.push(` To change the rule: ${e.change}`);
53
+ return lines.join("\n");
54
+ }
55
+ export async function explainCommand(options = {}) {
56
+ const projectDir = options.projectDir ?? process.cwd();
57
+ const last = options.last && options.last > 0 ? options.last : 5;
58
+ let events = [];
59
+ try {
60
+ events = await readEvents(projectDir);
61
+ }
62
+ catch {
63
+ events = [];
64
+ }
65
+ const blocked = events.filter((e) => e.decision === "block").slice(-last).reverse();
66
+ const entries = blocked.map((e) => toEntry(e, builtinBlocks));
67
+ if (options.json) {
68
+ console.log(JSON.stringify(entries, null, 2));
69
+ return { exitCode: 0, entries };
70
+ }
71
+ if (blocked.length === 0) {
72
+ console.log("No blocked tool calls in .omh/state/events.jsonl yet.");
73
+ return { exitCode: 0, entries };
74
+ }
75
+ const now = Date.now();
76
+ console.log(chalk.bold(`Last ${blocked.length} blocked tool call${blocked.length === 1 ? "" : "s"}:`));
77
+ console.log("");
78
+ for (const e of blocked) {
79
+ console.log(formatExplanation(e, builtinBlocks, now));
80
+ console.log("");
81
+ }
82
+ return { exitCode: 0, entries };
83
+ }
package/dist/cli/index.js CHANGED
@@ -35,6 +35,18 @@ export function createCli() {
35
35
  process.exitCode = 1;
36
36
  }
37
37
  });
38
+ program
39
+ .command("explain")
40
+ .description("Show the last blocked tool calls in plain language, with how to allow once and how to change the rule")
41
+ .option("-d, --project-dir <dir>", "Project directory")
42
+ .option("-n, --last <count>", "How many blocks to show (default 5)", (v) => Number.parseInt(v, 10))
43
+ .option("--json", "Machine-readable output")
44
+ .action(async (options) => {
45
+ const { explainCommand } = await import("./commands/explain.js");
46
+ const result = await explainCommand(options);
47
+ if (result.exitCode !== 0)
48
+ process.exitCode = result.exitCode;
49
+ });
38
50
  program
39
51
  .command("doctor")
40
52
  .description("Validate harness configuration health")
@@ -34,6 +34,8 @@ export interface StatsData {
34
34
  dateRange: DateRange;
35
35
  }
36
36
  export declare function deduplicateBlocks(blocks: BlockStats[]): BlockStats[];
37
+ /** catalog-<id>.sh -> id; legacy harness-<name>.sh -> name, unless the name is itself a block id (harness-guard). */
38
+ export declare function normalizeHookName(hook: string): string;
37
39
  export declare function getActiveBlocks(hookEntries: HookEntry[], allBlocks: BuildingBlock[]): {
38
40
  block: BuildingBlock;
39
41
  params: Record<string, unknown>;
@@ -26,6 +26,14 @@ export function deduplicateBlocks(blocks) {
26
26
  }
27
27
  return [...map.values()];
28
28
  }
29
+ const BUILTIN_IDS = new Set(builtinBlocks.map((b) => b.id));
30
+ /** catalog-<id>.sh -> id; legacy harness-<name>.sh -> name, unless the name is itself a block id (harness-guard). */
31
+ export function normalizeHookName(hook) {
32
+ const id = hook.replace(/\.sh$/, "").replace(/^catalog-/, "");
33
+ if (id.startsWith("harness-") && !BUILTIN_IDS.has(id))
34
+ return id.slice("harness-".length);
35
+ return id;
36
+ }
29
37
  export function getActiveBlocks(hookEntries, allBlocks) {
30
38
  const result = [];
31
39
  for (const entry of hookEntries) {
@@ -37,7 +45,7 @@ export function getActiveBlocks(hookEntries, allBlocks) {
37
45
  return result;
38
46
  }
39
47
  export function getDormantBlocks(activeBlocks, events) {
40
- const hitHooks = new Set(events.map(e => e.hook.replace(/\.sh$/, "").replace(/^catalog-/, "").replace(/^harness-/, "")));
48
+ const hitHooks = new Set(events.map(e => normalizeHookName(e.hook)));
41
49
  return activeBlocks
42
50
  .filter(ab => !hitHooks.has(ab.block.id))
43
51
  .map(ab => ab.block.id);
@@ -76,7 +84,7 @@ export function getDateFilteredEvents(events, range) {
76
84
  export function getBlockDetail(blockId, events, allBlocks, params = {}) {
77
85
  const block = allBlocks.find(b => b.id === blockId);
78
86
  const hookEvents = events.filter(e => {
79
- const normalized = e.hook.replace(/\.sh$/, "").replace(/^catalog-/, "").replace(/^harness-/, "");
87
+ const normalized = normalizeHookName(e.hook);
80
88
  return normalized === blockId;
81
89
  });
82
90
  const blockCount = hookEvents.filter(e => e.decision === "block").length;
@@ -203,7 +203,59 @@ _omh_gate_record() {
203
203
  mkdir -p "$(dirname "$file")" 2>/dev/null || true
204
204
  printf '%s %s\\n' "$fp" "$(date +%s)" > "$file"
205
205
  }`;
206
- const OMH_CMD_HELPERS = `${OMH_TREE_FINGERPRINT}
206
+ // Path resolution shared by guards that must know whether a token points
207
+ // inside THIS project (#133). Exposed as a bash variable holding awk function
208
+ // definitions; templates concatenate it in front of their own awk program:
209
+ // awk -v root=... "${_OMH_AWK_PATHLIB}"'BEGIN { ... }'
210
+ // omh_abs returns "" when the token cannot be resolved ($VAR, backticks,
211
+ // unknown cwd); callers fall back to name matching, so unknown stays fail-closed.
212
+ const OMH_AWK_PATHLIB = String.raw `
213
+ function omh_norm(p, n, parts, out, i, depth, res, s) {
214
+ n = split(p, parts, "/"); depth = 0
215
+ for (i = 1; i <= n; i++) {
216
+ s = parts[i]
217
+ if (s == "" || s == ".") continue
218
+ if (s == "..") { if (depth > 0) depth--; continue }
219
+ out[++depth] = s
220
+ }
221
+ res = ""; for (i = 1; i <= depth; i++) res = res "/" out[i]
222
+ return res == "" ? "/" : res
223
+ }
224
+ function omh_abs(cwd, t, home) {
225
+ if (t == "" || index(t, "$") > 0 || index(t, "\140") > 0) return ""
226
+ if (t ~ /^~(\/|$)/) { if (home == "") return ""; t = home substr(t, 2) }
227
+ else if (t !~ /^\//) { if (cwd == "?") return ""; t = cwd "/" t }
228
+ return omh_norm(t)
229
+ }
230
+ function omh_cd(cwd, arg, home, a) {
231
+ if (arg == "" || arg == "-") return "?"
232
+ a = omh_abs(cwd, arg, home)
233
+ return a == "" ? "?" : a
234
+ }
235
+ # cd / pushd / popd with option skipping (cd -P dir) and a directory stack.
236
+ # Returns the new cwd; the caller keeps the stack (omh_stack, omh_sp) between calls.
237
+ function omh_cd_cmd(cwd, i, home, k, arg) {
238
+ # pushd -n (no cd), pushd/popd +N or -N (stack rotation) and popd with options
239
+ # change the stack in ways we do not model: unknown cwd, fail closed.
240
+ for (k = i + 1; k <= NF; k++) {
241
+ if ($i != "cd" && ($k == "-n" || $k ~ /^[+-][0-9]+$/)) { omh_sp = 0; return "?" }
242
+ }
243
+ if ($i == "popd") { return (omh_sp > 0) ? omh_stack[omh_sp--] : "?" }
244
+ arg = ""
245
+ for (k = i + 1; k <= NF; k++) { if ($k ~ /^-./ && $k != "-") continue; arg = $k; break }
246
+ if ($i == "pushd") omh_stack[++omh_sp] = cwd
247
+ return omh_cd(cwd, arg, home)
248
+ }
249
+ function omh_under(a, root) {
250
+ return a == root || substr(a, 1, length(root) + 1) == root "/"
251
+ }`;
252
+ const OMH_CMD_HELPERS = `_OMH_AWK_PATHLIB='${OMH_AWK_PATHLIB}'
253
+ # 1 when the command chains with ';' or newlines: a failed cd then leaves the next
254
+ # command in the OLD directory, so relative paths must also be judged from there.
255
+ _omh_seq_unsafe() { case "\${1:-}" in *";"*|*$'\\n'*) echo 1 ;; *) echo 0 ;; esac; }
256
+ # The project this hook belongs to: the parent of .omh/state (absolute, symlinks resolved).
257
+ _OMH_PROJECT_ROOT="$(cd "$(dirname "$(dirname "$_OMH_STATE_DIR")")" 2>/dev/null && pwd -P || pwd -P)"
258
+ ${OMH_TREE_FINGERPRINT}
207
259
  _omh_tokenize() {
208
260
  printf '%s\\n' "\${1:-}" | awk '${OMH_CMD_TOKENIZER_AWK}'
209
261
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {