oh-my-harness 0.5.0 → 0.6.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.
@@ -131,7 +131,10 @@ async function initWithNL(projectDir, presetsDir, options) {
131
131
  const stackNames = harness.project.stacks.map((s) => `${s.name} (${s.framework})`).join(", ");
132
132
  console.log(`\nStacks: ${stackNames}`);
133
133
  console.log(`Rules: ${harness.rules.length}`);
134
- console.log(`Pre-commit checks: ${harness.enforcement.preCommit.join(", ") || "none"}`);
134
+ const { mergeEnforcementAndHooks } = await import("../../core/harness-converter.js");
135
+ const allHooks = mergeEnforcementAndHooks(harness);
136
+ const hookSummary = allHooks.map((h) => h.block).join(", ") || "none";
137
+ console.log(`Hooks: ${hookSummary}`);
135
138
  if (!options.yes) {
136
139
  const { confirm } = await import("@inquirer/prompts");
137
140
  const ok = await confirm({ message: "Proceed with this configuration?", default: true });
@@ -3,9 +3,10 @@ import chalk from "chalk";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import yaml from "js-yaml";
6
- import { getRegisteredHooks, generateTestCases, generateBlockTestCases, runTestCase } from "../harness-tester.js";
6
+ import { getRegisteredHooks, generateBlockTestCases, runTestCase } from "../harness-tester.js";
7
7
  import { checkHarnessCommands } from "../command-checker.js";
8
8
  import { HarnessConfigSchema } from "../../core/harness-schema.js";
9
+ import { mergeEnforcementAndHooks } from "../../core/harness-converter.js";
9
10
  import { builtinBlocks } from "../../catalog/blocks/index.js";
10
11
  export function formatCategoryName(category) {
11
12
  const names = {
@@ -27,18 +28,14 @@ export async function testCommand(options = {}) {
27
28
  const projectDir = options.projectDir ?? process.cwd();
28
29
  // 1. harness.yaml 읽기
29
30
  const harnessPath = path.join(projectDir, "harness.yaml");
30
- let enforcement = { blockedPaths: [], blockedCommands: [] };
31
31
  let hookEntries = [];
32
32
  try {
33
33
  const raw = await fs.readFile(harnessPath, "utf-8");
34
34
  const parsed = yaml.load(raw);
35
35
  const result = HarnessConfigSchema.safeParse(parsed);
36
36
  if (result.success) {
37
- enforcement = {
38
- blockedPaths: result.data.enforcement.blockedPaths,
39
- blockedCommands: result.data.enforcement.blockedCommands,
40
- };
41
- hookEntries = result.data.hooks ?? [];
37
+ // Merge enforcement-derived hooks with explicit hooks
38
+ hookEntries = mergeEnforcementAndHooks(result.data);
42
39
  }
43
40
  else {
44
41
  console.log(chalk.yellow(`Warning: harness.yaml schema validation failed: ${result.error.message}`));
@@ -85,12 +82,8 @@ export async function testCommand(options = {}) {
85
82
  catch {
86
83
  // git 없으면 undefined
87
84
  }
88
- const enforcementCases = generateTestCases(hooks, enforcement, currentBranch);
89
- const blockCases = generateBlockTestCases(hookEntries, builtinBlocks, currentBranch);
90
- // Block cases take priority — only keep enforcement cases for categories not covered by blocks
91
- const blockCategories = new Set(blockCases.map((c) => c.category));
92
- const uniqueEnforcementCases = enforcementCases.filter((c) => !blockCategories.has(c.category));
93
- const testCases = [...uniqueEnforcementCases, ...blockCases];
85
+ // Generate test cases from block-based hooks only
86
+ const testCases = generateBlockTestCases(hookEntries, builtinBlocks, currentBranch);
94
87
  const results = [];
95
88
  // 카테고리별 그룹핑
96
89
  const categories = [...new Set(testCases.map((tc) => tc.category))];
@@ -29,13 +29,9 @@ export declare function getRegisteredHooks(projectDir: string): Promise<{
29
29
  matcher: string;
30
30
  command: string;
31
31
  }[]>;
32
- export declare function generateTestCases(hooks: {
32
+ export declare function runTestCase(projectDir: string, testCase: TestCase): Promise<TestResult>;
33
+ export declare function generateBlockTestCases(hookEntries: HookEntry[], blocks: BuildingBlock[], currentBranch?: string, registeredHooks?: {
33
34
  event: string;
34
35
  matcher: string;
35
36
  command: string;
36
- }[], enforcement: {
37
- blockedPaths?: string[];
38
- blockedCommands?: string[];
39
- }, currentBranch?: string): TestCase[];
40
- export declare function runTestCase(projectDir: string, testCase: TestCase): Promise<TestResult>;
41
- export declare function generateBlockTestCases(hookEntries: HookEntry[], blocks: BuildingBlock[], currentBranch?: string): TestCase[];
37
+ }[]): TestCase[];
@@ -69,104 +69,6 @@ export async function getRegisteredHooks(projectDir) {
69
69
  }
70
70
  return hooks;
71
71
  }
72
- // harness.yaml + settings.json에서 테스트 케이스 자동 생성
73
- export function generateTestCases(hooks, enforcement, currentBranch) {
74
- const cases = [];
75
- for (const hook of hooks) {
76
- const scriptName = hook.command.replace(/^bash\s+/, "").replace(/^\.\//, "");
77
- // path-guard / file-guard
78
- if (scriptName.includes("file-guard") || scriptName.includes("path-guard")) {
79
- for (const blocked of enforcement.blockedPaths ?? []) {
80
- const testPath = blocked.endsWith("/")
81
- ? `${blocked}test-file.js`
82
- : blocked.startsWith("*")
83
- ? `test${blocked.slice(1)}`
84
- : blocked;
85
- cases.push({
86
- name: `${testPath} → BLOCKED`,
87
- category: "path-guard",
88
- hookScript: scriptName,
89
- input: { tool_name: "Edit", tool_input: { file_path: testPath } },
90
- expectation: "block",
91
- });
92
- }
93
- // allow case
94
- cases.push({
95
- name: "src/index.ts → ALLOWED",
96
- category: "path-guard",
97
- hookScript: scriptName,
98
- input: { tool_name: "Edit", tool_input: { file_path: "src/index.ts" } },
99
- expectation: "allow",
100
- });
101
- }
102
- // command-guard
103
- if (scriptName.includes("command-guard")) {
104
- for (const blocked of enforcement.blockedCommands ?? []) {
105
- cases.push({
106
- name: `"${blocked}" → BLOCKED`,
107
- category: "command-guard",
108
- hookScript: scriptName,
109
- input: { tool_name: "Bash", tool_input: { command: blocked } },
110
- expectation: "block",
111
- });
112
- }
113
- // allow case
114
- cases.push({
115
- name: '"npm test" → ALLOWED',
116
- category: "command-guard",
117
- hookScript: scriptName,
118
- input: { tool_name: "Bash", tool_input: { command: "npm test" } },
119
- expectation: "allow",
120
- });
121
- }
122
- // branch-guard
123
- if (scriptName.includes("branch-guard")) {
124
- const isProtected = currentBranch === "main" || currentBranch === "master";
125
- cases.push({
126
- name: `git commit on ${currentBranch ?? "unknown"} → ${isProtected ? "BLOCKED" : "ALLOWED"}`,
127
- category: "branch-guard",
128
- hookScript: scriptName,
129
- input: { tool_name: "Bash", tool_input: { command: "git commit -m 'test'" } },
130
- expectation: isProtected ? "block" : "allow",
131
- });
132
- }
133
- // lockfile-guard
134
- if (scriptName.includes("lockfile-guard")) {
135
- cases.push({
136
- name: "package-lock.json → BLOCKED",
137
- category: "lockfile-guard",
138
- hookScript: scriptName,
139
- input: { tool_name: "Edit", tool_input: { file_path: "package-lock.json" } },
140
- expectation: "block",
141
- });
142
- cases.push({
143
- name: "package.json → ALLOWED",
144
- category: "lockfile-guard",
145
- hookScript: scriptName,
146
- input: { tool_name: "Edit", tool_input: { file_path: "package.json" } },
147
- expectation: "allow",
148
- });
149
- }
150
- // secret-file-guard
151
- if (scriptName.includes("secret-file-guard")) {
152
- cases.push({
153
- name: ".env → BLOCKED",
154
- category: "secret-file-guard",
155
- hookScript: scriptName,
156
- input: { tool_name: "Edit", tool_input: { file_path: ".env" } },
157
- expectation: "block",
158
- });
159
- cases.push({
160
- name: "src/app.ts → ALLOWED",
161
- category: "secret-file-guard",
162
- hookScript: scriptName,
163
- input: { tool_name: "Edit", tool_input: { file_path: "src/app.ts" } },
164
- expectation: "allow",
165
- });
166
- }
167
- }
168
- return cases;
169
- }
170
72
  // 테스트 실행
171
73
  export async function runTestCase(projectDir, testCase) {
172
74
  const hookPath = path.join(projectDir, testCase.hookScript);
@@ -202,7 +104,7 @@ export async function runTestCase(projectDir, testCase) {
202
104
  }
203
105
  }
204
106
  // 블록 기반 테스트 케이스 생성
205
- export function generateBlockTestCases(hookEntries, blocks, currentBranch) {
107
+ export function generateBlockTestCases(hookEntries, blocks, currentBranch, registeredHooks) {
206
108
  const cases = [];
207
109
  for (const entry of hookEntries) {
208
110
  const block = blocks.find((b) => b.id === entry.block);
@@ -211,7 +113,24 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch) {
211
113
  if (!block.canBlock)
212
114
  continue;
213
115
  const params = applyDefaults(block, entry.params);
214
- const hookScript = `.claude/hooks/catalog-${block.id}.sh`;
116
+ // Find actual script path from registered hooks, fallback to catalog- prefix
117
+ // Map block ids to enforcement script name patterns
118
+ const blockIdAliases = {
119
+ "path-guard": ["path-guard", "file-guard"],
120
+ "command-guard": ["command-guard"],
121
+ "branch-guard": ["branch-guard"],
122
+ "lockfile-guard": ["lockfile-guard"],
123
+ "secret-file-guard": ["secret-file-guard"],
124
+ "tdd-guard": ["tdd-guard"],
125
+ };
126
+ const aliases = blockIdAliases[block.id] ?? [block.id];
127
+ const matchedHook = registeredHooks?.find((h) => {
128
+ const scriptName = h.command.replace(/^bash\s+/, "");
129
+ return aliases.some((alias) => scriptName.includes(alias));
130
+ });
131
+ const hookScript = matchedHook
132
+ ? matchedHook.command.replace(/^bash\s+/, "")
133
+ : `.claude/hooks/catalog-${block.id}.sh`;
215
134
  switch (block.id) {
216
135
  case "path-guard": {
217
136
  const blockedPaths = params.blockedPaths ?? [];
@@ -11,5 +11,11 @@ export interface ToolRef {
11
11
  source: string;
12
12
  lookupCommand: string;
13
13
  }
14
+ /**
15
+ * Extracts tool references from a HarnessConfig.
16
+ * Collects commands from:
17
+ * 1. enforcement.preCommit and enforcement.postSave (legacy)
18
+ * 2. hooks[] params (commit-test-gate, commit-typecheck-gate, lint-on-save)
19
+ */
14
20
  export declare function extractToolNames(config: HarnessConfig): ToolRef[];
15
21
  export declare function checkReferencedTools(config: HarnessConfig): Promise<ToolCheck[]>;
@@ -58,25 +58,46 @@ function extractBinary(command) {
58
58
  }
59
59
  return { name: parts[0], lookupCommand: parts[0] };
60
60
  }
61
+ /**
62
+ * Extracts tool references from a HarnessConfig.
63
+ * Collects commands from:
64
+ * 1. enforcement.preCommit and enforcement.postSave (legacy)
65
+ * 2. hooks[] params (commit-test-gate, commit-typecheck-gate, lint-on-save)
66
+ */
61
67
  export function extractToolNames(config) {
62
68
  const seen = new Set();
63
69
  const tools = [];
64
- for (const cmd of config.enforcement.preCommit) {
70
+ function addCommand(cmd, source) {
65
71
  const result = extractBinary(cmd);
66
72
  if (!result)
67
- continue;
73
+ return;
68
74
  if (!seen.has(result.name)) {
69
75
  seen.add(result.name);
70
- tools.push({ name: result.name, lookupCommand: result.lookupCommand, source: "pre-commit" });
76
+ tools.push({ name: result.name, lookupCommand: result.lookupCommand, source });
71
77
  }
72
78
  }
79
+ // Extract from enforcement fields (legacy)
80
+ for (const cmd of config.enforcement.preCommit) {
81
+ addCommand(cmd, "pre-commit");
82
+ }
73
83
  for (const ps of config.enforcement.postSave) {
74
- const result = extractBinary(ps.command);
75
- if (!result)
76
- continue;
77
- if (!seen.has(result.name)) {
78
- seen.add(result.name);
79
- tools.push({ name: result.name, lookupCommand: result.lookupCommand, source: "post-save hook" });
84
+ addCommand(ps.command, "post-save hook");
85
+ }
86
+ // Extract from hooks[] params
87
+ const hookEntries = config.hooks ?? [];
88
+ for (const entry of hookEntries) {
89
+ const params = entry.params;
90
+ if (entry.block === "commit-test-gate" && typeof params.testCommand === "string") {
91
+ addCommand(params.testCommand, "commit-test-gate hook");
92
+ }
93
+ if (entry.block === "commit-typecheck-gate" && typeof params.typecheckCommand === "string") {
94
+ addCommand(params.typecheckCommand, "commit-typecheck-gate hook");
95
+ }
96
+ if (entry.block === "lint-on-save" && typeof params.command === "string") {
97
+ addCommand(params.command, "lint-on-save hook");
98
+ }
99
+ if (entry.block === "format-on-save" && typeof params.command === "string") {
100
+ addCommand(params.command, "format-on-save hook");
80
101
  }
81
102
  }
82
103
  return tools;
@@ -10,7 +10,7 @@ import { loadAndMergePresets, writeHarnessState } from "../commands/init.js";
10
10
  import { mergePresets } from "../../core/config-merger.js";
11
11
  import { generate } from "../../core/generator.js";
12
12
  import { generateHarnessConfig } from "../../nl/parse-intent.js";
13
- import { harnessToMergedConfig } from "../../core/harness-converter.js";
13
+ import { mergeEnforcementAndHooks } from "../../core/harness-converter.js";
14
14
  import { HarnessConfigSchema } from "../../core/harness-schema.js";
15
15
  import { detectProject } from "../../detector/project-detector.js";
16
16
  function getDefaultPresetsDir() {
@@ -55,27 +55,16 @@ export function formatConfigSummary(config) {
55
55
  lines.push(` ${i + 1}. ${rule.title} (priority: ${rule.priority})`);
56
56
  }
57
57
  }
58
- // Enforcement
59
- const hasEnforcement = config.enforcement.preCommit.length > 0 ||
60
- config.enforcement.blockedPaths.length > 0 ||
61
- config.enforcement.blockedCommands.length > 0 ||
62
- config.enforcement.postSave.length > 0;
63
- if (hasEnforcement) {
58
+ // Hooks (merged from enforcement + explicit hooks)
59
+ const allHooks = mergeEnforcementAndHooks(config);
60
+ if (allHooks.length > 0) {
64
61
  lines.push("");
65
- lines.push(" Enforcement:");
66
- if (config.enforcement.preCommit.length > 0) {
67
- lines.push(` Pre-commit: ${config.enforcement.preCommit.join(", ")}`);
68
- }
69
- if (config.enforcement.blockedPaths.length > 0) {
70
- lines.push(` Blocked paths: ${config.enforcement.blockedPaths.join(", ")}`);
71
- }
72
- if (config.enforcement.blockedCommands.length > 0) {
73
- lines.push(` Blocked commands: ${config.enforcement.blockedCommands.join(", ")}`);
74
- }
75
- if (config.enforcement.postSave.length > 0) {
76
- for (const ps of config.enforcement.postSave) {
77
- lines.push(` Post-save: ${ps.command} on ${ps.pattern}`);
78
- }
62
+ lines.push(" Hooks:");
63
+ for (const hook of allHooks) {
64
+ const paramSummary = Object.entries(hook.params)
65
+ .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : String(v)}`)
66
+ .join(", ");
67
+ lines.push(` ${hook.block}${paramSummary ? ` (${paramSummary})` : ""}`);
79
68
  }
80
69
  }
81
70
  return lines.join("\n");
@@ -353,8 +342,9 @@ export async function runInitTUI(options) {
353
342
  const generatedFiles = [];
354
343
  try {
355
344
  if (harnessConfig) {
356
- // NL or import mode: convert harness config to merged config
357
- const config = harnessToMergedConfig(harnessConfig);
345
+ // NL or import mode: convert harness config to merged config via v2 (catalog pipeline)
346
+ const { harnessToMergedConfigV2 } = await import("../../core/harness-converter-v2.js");
347
+ const config = await harnessToMergedConfigV2(harnessConfig);
358
348
  const result = await generate({ projectDir, config });
359
349
  generatedFiles.push(...result.files);
360
350
  // Save harness.yaml
@@ -397,17 +387,14 @@ export async function runInitTUI(options) {
397
387
  summaryLines.push(`Generated ${generatedFiles.length} files in ${chalk.cyan(projectDir)}`);
398
388
  summaryLines.push("");
399
389
  if (harnessConfig) {
400
- summaryLines.push("Active enforcement:");
401
- if (harnessConfig.enforcement.preCommit.length > 0) {
402
- summaryLines.push(` \u2022 Pre-commit: ${harnessConfig.enforcement.preCommit.join(", ")}`);
403
- }
404
- if (harnessConfig.enforcement.blockedPaths.length > 0) {
405
- summaryLines.push(` \u2022 Protected paths: ${harnessConfig.enforcement.blockedPaths.join(", ")}`);
406
- }
407
- if (harnessConfig.enforcement.blockedCommands.length > 0) {
408
- summaryLines.push(" \u2022 Dangerous commands blocked");
390
+ const activeHooks = mergeEnforcementAndHooks(harnessConfig);
391
+ if (activeHooks.length > 0) {
392
+ summaryLines.push("Active hooks:");
393
+ for (const hook of activeHooks) {
394
+ summaryLines.push(` \u2022 ${hook.block}`);
395
+ }
396
+ summaryLines.push("");
409
397
  }
410
- summaryLines.push("");
411
398
  }
412
399
  summaryLines.push("Next steps:");
413
400
  summaryLines.push(" 1. Review harness.yaml to customize");
@@ -1,23 +1,23 @@
1
- import { harnessToMergedConfig } from "./harness-converter.js";
1
+ import { harnessToMergedConfig, mergeEnforcementAndHooks } from "./harness-converter.js";
2
2
  import { createDefaultRegistry } from "../catalog/registry.js";
3
3
  import { convertHookEntries } from "../catalog/converter.js";
4
4
  export async function harnessToMergedConfigV2(harness, registry, projectDir) {
5
- // Start with v1 conversion (handles enforcement field)
5
+ // Start with base conversion (rules, variables, permissions — no inline enforcement scripts)
6
6
  const base = harnessToMergedConfig(harness);
7
- // If no hooks or empty, return base config unchanged
8
- if (!harness.hooks || harness.hooks.length === 0) {
7
+ // Merge enforcement-derived hooks with explicit hooks (dedup by block id)
8
+ const allHookEntries = mergeEnforcementAndHooks(harness);
9
+ // If no hook entries at all, return base config unchanged
10
+ if (allHookEntries.length === 0) {
9
11
  return { ...base };
10
12
  }
11
13
  // Resolve registry — use provided one or create the default
12
14
  const resolvedRegistry = registry ?? (await createDefaultRegistry());
13
- const catalogResult = await convertHookEntries(harness.hooks, resolvedRegistry, projectDir ?? ".");
15
+ const catalogResult = await convertHookEntries(allHookEntries, resolvedRegistry, projectDir ?? ".");
14
16
  // If there are errors, return base config with catalogErrors attached
15
17
  if (catalogResult.errors.length > 0) {
16
18
  return { ...base, catalogErrors: catalogResult.errors };
17
19
  }
18
20
  // Convert hooksConfig entries from catalog into HookDefinition format.
19
- // Catalog hooks are appended after v1 hooks so v2 catalog takes precedence
20
- // (last writer wins in Claude settings; appended = higher effective priority).
21
21
  const additionalPreToolUse = [];
22
22
  const additionalPostToolUse = [];
23
23
  for (const [event, entries] of Object.entries(catalogResult.hooksConfig)) {
@@ -1,3 +1,20 @@
1
1
  import type { HarnessConfig } from "./harness-schema.js";
2
2
  import type { MergedConfig } from "./preset-types.js";
3
+ import type { HookEntry } from "../catalog/types.js";
4
+ /**
5
+ * Converts legacy enforcement fields into catalog-based HookEntry[].
6
+ * This allows old harness.yaml configs with enforcement to be processed
7
+ * through the unified catalog pipeline.
8
+ */
9
+ export declare function convertEnforcementToHooks(enforcement: HarnessConfig["enforcement"]): HookEntry[];
10
+ /**
11
+ * Merges enforcement-derived hooks with explicit harness.hooks,
12
+ * deduplicating by block id (explicit hooks take priority).
13
+ */
14
+ export declare function mergeEnforcementAndHooks(harness: HarnessConfig): HookEntry[];
15
+ /**
16
+ * Converts HarnessConfig to MergedConfig.
17
+ * No longer generates inline enforcement scripts — enforcement is converted
18
+ * to catalog hook entries and processed through the v2 catalog pipeline.
19
+ */
3
20
  export declare function harnessToMergedConfig(harness: HarnessConfig): MergedConfig;
@@ -1,3 +1,51 @@
1
+ /**
2
+ * Converts legacy enforcement fields into catalog-based HookEntry[].
3
+ * This allows old harness.yaml configs with enforcement to be processed
4
+ * through the unified catalog pipeline.
5
+ */
6
+ export function convertEnforcementToHooks(enforcement) {
7
+ const hooks = [];
8
+ // preCommit → commit-test-gate or commit-typecheck-gate
9
+ for (const cmd of enforcement.preCommit) {
10
+ if (/\btsc\b/.test(cmd)) {
11
+ hooks.push({ block: "commit-typecheck-gate", params: { typecheckCommand: cmd } });
12
+ }
13
+ else {
14
+ hooks.push({ block: "commit-test-gate", params: { testCommand: cmd } });
15
+ }
16
+ }
17
+ // blockedPaths → path-guard
18
+ if (enforcement.blockedPaths.length > 0) {
19
+ hooks.push({ block: "path-guard", params: { blockedPaths: enforcement.blockedPaths } });
20
+ }
21
+ // blockedCommands → command-guard
22
+ if (enforcement.blockedCommands.length > 0) {
23
+ hooks.push({ block: "command-guard", params: { patterns: enforcement.blockedCommands } });
24
+ }
25
+ // postSave → lint-on-save (each entry)
26
+ for (const ps of enforcement.postSave) {
27
+ hooks.push({ block: "lint-on-save", params: { filePattern: ps.pattern, command: ps.command } });
28
+ }
29
+ return hooks;
30
+ }
31
+ /**
32
+ * Merges enforcement-derived hooks with explicit harness.hooks,
33
+ * deduplicating by block id (explicit hooks take priority).
34
+ */
35
+ export function mergeEnforcementAndHooks(harness) {
36
+ const enforcementHooks = convertEnforcementToHooks(harness.enforcement);
37
+ const explicitHooks = harness.hooks ?? [];
38
+ // Explicit hooks take priority — only keep enforcement hooks for blocks
39
+ // not already covered by explicit hooks
40
+ const explicitBlockIds = new Set(explicitHooks.map((h) => h.block));
41
+ const uniqueEnforcementHooks = enforcementHooks.filter((h) => !explicitBlockIds.has(h.block));
42
+ return [...uniqueEnforcementHooks, ...explicitHooks];
43
+ }
44
+ /**
45
+ * Converts HarnessConfig to MergedConfig.
46
+ * No longer generates inline enforcement scripts — enforcement is converted
47
+ * to catalog hook entries and processed through the v2 catalog pipeline.
48
+ */
1
49
  export function harnessToMergedConfig(harness) {
2
50
  // Build variables from first stack
3
51
  const variables = {};
@@ -21,114 +69,13 @@ export function harnessToMergedConfig(harness) {
21
69
  priority: rule.priority,
22
70
  }))
23
71
  .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50));
24
- // Build hooks
25
- const preToolUse = [];
26
- const postToolUse = [];
27
- // preCommit hook
28
- if (harness.enforcement.preCommit.length > 0) {
29
- const commands = harness.enforcement.preCommit
30
- .map((cmd) => {
31
- const safeCmdJson = cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
32
- return ` echo "oh-my-harness: Running ${cmd} before commit..." >&2\n if ! ${cmd} >&2 2>&1; then\n echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: ${safeCmdJson} failed, commit blocked\\"}"\n exit 0\n fi`;
33
- })
34
- .join("\n");
35
- preToolUse.push({
36
- id: "harness-pre-commit",
37
- matcher: "Bash",
38
- description: "Runs configured checks before git commit",
39
- inline: `#!/bin/bash
40
- set -euo pipefail
41
- INPUT=$(cat)
42
- COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
43
- if echo "$COMMAND" | grep -qE "git commit"; then
44
- ${commands}
45
- fi
46
- exit 0
47
- `,
48
- });
49
- }
50
- // file-guard hook from blockedPaths
51
- if (harness.enforcement.blockedPaths.length > 0) {
52
- const patterns = harness.enforcement.blockedPaths
53
- .map((p) => `"${p}"`)
54
- .join(" ");
55
- preToolUse.push({
56
- id: "harness-file-guard",
57
- matcher: "Edit|Write",
58
- description: "Prevents writing to blocked paths",
59
- inline: `#!/bin/bash
60
- set -euo pipefail
61
- INPUT=$(cat)
62
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
63
- [[ -z "$FILE_PATH" ]] && exit 0
64
- BLOCKED=(${patterns})
65
- for pattern in "\${BLOCKED[@]}"; do
66
- if [[ "$pattern" == */ ]]; then
67
- if [[ "$FILE_PATH" == *"/$pattern"* ]] || [[ "$FILE_PATH" == "$pattern"* ]]; then
68
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: protected path $pattern\\"}"
69
- exit 0
70
- fi
71
- elif [[ "$pattern" == \\** ]]; then
72
- suffix="\${pattern#\\*}"
73
- if [[ "$FILE_PATH" == *"\$suffix" ]]; then
74
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: protected path $pattern\\"}"
75
- exit 0
76
- fi
77
- fi
78
- done
79
- exit 0
80
- `,
81
- });
82
- }
83
- // command-guard hook from blockedCommands
84
- if (harness.enforcement.blockedCommands.length > 0) {
85
- const patterns = harness.enforcement.blockedCommands
86
- .map((c) => `"${c}"`)
87
- .join(" ");
88
- preToolUse.push({
89
- id: "harness-command-guard",
90
- matcher: "Bash",
91
- description: "Blocks dangerous shell commands",
92
- inline: `#!/bin/bash
93
- set -euo pipefail
94
- INPUT=$(cat)
95
- COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
96
- DANGEROUS_PATTERNS=(${patterns})
97
- for pattern in "\${DANGEROUS_PATTERNS[@]}"; do
98
- if echo "$COMMAND" | grep -qF "$pattern"; then
99
- echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: dangerous command blocked\\"}"
100
- exit 0
101
- fi
102
- done
103
- exit 0
104
- `,
105
- });
106
- }
107
- // postSave hooks
108
- for (let i = 0; i < harness.enforcement.postSave.length; i++) {
109
- const ps = harness.enforcement.postSave[i];
110
- postToolUse.push({
111
- id: `harness-post-save-${i}`,
112
- matcher: "Edit|Write",
113
- description: `Runs ${ps.command} on ${ps.pattern} files after save`,
114
- inline: `#!/bin/bash
115
- INPUT=$(cat)
116
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
117
- [[ -z "$FILE_PATH" ]] && exit 0
118
- if [[ "$FILE_PATH" == ${ps.pattern} ]]; then
119
- ${ps.command} "$FILE_PATH" 2>/dev/null || true
120
- fi
121
- exit 0
122
- `,
123
- });
124
- }
125
72
  return {
126
73
  presets: ["harness"],
127
74
  variables,
128
75
  claudeMdSections,
129
76
  hooks: {
130
- preToolUse,
131
- postToolUse,
77
+ preToolUse: [],
78
+ postToolUse: [],
132
79
  },
133
80
  settings: {
134
81
  permissions: {
@@ -40,7 +40,7 @@ Do NOT guess commands — use the detected values above.\n`;
40
40
  export function buildHarnessGenerationPrompt(description, catalogBlocks, projectFacts) {
41
41
  const factsSection = projectFacts ? buildProjectFactsSection(projectFacts) : "";
42
42
  const catalogSection = catalogBlocks && catalogBlocks.length > 0
43
- ? `\nAvailable building blocks (MUST use in the hooks field — prefer hooks over enforcement):
43
+ ? `\nAvailable building blocks (MUST use in the hooks field):
44
44
  ${catalogBlocks
45
45
  .map((b) => {
46
46
  const paramDesc = b.params.length > 0
@@ -50,7 +50,7 @@ ${catalogBlocks
50
50
  })
51
51
  .join("\n")}
52
52
 
53
- IMPORTANT: Match the user's description to the most relevant blocks above. Use hooks for ALL enforcement that has a matching block. Only use enforcement as a fallback for commands with no matching block.
53
+ IMPORTANT: Match the user's description to the most relevant blocks above. Use hooks for ALL needs.
54
54
  `
55
55
  : "";
56
56
  return `You are a configuration generator for oh-my-harness, an AI code agent harness tool. Given a project description, generate a complete harness.yaml configuration in YAML format. Return ONLY the YAML content with no markdown formatting.
@@ -59,9 +59,7 @@ The harness.yaml schema has these fields:
59
59
  - version: must be "1.0"
60
60
  - project: object with name (optional string), description (optional string), stacks (array of {name, framework, language, packageManager?, testRunner?, linter?})
61
61
  - rules: array of {id, title, content (markdown), priority (number, lower = higher in file)}
62
- - enforcement: object with preCommit (array of full executable shell commands like "pnpm test", "npx eslint", "npx tsc --noEmit"), blockedPaths (array of glob patterns), blockedCommands (array of dangerous commands), postSave (array of {pattern, command})
63
- - hooks: array of {block, params} — MUST use catalog building blocks here. This is the primary mechanism for enforcement. Match user requirements to available blocks.
64
- - enforcement: fallback for commands with no matching block. Only use enforcement.preCommit when no catalog block covers the use case.
62
+ - hooks: array of {block, params} MUST use catalog building blocks here. This is the primary mechanism for all guards and automation (pre-commit checks, path guards, command guards, lint-on-save, etc.).
65
63
  - permissions: object with allow (array of permission strings like "Bash(npm test*)") and deny (array)
66
64
  ${catalogSection}${factsSection}
67
65
  Example 1 - Next.js app:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {