oh-my-harness 0.23.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.
package/README.md CHANGED
@@ -99,6 +99,7 @@ omh init "Next.js + FastAPI, TDD enforced, no auto PRs"
99
99
  - Get a key from the [TypeSafe console](https://typesafe.ai) (early access at the time of writing). One init costs a fraction of a cent (input $0.042 per million tokens, output free) and takes about half a second.
100
100
  - `--preset` always wins and never calls Jev. No key and no LLM provider? `omh init "description"` falls back to `safe` and says so.
101
101
  - `omh doctor` and `omh config --show` tell you whether the chooser is active. Remove the key to go back to the LLM providers or presets.
102
+ - `omh modify "..."` later edits the same way: Jev answers enable / disable / ask / keep per block, you confirm the change set, `omh sync` runs. Mark an entry `locked: true` in harness.yaml and modify will refuse to remove or weaken it.
102
103
  - Interactive `omh init` offers both: "Describe your project (Jev picks the blocks)" and "Use a preset (no AI)".
103
104
  - Descriptions in English work best; other languages are handled but with lower confidence, so include the specifics.
104
105
 
@@ -119,6 +120,8 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
119
120
  | 🪝 `harness-guard` | security | Blocks shell writes to the harness's own hooks and config (always on) |
120
121
  | 🚫 `no-verify-guard` | git | Blocks `--no-verify` / `-n` / hooksPath overrides on commit and push (always on) |
121
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 |
122
125
  | 📁 `path-guard` | file-protection | Blocks writes to protected paths |
123
126
  | 🔐 `lockfile-guard` | file-protection | Prevents manual lockfile edits |
124
127
  | 🤫 `secret-file-guard` | security | Blocks edits to .env, credentials |
@@ -192,6 +195,7 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
192
195
  omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
193
196
  omh init --preset strict # No provider: minimal | safe | strict
194
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)
195
199
  omh init # Interactive TUI (import existing harness.yaml)
196
200
 
197
201
  # 📋 Catalog
@@ -27,7 +27,11 @@ INPUT=$(cat)
27
27
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
28
28
  if _omh_cmd_matches "$COMMAND" git commit; then
29
29
  FP=$(_omh_tree_fingerprint)
30
- if _omh_gate_cached commit-test-gate {{cacheTtlSeconds}} "$FP"; then
30
+ GATE_CMD=$(cat <<'OMH_GATE_CMD'
31
+ {{{testCommand}}}
32
+ OMH_GATE_CMD
33
+ )
34
+ if _omh_gate_cached commit-test-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
31
35
  echo "oh-my-harness: tests already passed on this exact tree; skipping" >&2
32
36
  _log_event "allow" "cached: tree unchanged since last passing run"
33
37
  exit 0
@@ -39,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
39
43
  _emit_decision "block" "$REASON"
40
44
  exit 0
41
45
  fi
42
- _omh_gate_record commit-test-gate "$FP"
46
+ _omh_gate_record commit-test-gate "$FP" "$GATE_CMD"
43
47
  fi
44
48
  exit 0`,
45
49
  };
@@ -27,7 +27,11 @@ INPUT=$(cat)
27
27
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
28
28
  if _omh_cmd_matches "$COMMAND" git commit; then
29
29
  FP=$(_omh_tree_fingerprint)
30
- if _omh_gate_cached commit-typecheck-gate {{cacheTtlSeconds}} "$FP"; then
30
+ GATE_CMD=$(cat <<'OMH_GATE_CMD'
31
+ {{{typecheckCommand}}}
32
+ OMH_GATE_CMD
33
+ )
34
+ if _omh_gate_cached commit-typecheck-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
31
35
  echo "oh-my-harness: typecheck already passed on this exact tree; skipping" >&2
32
36
  _log_event "allow" "cached: tree unchanged since last passing run"
33
37
  exit 0
@@ -39,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
39
43
  _emit_decision "block" "$REASON"
40
44
  exit 0
41
45
  fi
42
- _omh_gate_record commit-typecheck-gate "$FP"
46
+ _omh_gate_record commit-typecheck-gate "$FP" "$GATE_CMD"
43
47
  fi
44
48
  exit 0`,
45
49
  };
@@ -19,6 +19,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
  ];
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const stopTestGate: BuildingBlock;
@@ -0,0 +1,81 @@
1
+ // Stop-event gate (#117): when the agent is about to end its turn with a red
2
+ // test suite, hand the failure back so it keeps working instead of finishing
3
+ // on a broken tree. Two safety valves: Claude's stop_hook_active flag (a Stop
4
+ // hook that blocked once must not block the follow-up stop) and a per-session
5
+ // retry cap kept in .omh/state, which also covers runtimes without that flag.
6
+ // The tree-fingerprint cache shared with commit-test-gate means a suite that
7
+ // just passed on this exact tree is not run again.
8
+ export const stopTestGate = {
9
+ id: "stop-test-gate",
10
+ name: "Stop Test Gate",
11
+ description: "Runs tests when the agent tries to end its turn and sends it back to work while they fail",
12
+ category: "quality",
13
+ event: "Stop",
14
+ canBlock: true,
15
+ params: [
16
+ { name: "testCommand", type: "string", description: "Test command to run before the turn may end", required: true },
17
+ {
18
+ name: "maxRetries",
19
+ type: "number",
20
+ description: "How many times per session the turn may be sent back before the gate gives up and lets it end",
21
+ default: 2,
22
+ required: false,
23
+ },
24
+ {
25
+ name: "cacheTtlSeconds",
26
+ type: "number",
27
+ description: "Skip the run when the working tree is unchanged since the last pass and that pass is younger than this many seconds (0 disables)",
28
+ default: 600,
29
+ required: false,
30
+ },
31
+ ],
32
+ tags: ["stop", "test", "quality", "gate"],
33
+ explain: {
34
+ allowOnce: "make the test command pass; after maxRetries send-backs in one session the gate lets the turn end anyway",
35
+ change: "harness.yaml > hooks > stop-test-gate: change testCommand, maxRetries or cacheTtlSeconds, or remove it, then omh sync",
36
+ },
37
+ template: `#!/bin/bash
38
+ set -euo pipefail
39
+ INPUT=$(cat)
40
+ # A Stop hook that already sent the agent back must not block the follow-up stop.
41
+ [[ "$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null)" == "true" ]] && exit 0
42
+
43
+ SESSION=$(echo "$INPUT" | jq -r '.session_id // "default"' 2>/dev/null | tr -c 'A-Za-z0-9_-' '_')
44
+ STATE_DIR="\${_OMH_STATE_DIR:-.omh/state}"
45
+ COUNT_FILE="$STATE_DIR/stop-test-gate-\${SESSION}.count"
46
+ COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
47
+ [[ "$COUNT" =~ ^[0-9]+$ ]] || COUNT=0
48
+ if [[ "$COUNT" -ge {{maxRetries}} ]]; then
49
+ _log_event "allow" "stop-test-gate: retry cap ({{maxRetries}}) reached for this session; letting the turn end"
50
+ exit 0
51
+ fi
52
+
53
+ FP=$(_omh_tree_fingerprint)
54
+ GATE_CMD=$(cat <<'OMH_GATE_CMD'
55
+ {{{testCommand}}}
56
+ OMH_GATE_CMD
57
+ )
58
+ if _omh_gate_cached stop-test-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
59
+ rm -f "$COUNT_FILE"
60
+ _log_event "allow" "cached: tree unchanged since last passing run"
61
+ exit 0
62
+ fi
63
+
64
+ STATUS=0
65
+ set -o pipefail
66
+ ESC=$'\\x1b'
67
+ OUTPUT=$(NO_COLOR=1 FORCE_COLOR=0 {{{testCommand}}} 2>&1 | sed "s/\${ESC}\\[[0-9;]*[A-Za-z]//g") || STATUS=$?
68
+ if [[ "$STATUS" -ne 0 ]]; then
69
+ mkdir -p "$STATE_DIR" 2>/dev/null || true
70
+ echo $((COUNT + 1)) > "$COUNT_FILE"
71
+ TAIL=$(printf '%s\\n' "$OUTPUT" | tail -n 15)
72
+ REASON="oh-my-harness: the test suite is failing; fix it before ending the turn (attempt $((COUNT + 1))/{{maxRetries}}):
73
+ $TAIL"
74
+ _log_event "block" "$REASON"
75
+ jq -cn --arg r "$REASON" '{decision:"block", reason:$r, hookSpecificOutput:{hookEventName:"Stop", decision:"block", reason:$r}}'
76
+ exit 0
77
+ fi
78
+ rm -f "$COUNT_FILE"
79
+ _omh_gate_record stop-test-gate "$FP" "$GATE_CMD"
80
+ exit 0`,
81
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const stopUncommittedWarn: BuildingBlock;
@@ -0,0 +1,30 @@
1
+ // Stop-event notice (#117): when the turn ends with uncommitted changes, say
2
+ // so as a system message. Never blocks; the point is that the human sees what
3
+ // was left behind instead of discovering it later.
4
+ export const stopUncommittedWarn = {
5
+ id: "stop-uncommitted-warn",
6
+ name: "Stop Uncommitted Warning",
7
+ description: "When the agent ends its turn with uncommitted changes, lists them as a system message (never blocks)",
8
+ category: "git",
9
+ event: "Stop",
10
+ canBlock: false,
11
+ params: [],
12
+ tags: ["stop", "git", "notice"],
13
+ template: `#!/bin/bash
14
+ set -euo pipefail
15
+ INPUT=$(cat)
16
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
17
+ if ! CHANGES=$(git status --porcelain 2>&1); then
18
+ MSG="oh-my-harness: could not read git status, so uncommitted changes may be unreported: $(printf '%s' "$CHANGES" | head -n 3)"
19
+ _log_event "allow" "$MSG"
20
+ jq -cn --arg m "$MSG" '{systemMessage:$m}'
21
+ exit 0
22
+ fi
23
+ [[ -z "$CHANGES" ]] && exit 0
24
+ N=$(printf '%s\\n' "$CHANGES" | wc -l | tr -d ' ')
25
+ MSG="oh-my-harness: $N uncommitted change(s) left behind:
26
+ $(printf '%s\\n' "$CHANGES" | head -n 20)"
27
+ _log_event "allow" "$MSG"
28
+ jq -cn --arg m "$MSG" '{systemMessage:$m}'
29
+ exit 0`,
30
+ };
@@ -31,6 +31,8 @@ export interface HookEntry {
31
31
  block: string;
32
32
  params: Record<string, unknown>;
33
33
  mode?: "block" | "ask";
34
+ /** `omh modify` may never remove or weaken a locked entry (#118). */
35
+ locked?: boolean;
34
36
  }
35
37
  export declare const ParamDefinitionSchema: z.ZodObject<{
36
38
  name: z.ZodString;
@@ -82,7 +84,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
82
84
  tags: z.ZodArray<z.ZodString, "many">;
83
85
  }, "strip", z.ZodTypeAny, {
84
86
  id: string;
85
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
87
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "Stop" | "SubagentStop" | "UserPromptSubmit" | "PreCompact" | "PostCompact" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
86
88
  name: string;
87
89
  params: {
88
90
  name: string;
@@ -99,7 +101,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
99
101
  matcher?: string | undefined;
100
102
  }, {
101
103
  id: string;
102
- event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "UserPromptSubmit" | "Stop" | "PreCompact" | "PostCompact" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
104
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "Stop" | "SubagentStop" | "UserPromptSubmit" | "PreCompact" | "PostCompact" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
103
105
  name: string;
104
106
  params: {
105
107
  name: string;
@@ -119,12 +121,16 @@ export declare const HookEntrySchema: z.ZodObject<{
119
121
  block: z.ZodString;
120
122
  params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
121
123
  mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
124
+ /** `omh modify` may never remove or weaken a locked entry (#118). */
125
+ locked: z.ZodOptional<z.ZodBoolean>;
122
126
  }, "strip", z.ZodTypeAny, {
123
127
  block: string;
124
128
  params: Record<string, unknown>;
125
129
  mode: "block" | "ask";
130
+ locked?: boolean | undefined;
126
131
  }, {
127
132
  block: string;
128
133
  params?: Record<string, unknown> | undefined;
129
134
  mode?: "block" | "ask" | undefined;
135
+ locked?: boolean | undefined;
130
136
  }>;
@@ -22,4 +22,6 @@ export const HookEntrySchema = z.object({
22
22
  block: z.string(),
23
23
  params: z.record(z.unknown()).default({}),
24
24
  mode: z.enum(["block", "ask"]).default("block"),
25
+ /** `omh modify` may never remove or weaken a locked entry (#118). */
26
+ locked: z.boolean().optional(),
25
27
  });
@@ -0,0 +1,8 @@
1
+ export interface ModifyOptions {
2
+ projectDir?: string;
3
+ yes?: boolean;
4
+ dryRun?: boolean;
5
+ }
6
+ export declare function modifyCommand(requestParts: string[], options?: ModifyOptions): Promise<{
7
+ exitCode: number;
8
+ }>;
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+ import chalk from "chalk";
5
+ import { HarnessConfigSchema } from "../../core/harness-schema.js";
6
+ import { detectProject } from "../../detector/project-detector.js";
7
+ import { builtinBlocks } from "../../catalog/blocks/index.js";
8
+ import { resolveTypesafeApiKey, TYPESAFE_MODEL } from "../../nl/typesafe-chooser.js";
9
+ import { modifyWithJev, applyModifications } from "../../nl/typesafe-modify.js";
10
+ export async function modifyCommand(requestParts, options = {}) {
11
+ const projectDir = options.projectDir ?? process.cwd();
12
+ const request = requestParts.join(" ").trim();
13
+ if (!request) {
14
+ console.log('Usage: omh modify "what to change" (e.g. "no auto PRs, make the TDD guard ask instead of block")');
15
+ return { exitCode: 1 };
16
+ }
17
+ const apiKey = resolveTypesafeApiKey(projectDir);
18
+ if (!apiKey) {
19
+ console.log("omh modify needs TYPESAFE_API_KEY (environment or the project's .env): Jev decides which blocks the request refers to.");
20
+ console.log("Without it, edit harness.yaml directly and run `omh sync`.");
21
+ return { exitCode: 1 };
22
+ }
23
+ const harnessPath = path.join(projectDir, "harness.yaml");
24
+ let harness;
25
+ try {
26
+ const parsed = HarnessConfigSchema.safeParse(yaml.load(await fs.readFile(harnessPath, "utf-8")));
27
+ if (!parsed.success) {
28
+ console.log(`harness.yaml does not validate: ${parsed.error.message}`);
29
+ return { exitCode: 1 };
30
+ }
31
+ harness = parsed.data;
32
+ }
33
+ catch {
34
+ console.log("No harness.yaml here. Run `omh init` first.");
35
+ return { exitCode: 1 };
36
+ }
37
+ let facts;
38
+ try {
39
+ facts = await detectProject(projectDir);
40
+ }
41
+ catch { /* non-fatal */ }
42
+ let result;
43
+ try {
44
+ result = await modifyWithJev({ request, harness, blocks: builtinBlocks }, { apiKey });
45
+ }
46
+ catch (err) {
47
+ console.log(`TypeSafe chooser unavailable (${err.message}). Edit harness.yaml directly and run \`omh sync\`.`);
48
+ return { exitCode: 1 };
49
+ }
50
+ const applied = applyModifications(harness, result.decisions, facts, builtinBlocks);
51
+ console.log(chalk.dim(`Jev (${TYPESAFE_MODEL}) read the request, ${result.usage?.input_tokens ?? "?"} input tokens`));
52
+ for (const r of applied.refused)
53
+ console.log(` ${chalk.yellow("refused")} ${r.reason}`);
54
+ if (applied.changes.length === 0) {
55
+ console.log("No changes: Jev did not match the request to any block with enough confidence. Try naming the block or the behaviour.");
56
+ return { exitCode: 0 };
57
+ }
58
+ for (const c of applied.changes) {
59
+ const verb = c.action === "enable" ? chalk.green("enable ") : c.action === "disable" ? chalk.red("disable") : chalk.yellow("ask ");
60
+ console.log(` ${verb} ${c.block}`);
61
+ }
62
+ if (options.dryRun) {
63
+ console.log(chalk.dim("dry run: harness.yaml not written"));
64
+ return { exitCode: 0 };
65
+ }
66
+ if (!options.yes) {
67
+ const { confirm } = await import("@inquirer/prompts");
68
+ if (!(await confirm({ message: "Apply these changes to harness.yaml and re-sync?", default: true }))) {
69
+ console.log("Aborted.");
70
+ return { exitCode: 0 };
71
+ }
72
+ }
73
+ await fs.writeFile(harnessPath, yaml.dump(applied.harness, { lineWidth: 120 }), "utf-8");
74
+ const { syncCommand } = await import("./sync.js");
75
+ const sync = await syncCommand({ projectDir });
76
+ return { exitCode: sync.exitCode };
77
+ }
@@ -14,6 +14,7 @@ export function formatCategoryName(category) {
14
14
  "command-guard": "Command guards",
15
15
  "harness-guard": "Harness self-protection",
16
16
  "no-verify-guard": "Git hook bypass guard",
17
+ "stop-test-gate": "Stop test gate",
17
18
  "force-push-guard": "Force push guard",
18
19
  "branch-guard": "Branch guard",
19
20
  "lockfile-guard": "Lockfile guard",
@@ -195,6 +195,16 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
195
195
  });
196
196
  break;
197
197
  }
198
+ case "stop-test-gate": {
199
+ cases.push({
200
+ name: "Stop with stop_hook_active → ALLOWED (loop guard)",
201
+ category: "stop-test-gate",
202
+ hookScript,
203
+ input: { tool_name: "", tool_input: {}, stop_hook_active: true, session_id: "omh-test" },
204
+ expectation: "allow",
205
+ });
206
+ break;
207
+ }
198
208
  case "no-verify-guard": {
199
209
  cases.push({
200
210
  name: '"git commit --no-verify" → BLOCKED',
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("modify [request...]")
40
+ .description('Edit harness.yaml from a sentence, e.g. omh modify "no auto PRs, make the TDD guard ask" (needs TYPESAFE_API_KEY)')
41
+ .option("-d, --project-dir <dir>", "Project directory")
42
+ .option("-y, --yes", "Apply without confirmation")
43
+ .option("--dry-run", "Show the change set without writing")
44
+ .action(async (request, options) => {
45
+ const { modifyCommand } = await import("./commands/modify.js");
46
+ const result = await modifyCommand(request, options);
47
+ if (result.exitCode !== 0)
48
+ process.exitCode = result.exitCode;
49
+ });
38
50
  program
39
51
  .command("explain")
40
52
  .description("Show the last blocked tool calls in plain language, with how to allow once and how to change the rule")
@@ -29,7 +29,7 @@ function harnessToMergedConfig(harness) {
29
29
  presets: ["harness"],
30
30
  variables,
31
31
  claudeMdSections,
32
- hooks: { preToolUse: [], postToolUse: [], sessionStart: [], notification: [], configChange: [], worktreeCreate: [] },
32
+ hooks: { preToolUse: [], postToolUse: [], sessionStart: [], notification: [], configChange: [], worktreeCreate: [], stop: [] },
33
33
  settings: { permissions: { allow: harness.permissions.allow, deny: harness.permissions.deny } },
34
34
  ...(harness.loop?.enabled ? { loop: harness.loop } : {}),
35
35
  };
@@ -111,6 +111,7 @@ const eventToField = {
111
111
  Notification: "notification",
112
112
  ConfigChange: "configChange",
113
113
  WorktreeCreate: "worktreeCreate",
114
+ Stop: "stop",
114
115
  };
115
116
  export async function harnessToMergedConfigV2(harness, registry, projectDir) {
116
117
  // Start with base conversion (rules, variables, permissions — no inline enforcement scripts)
@@ -156,6 +157,7 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
156
157
  notification: [...(base.hooks.notification ?? []), ...(additionalHooks.notification ?? [])],
157
158
  configChange: [...(base.hooks.configChange ?? []), ...(additionalHooks.configChange ?? [])],
158
159
  worktreeCreate: [...(base.hooks.worktreeCreate ?? []), ...(additionalHooks.worktreeCreate ?? [])],
160
+ stop: [...(base.hooks.stop ?? []), ...(additionalHooks.stop ?? [])],
159
161
  };
160
162
  return {
161
163
  ...base,
@@ -105,14 +105,17 @@ export declare const HarnessConfigSchema: z.ZodObject<{
105
105
  block: z.ZodString;
106
106
  params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
107
107
  mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
108
+ locked: z.ZodOptional<z.ZodBoolean>;
108
109
  }, "strip", z.ZodTypeAny, {
109
110
  block: string;
110
111
  params: Record<string, unknown>;
111
112
  mode: "block" | "ask";
113
+ locked?: boolean | undefined;
112
114
  }, {
113
115
  block: string;
114
116
  params?: Record<string, unknown> | undefined;
115
117
  mode?: "block" | "ask" | undefined;
118
+ locked?: boolean | undefined;
116
119
  }>, "many">>;
117
120
  loop: z.ZodDefault<z.ZodEffects<z.ZodObject<{
118
121
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -205,6 +208,7 @@ export declare const HarnessConfigSchema: z.ZodObject<{
205
208
  block: string;
206
209
  params: Record<string, unknown>;
207
210
  mode: "block" | "ask";
211
+ locked?: boolean | undefined;
208
212
  }[];
209
213
  permissions: {
210
214
  allow: string[];
@@ -264,6 +268,7 @@ export declare const HarnessConfigSchema: z.ZodObject<{
264
268
  block: string;
265
269
  params?: Record<string, unknown> | undefined;
266
270
  mode?: "block" | "ask" | undefined;
271
+ locked?: boolean | undefined;
267
272
  }[] | undefined;
268
273
  permissions?: {
269
274
  allow?: string[] | undefined;
@@ -21,6 +21,8 @@ export interface HooksConfig {
21
21
  notification?: HookDefinition[];
22
22
  configChange?: HookDefinition[];
23
23
  worktreeCreate?: HookDefinition[];
24
+ /** Stop-event hooks (#117): fire when the agent is about to end its turn. No matcher. */
25
+ stop?: HookDefinition[];
24
26
  }
25
27
  export interface SettingsConfig {
26
28
  permissions?: {
@@ -39,6 +39,8 @@ export function defaultParamsFor(blockId, facts) {
39
39
  return facts?.lintCommands?.[0] ? { filePattern: l.glob, command: facts.lintCommands[0] } : null;
40
40
  case "test-on-save":
41
41
  return facts?.testCommands?.[0] ? { testCommand: facts.testCommands[0], filePattern: l.src } : null;
42
+ case "stop-test-gate":
43
+ return facts?.testCommands?.[0] ? { testCommand: facts.testCommands[0] } : null;
42
44
  case "format-on-save":
43
45
  return null; // no formatter detection yet; ponytail: add when the detector learns formatters
44
46
  case "path-guard":
@@ -46,7 +46,7 @@ export function buildCodexHooks(hooksOutput) {
46
46
  continue;
47
47
  }
48
48
  codexHooks.hooks[event] = entries.map((entry) => ({
49
- matcher: normalizeMatcher(entry.matcher),
49
+ matcher: normalizeMatcher(entry.matcher ?? ""),
50
50
  hooks: entry.hooks.map((h) => ({ type: h.type, command: h.command })),
51
51
  }));
52
52
  }
@@ -11,7 +11,7 @@ export interface HookCommand {
11
11
  }
12
12
  export interface HooksOutput {
13
13
  hooksConfig: Record<string, Array<{
14
- matcher: string;
14
+ matcher?: string;
15
15
  hooks: HookCommand[];
16
16
  }>>;
17
17
  generatedFiles: string[];
@@ -20,7 +20,7 @@ export interface HooksPlan {
20
20
  /** Hook script files (each chmod 0o755). */
21
21
  files: PlannedFile[];
22
22
  hooksConfig: Record<string, Array<{
23
- matcher: string;
23
+ matcher?: string;
24
24
  hooks: HookCommand[];
25
25
  }>>;
26
26
  generatedFiles: string[];
@@ -186,11 +186,17 @@ const OMH_TREE_FINGERPRINT = `_omh_tree_fingerprint() {
186
186
  # _omh_gate_cached <name> <ttl-seconds> <fp> -> 0 when the last recorded pass
187
187
  # for <name> is <fp> and younger than ttl.
188
188
  # _omh_gate_record <name> <fp> -> remember <fp> as passed.
189
+ # The optional [cmd] argument becomes part of the cache key, so changing the
190
+ # command in harness.yaml never reuses a pass recorded for the old one.
191
+ _omh_gate_key() {
192
+ local name="\${1:-}" cmd="\${2:-}"
193
+ if [ -n "$cmd" ]; then printf '%s-%s' "$name" "$(printf '%s' "$cmd" | cksum | cut -d' ' -f1)"; else printf '%s' "$name"; fi
194
+ }
189
195
  _omh_gate_cached() {
190
196
  local name="$1" ttl="\${2:-0}" fp="\${3:-none}" file now cached_fp cached_ts
191
197
  [ "$ttl" -gt 0 ] 2>/dev/null || return 1
192
198
  [ "$fp" = "none" ] && return 1
193
- file="\${_OMH_STATE_DIR:-.omh/state}/gate-$name.fp"
199
+ file="\${_OMH_STATE_DIR:-.omh/state}/gate-$(_omh_gate_key "$name" "\${4:-}").fp"
194
200
  [ -f "$file" ] || return 1
195
201
  read -r cached_fp cached_ts < "$file" || return 1
196
202
  now=$(date +%s)
@@ -199,7 +205,7 @@ _omh_gate_cached() {
199
205
  _omh_gate_record() {
200
206
  local name="$1" fp="\${2:-none}" file
201
207
  [ "$fp" = "none" ] && return 0
202
- file="\${_OMH_STATE_DIR:-.omh/state}/gate-$name.fp"
208
+ file="\${_OMH_STATE_DIR:-.omh/state}/gate-$(_omh_gate_key "$name" "\${3:-}").fp"
203
209
  mkdir -p "$(dirname "$file")" 2>/dev/null || true
204
210
  printf '%s %s\\n' "$fp" "$(date +%s)" > "$file"
205
211
  }`;
@@ -438,6 +444,7 @@ export async function computeHooks(options) {
438
444
  ["Notification", config.hooks.notification ?? []],
439
445
  ["ConfigChange", config.hooks.configChange ?? []],
440
446
  ["WorktreeCreate", config.hooks.worktreeCreate ?? []],
447
+ ["Stop", config.hooks.stop ?? []],
441
448
  ];
442
449
  const allHooks = eventMap.flatMap(([event, hooks]) => hooks.map((h) => ({ ...h, event })));
443
450
  // Read previous manifest to identify stale hook files to clean up.
@@ -479,7 +486,8 @@ export async function computeHooks(options) {
479
486
  if (!hooksConfig[p.event])
480
487
  hooksConfig[p.event] = [];
481
488
  hooksConfig[p.event].push({
482
- matcher: p.matcher,
489
+ // Stop (and SubagentStop) have no matcher support in Claude Code: omit the key rather than emit "".
490
+ ...(p.event === "Stop" || p.event === "SubagentStop" ? {} : { matcher: p.matcher }),
483
491
  hooks: [{ type: "command", command: `bash ${shellSingleQuote(p.scriptPath)}` }],
484
492
  });
485
493
  }
@@ -36,7 +36,7 @@ export function extractPiBindings(hooksConfig) {
36
36
  const pre = hooksConfig["PreToolUse"] ?? [];
37
37
  const bindings = [];
38
38
  for (const entry of pre) {
39
- const tools = matcherToPiTools(entry.matcher);
39
+ const tools = matcherToPiTools(entry.matcher ?? "");
40
40
  if (tools.length === 0)
41
41
  continue;
42
42
  for (const hook of entry.hooks) {
@@ -5,7 +5,7 @@ import { type PresetName } from "../core/presets.js";
5
5
  export declare const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
6
6
  export declare const TYPESAFE_MODEL = "jev-latest";
7
7
  /** Blocks a project may or may not want. Always-on and loop-internal blocks are never asked. */
8
- export declare const SELECTABLE_BLOCKS: readonly ["branch-guard", "commit-test-gate", "commit-typecheck-gate", "command-guard", "path-guard", "lockfile-guard", "secret-file-guard", "tdd-guard", "sql-guard", "lint-on-save", "format-on-save", "test-on-save", "auto-pr", "desktop-notify", "compact-context"];
8
+ export declare const SELECTABLE_BLOCKS: readonly ["branch-guard", "commit-test-gate", "commit-typecheck-gate", "command-guard", "path-guard", "lockfile-guard", "secret-file-guard", "tdd-guard", "sql-guard", "lint-on-save", "format-on-save", "test-on-save", "auto-pr", "desktop-notify", "compact-context", "stop-test-gate", "stop-uncommitted-warn"];
9
9
  export type Decision = "on" | "off" | "undecided";
10
10
  export interface ChooserInput {
11
11
  description: string;
@@ -16,6 +16,7 @@ export const SELECTABLE_BLOCKS = [
16
16
  "branch-guard", "commit-test-gate", "commit-typecheck-gate", "command-guard", "path-guard",
17
17
  "lockfile-guard", "secret-file-guard", "tdd-guard", "sql-guard",
18
18
  "lint-on-save", "format-on-save", "test-on-save", "auto-pr", "desktop-notify", "compact-context",
19
+ "stop-test-gate", "stop-uncommitted-warn",
19
20
  ];
20
21
  export const THRESHOLDS = { on: 0.65, off: 0.35, choiceConfidence: 0.5 };
21
22
  export function buildJevRequest(input) {
@@ -0,0 +1,51 @@
1
+ import type { HarnessConfig } from "../core/harness-schema.js";
2
+ import type { BuildingBlock } from "../catalog/types.js";
3
+ import type { ProjectFacts } from "../detector/types.js";
4
+ export declare const MODIFY_ACTIONS: readonly ["enable", "disable", "ask", "keep"];
5
+ export type ModifyAction = (typeof MODIFY_ACTIONS)[number];
6
+ export interface ModifyInput {
7
+ request: string;
8
+ harness: HarnessConfig;
9
+ blocks: Pick<BuildingBlock, "id" | "description">[];
10
+ }
11
+ export interface ModifyRequest {
12
+ model: string;
13
+ state: Record<string, unknown>;
14
+ questions: Record<string, Record<string, unknown>>;
15
+ }
16
+ type ChoiceAnswer = {
17
+ type: "choice";
18
+ choice: string;
19
+ confidence: number;
20
+ probabilities: Record<string, number>;
21
+ };
22
+ export declare function buildModifyRequest(input: ModifyInput): ModifyRequest;
23
+ export declare function routeModifyAnswers(answers: Record<string, ChoiceAnswer | {
24
+ type: string;
25
+ }>): Map<string, ModifyAction>;
26
+ export interface ModifyResult {
27
+ decisions: Map<string, ModifyAction>;
28
+ usage?: {
29
+ input_tokens: number;
30
+ output_tokens: number;
31
+ };
32
+ }
33
+ export declare function modifyWithJev(input: ModifyInput, opts: {
34
+ apiKey: string;
35
+ timeoutMs?: number;
36
+ }): Promise<ModifyResult>;
37
+ export interface AppliedModifications {
38
+ harness: HarnessConfig;
39
+ changes: {
40
+ block: string;
41
+ action: ModifyAction;
42
+ }[];
43
+ refused: {
44
+ block: string;
45
+ action: ModifyAction;
46
+ reason: string;
47
+ }[];
48
+ }
49
+ /** Apply decisions as structured edits. `locked: true` entries are never removed or weakened. */
50
+ export declare function applyModifications(harness: HarnessConfig, decisions: Map<string, ModifyAction>, facts: ProjectFacts | undefined, blocks: Pick<BuildingBlock, "id">[]): AppliedModifications;
51
+ export {};
@@ -0,0 +1,118 @@
1
+ import { defaultParamsFor } from "../core/presets.js";
2
+ import { SELECTABLE_BLOCKS, TYPESAFE_ENDPOINT, TYPESAFE_MODEL, THRESHOLDS } from "./typesafe-chooser.js";
3
+ // `omh modify "request"` (#118) through Jev: one choice question per
4
+ // selectable block ("what does the request ask for this block?"), answers
5
+ // routed by confidence, applied as structured edits to harness.yaml. No
6
+ // text is generated; params come from the detector or catalog defaults.
7
+ export const MODIFY_ACTIONS = ["enable", "disable", "ask", "keep"];
8
+ export function buildModifyRequest(input) {
9
+ const byId = new Map(input.blocks.map((b) => [b.id, b]));
10
+ const enabled = new Set(input.harness.hooks.map((h) => h.block));
11
+ const questions = {};
12
+ for (const id of SELECTABLE_BLOCKS) {
13
+ const b = byId.get(id);
14
+ if (!b)
15
+ continue;
16
+ const state = enabled.has(id) ? "currently ENABLED" : "currently DISABLED";
17
+ questions[`block:${id}`] = {
18
+ type: "choice",
19
+ instructions: `The user wants to change their guardrail configuration. Block '${id}' (${state}) ${lowerFirst(b.description)}. ` +
20
+ "What does the user's request ask for this block? Answer keep unless the request clearly refers to it.",
21
+ criteria: {
22
+ enable: "turn this block on (add it, or restore it to blocking mode)",
23
+ disable: "turn this block off entirely",
24
+ ask: "keep it but make it ask for confirmation instead of blocking",
25
+ keep: "the request does not concern this block",
26
+ },
27
+ };
28
+ }
29
+ return {
30
+ model: TYPESAFE_MODEL,
31
+ state: {
32
+ request: input.request,
33
+ currently_enabled: [...enabled],
34
+ ask_mode: input.harness.hooks.filter((h) => h.mode === "ask").map((h) => h.block),
35
+ },
36
+ questions,
37
+ };
38
+ }
39
+ export function routeModifyAnswers(answers) {
40
+ const out = new Map();
41
+ for (const [key, a] of Object.entries(answers)) {
42
+ if (!key.startsWith("block:") || a.type !== "choice")
43
+ continue;
44
+ const id = key.slice("block:".length);
45
+ if (!SELECTABLE_BLOCKS.includes(id))
46
+ continue;
47
+ const c = a;
48
+ if (!MODIFY_ACTIONS.includes(c.choice) || c.choice === "keep")
49
+ continue;
50
+ if (typeof c.confidence !== "number" || c.confidence < THRESHOLDS.choiceConfidence)
51
+ continue;
52
+ out.set(id, c.choice);
53
+ }
54
+ return out;
55
+ }
56
+ export async function modifyWithJev(input, opts) {
57
+ const res = await fetch(TYPESAFE_ENDPOINT, {
58
+ method: "POST",
59
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
60
+ body: JSON.stringify(buildModifyRequest(input)),
61
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
62
+ });
63
+ if (!res.ok) {
64
+ const body = (await res.text().catch(() => "")).slice(0, 200);
65
+ throw new Error(`TypeSafe API error ${res.status}: ${body || res.statusText}`);
66
+ }
67
+ const json = (await res.json());
68
+ return { decisions: routeModifyAnswers(json.answers ?? {}), usage: json.usage };
69
+ }
70
+ /** Apply decisions as structured edits. `locked: true` entries are never removed or weakened. */
71
+ export function applyModifications(harness, decisions, facts, blocks) {
72
+ const known = new Set(blocks.map((b) => b.id));
73
+ const hooks = harness.hooks.map((h) => ({ ...h, params: { ...h.params } }));
74
+ const changes = [];
75
+ const refused = [];
76
+ for (const [block, action] of decisions) {
77
+ if (action === "keep" || !known.has(block))
78
+ continue;
79
+ const idx = hooks.findIndex((h) => h.block === block);
80
+ const existing = idx >= 0 ? hooks[idx] : undefined;
81
+ if (existing?.locked && (action === "disable" || action === "ask")) {
82
+ refused.push({ block, action, reason: `${block} is locked in harness.yaml; remove \`locked: true\` first` });
83
+ continue;
84
+ }
85
+ if (action === "disable") {
86
+ if (idx >= 0) {
87
+ hooks.splice(idx, 1);
88
+ changes.push({ block, action });
89
+ }
90
+ }
91
+ else if (action === "ask") {
92
+ if (existing && existing.mode !== "ask") {
93
+ existing.mode = "ask";
94
+ changes.push({ block, action });
95
+ }
96
+ }
97
+ else if (action === "enable") {
98
+ if (existing) {
99
+ if (existing.mode === "ask") {
100
+ existing.mode = "block";
101
+ changes.push({ block, action });
102
+ }
103
+ continue;
104
+ }
105
+ const params = defaultParamsFor(block, facts);
106
+ if (params === null) {
107
+ refused.push({ block, action, reason: `${block} needs a parameter the project detector could not supply; add it to harness.yaml by hand` });
108
+ continue;
109
+ }
110
+ hooks.push({ block, params, mode: "block" });
111
+ changes.push({ block, action });
112
+ }
113
+ }
114
+ return { harness: { ...harness, hooks }, changes, refused };
115
+ }
116
+ function lowerFirst(s) {
117
+ return s ? s[0].toLowerCase() + s.slice(1) : s;
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {