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.
- package/dist/cli/commands/init.js +4 -1
- package/dist/cli/commands/test.js +6 -13
- package/dist/cli/harness-tester.d.ts +3 -7
- package/dist/cli/harness-tester.js +19 -100
- package/dist/cli/tool-checker.d.ts +6 -0
- package/dist/cli/tool-checker.js +30 -9
- package/dist/cli/tui/init-flow.js +20 -33
- package/dist/core/harness-converter-v2.js +7 -7
- package/dist/core/harness-converter.d.ts +17 -0
- package/dist/core/harness-converter.js +50 -103
- package/dist/nl/prompt-templates.js +3 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
const
|
|
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
|
|
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
|
-
}[]
|
|
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
|
-
|
|
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[]>;
|
package/dist/cli/tool-checker.js
CHANGED
|
@@ -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
|
-
|
|
70
|
+
function addCommand(cmd, source) {
|
|
65
71
|
const result = extractBinary(cmd);
|
|
66
72
|
if (!result)
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
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("
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
lines.push(`
|
|
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
|
|
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
|
-
|
|
401
|
-
if (
|
|
402
|
-
summaryLines.push(
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
5
|
+
// Start with base conversion (rules, variables, permissions — no inline enforcement scripts)
|
|
6
6
|
const base = harnessToMergedConfig(harness);
|
|
7
|
-
//
|
|
8
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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:
|