oh-my-harness 0.3.2 → 0.4.1
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/catalog/blocks/branch-guard.js +2 -0
- package/dist/catalog/blocks/command-guard.js +1 -0
- package/dist/catalog/blocks/commit-test-gate.js +1 -0
- package/dist/catalog/blocks/commit-typecheck-gate.js +1 -0
- package/dist/catalog/blocks/index.js +2 -0
- package/dist/catalog/blocks/lockfile-guard.js +1 -0
- package/dist/catalog/blocks/path-guard.js +3 -0
- package/dist/catalog/blocks/secret-file-guard.js +1 -0
- package/dist/catalog/blocks/tdd-guard.d.ts +2 -0
- package/dist/catalog/blocks/tdd-guard.js +82 -0
- package/dist/cli/event-logger.d.ts +21 -0
- package/dist/cli/event-logger.js +64 -0
- package/dist/core/generator.js +1 -1
- package/dist/detector/detectors/node.js +24 -7
- package/dist/generators/hooks.d.ts +1 -0
- package/dist/generators/hooks.js +34 -1
- package/dist/nl/prompt-templates.js +42 -21
- package/package.json +1 -1
|
@@ -19,6 +19,7 @@ if echo "$COMMAND" | grep -qE "git commit|git push"; then
|
|
|
19
19
|
[[ -z "$BRANCH" ]] && exit 0
|
|
20
20
|
MAIN='{{mainBranch}}'
|
|
21
21
|
if [[ "$BRANCH" == "$MAIN" ]] || [[ "$BRANCH" == "master" && "$MAIN" == "main" ]]; then
|
|
22
|
+
_log_event "block" "oh-my-harness: direct commits to $BRANCH are blocked. Create a feature branch."
|
|
22
23
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: direct commits to $BRANCH are blocked. Create a feature branch.\\"}"
|
|
23
24
|
exit 0
|
|
24
25
|
fi
|
|
@@ -36,6 +37,7 @@ if echo "$COMMAND" | grep -qE "git commit|git push"; then
|
|
|
36
37
|
fi
|
|
37
38
|
fi
|
|
38
39
|
if [[ "$MERGED" -eq 1 ]]; then
|
|
40
|
+
_log_event "block" "oh-my-harness: branch $BRANCH has already been merged to $MAIN. Create a new branch."
|
|
39
41
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: branch $BRANCH has already been merged to $MAIN. Create a new branch.\\"}"
|
|
40
42
|
exit 0
|
|
41
43
|
fi
|
|
@@ -24,6 +24,7 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
|
24
24
|
PATTERNS=({{#each patterns}}"{{this}}" {{/each}})
|
|
25
25
|
for PATTERN in "\${PATTERNS[@]}"; do
|
|
26
26
|
if echo "$COMMAND" | grep -qF "$PATTERN"; then
|
|
27
|
+
_log_event "block" "oh-my-harness: command matches blocked pattern: $PATTERN"
|
|
27
28
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: command matches blocked pattern: $PATTERN\\"}"
|
|
28
29
|
exit 0
|
|
29
30
|
fi
|
|
@@ -17,6 +17,7 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
|
17
17
|
if echo "$COMMAND" | grep -qE "git commit"; then
|
|
18
18
|
echo "oh-my-harness: Running {{testCommand}} before commit..." >&2
|
|
19
19
|
if ! {{testCommand}} >&2 2>&1; then
|
|
20
|
+
_log_event "block" "oh-my-harness: pre-commit check failed"
|
|
20
21
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
|
|
21
22
|
exit 0
|
|
22
23
|
fi
|
|
@@ -17,6 +17,7 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
|
17
17
|
if echo "$COMMAND" | grep -qE "git commit"; then
|
|
18
18
|
echo "oh-my-harness: Running {{typecheckCommand}} before commit..." >&2
|
|
19
19
|
if ! {{typecheckCommand}} >&2 2>&1; then
|
|
20
|
+
_log_event "block" "oh-my-harness: pre-commit check failed"
|
|
20
21
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
|
|
21
22
|
exit 0
|
|
22
23
|
fi
|
|
@@ -8,6 +8,7 @@ import { lintOnSave } from "./lint-on-save.js";
|
|
|
8
8
|
import { formatOnSave } from "./format-on-save.js";
|
|
9
9
|
import { autoPr } from "./auto-pr.js";
|
|
10
10
|
import { secretFileGuard } from "./secret-file-guard.js";
|
|
11
|
+
import { tddGuard } from "./tdd-guard.js";
|
|
11
12
|
export const builtinBlocks = [
|
|
12
13
|
branchGuard,
|
|
13
14
|
commitTestGate,
|
|
@@ -19,4 +20,5 @@ export const builtinBlocks = [
|
|
|
19
20
|
formatOnSave,
|
|
20
21
|
autoPr,
|
|
21
22
|
secretFileGuard,
|
|
23
|
+
tddGuard,
|
|
22
24
|
];
|
|
@@ -25,6 +25,7 @@ BASENAME=$(basename "$FILE_PATH")
|
|
|
25
25
|
LOCKFILES=({{#each lockfiles}}"{{this}}" {{/each}})
|
|
26
26
|
for LOCKFILE in "\${LOCKFILES[@]}"; do
|
|
27
27
|
if [[ "$BASENAME" == "$LOCKFILE" ]]; then
|
|
28
|
+
_log_event "block" "oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead."
|
|
28
29
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead.\\"}"
|
|
29
30
|
exit 0
|
|
30
31
|
fi
|
|
@@ -24,17 +24,20 @@ BLOCKED_PATHS=({{#each blockedPaths}}"{{this}}" {{/each}})
|
|
|
24
24
|
for BLOCKED in "\${BLOCKED_PATHS[@]}"; do
|
|
25
25
|
if [[ "$BLOCKED" == */ ]]; then
|
|
26
26
|
if [[ "$FILE_PATH" == "$BLOCKED"* || "$FILE_PATH" == *"/$BLOCKED"* ]]; then
|
|
27
|
+
_log_event "block" "oh-my-harness: file path matches blocked directory: $BLOCKED"
|
|
27
28
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: file path matches blocked directory: $BLOCKED\\"}"
|
|
28
29
|
exit 0
|
|
29
30
|
fi
|
|
30
31
|
elif [[ "$BLOCKED" == \\** ]]; then
|
|
31
32
|
PATTERN="\${BLOCKED#\\*}"
|
|
32
33
|
if [[ "$FILE_PATH" == *"$PATTERN" ]]; then
|
|
34
|
+
_log_event "block" "oh-my-harness: file path matches blocked pattern: $BLOCKED"
|
|
33
35
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: file path matches blocked pattern: $BLOCKED\\"}"
|
|
34
36
|
exit 0
|
|
35
37
|
fi
|
|
36
38
|
else
|
|
37
39
|
if [[ "$FILE_PATH" == "$BLOCKED" || "$FILE_PATH" == *"/$BLOCKED" ]]; then
|
|
40
|
+
_log_event "block" "oh-my-harness: file path matches blocked path: $BLOCKED"
|
|
38
41
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: file path matches blocked path: $BLOCKED\\"}"
|
|
39
42
|
exit 0
|
|
40
43
|
fi
|
|
@@ -25,6 +25,7 @@ BASENAME=$(basename "$FILE_PATH")
|
|
|
25
25
|
PATTERNS=({{#each patterns}}"{{this}}" {{/each}})
|
|
26
26
|
for PATTERN in "\${PATTERNS[@]}"; do
|
|
27
27
|
if [[ "$BASENAME" == $PATTERN ]]; then
|
|
28
|
+
_log_event "block" "oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN"
|
|
28
29
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN\\"}"
|
|
29
30
|
exit 0
|
|
30
31
|
fi
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const tddGuard = {
|
|
2
|
+
id: "tdd-guard",
|
|
3
|
+
name: "TDD Guard",
|
|
4
|
+
description: "Blocks source file edits unless corresponding test file was modified first",
|
|
5
|
+
category: "quality",
|
|
6
|
+
event: "PreToolUse",
|
|
7
|
+
matcher: "Edit|Write",
|
|
8
|
+
canBlock: true,
|
|
9
|
+
params: [
|
|
10
|
+
{
|
|
11
|
+
name: "srcPattern",
|
|
12
|
+
type: "string",
|
|
13
|
+
description: "Regex pattern for source files to guard (default: .ts/.tsx/.js/.jsx)",
|
|
14
|
+
required: false,
|
|
15
|
+
default: "\\.(ts|tsx|js|jsx)$",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "testPattern",
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Regex pattern for test files (default: .test.ts/.spec.ts etc.)",
|
|
21
|
+
required: false,
|
|
22
|
+
default: "\\.(test|spec)\\.(ts|tsx|js|jsx)$",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
template: `#!/bin/bash
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
INPUT=$(cat)
|
|
28
|
+
FILE_PATH=$(echo "\$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
|
|
29
|
+
[[ -z "\$FILE_PATH" ]] && exit 0
|
|
30
|
+
|
|
31
|
+
# 비코드 파일은 통과
|
|
32
|
+
case "\$FILE_PATH" in
|
|
33
|
+
*.json|*.yaml|*.yml|*.md|*.sh|*.css|*.html|*.svg|*.png|*.jpg) exit 0 ;;
|
|
34
|
+
esac
|
|
35
|
+
|
|
36
|
+
# edit-history 상태 파일
|
|
37
|
+
STATE_DIR=".claude/hooks/.state"
|
|
38
|
+
HISTORY_FILE="\$STATE_DIR/edit-history.json"
|
|
39
|
+
mkdir -p "\$STATE_DIR" 2>/dev/null || true
|
|
40
|
+
|
|
41
|
+
if echo "\$FILE_PATH" | grep -qE '{{testPattern}}'; then
|
|
42
|
+
# 테스트 파일 수정 → 기록 + 통과
|
|
43
|
+
if [[ ! -f "\$HISTORY_FILE" ]]; then
|
|
44
|
+
echo '{"edits":[]}' > "\$HISTORY_FILE"
|
|
45
|
+
fi
|
|
46
|
+
UPDATED=$(jq --arg f "\$FILE_PATH" '.edits += [$f] | .edits |= unique' "\$HISTORY_FILE" 2>/dev/null) || true
|
|
47
|
+
if [[ -n "\$UPDATED" ]]; then
|
|
48
|
+
echo "\$UPDATED" > "\$HISTORY_FILE"
|
|
49
|
+
fi
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# 소스 파일 (.ts/.tsx/.js/.jsx) 이 아니면 통과
|
|
54
|
+
if ! echo "\$FILE_PATH" | grep -qE '{{srcPattern}}'; then
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# 대응 테스트 파일 확인
|
|
59
|
+
BASENAME=$(basename "\$FILE_PATH" | sed -E 's/\\.(ts|tsx|js|jsx)$//')
|
|
60
|
+
TEST_SUFFIX=".test."
|
|
61
|
+
|
|
62
|
+
if [[ ! -f "\$HISTORY_FILE" ]]; then
|
|
63
|
+
_log_event "block" "oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요"
|
|
64
|
+
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요\\"}"
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# edit-history에서 테스트 파일 검색
|
|
69
|
+
if jq -e --arg b "\$BASENAME" '.edits[] | select(contains($b + ".test.") or contains($b + ".spec."))' "\$HISTORY_FILE" >/dev/null 2>&1; then
|
|
70
|
+
# 테스트 먼저 수정됨 → 소스 기록 + 통과
|
|
71
|
+
UPDATED=$(jq --arg f "\$FILE_PATH" '.edits += [$f] | .edits |= unique' "\$HISTORY_FILE" 2>/dev/null) || true
|
|
72
|
+
if [[ -n "\$UPDATED" ]]; then
|
|
73
|
+
echo "\$UPDATED" > "\$HISTORY_FILE"
|
|
74
|
+
fi
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
_log_event "block" "oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요"
|
|
79
|
+
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: TDD — \${BASENAME}\${TEST_SUFFIX}* 테스트 파일을 먼저 수정하세요\\"}"
|
|
80
|
+
exit 0`,
|
|
81
|
+
tags: ["tdd", "workflow", "quality"],
|
|
82
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface HookEvent {
|
|
2
|
+
ts: string;
|
|
3
|
+
event: string;
|
|
4
|
+
hook: string;
|
|
5
|
+
decision: "block" | "allow";
|
|
6
|
+
reason?: string;
|
|
7
|
+
tool?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function appendEvent(projectDir: string, hookEvent: HookEvent): Promise<void>;
|
|
10
|
+
export declare function readEvents(projectDir: string): Promise<HookEvent[]>;
|
|
11
|
+
export declare function getSessionEvents(projectDir: string, since?: Date): Promise<HookEvent[]>;
|
|
12
|
+
export interface EventStats {
|
|
13
|
+
totalEvents: number;
|
|
14
|
+
blockCount: number;
|
|
15
|
+
allowCount: number;
|
|
16
|
+
byHook: Record<string, {
|
|
17
|
+
block: number;
|
|
18
|
+
allow: number;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export declare function aggregateStats(events: HookEvent[]): EventStats;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const STATE_DIR = ".claude/hooks/.state";
|
|
4
|
+
const EVENTS_FILE = "events.jsonl";
|
|
5
|
+
export async function appendEvent(projectDir, hookEvent) {
|
|
6
|
+
const stateDir = path.join(projectDir, STATE_DIR);
|
|
7
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
8
|
+
const filePath = path.join(stateDir, EVENTS_FILE);
|
|
9
|
+
await fs.appendFile(filePath, JSON.stringify(hookEvent) + "\n", "utf-8");
|
|
10
|
+
}
|
|
11
|
+
export async function readEvents(projectDir) {
|
|
12
|
+
const filePath = path.join(projectDir, STATE_DIR, EVENTS_FILE);
|
|
13
|
+
let content;
|
|
14
|
+
try {
|
|
15
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const events = [];
|
|
21
|
+
for (const line of content.split("\n")) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
continue;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(trimmed);
|
|
27
|
+
if (typeof parsed.ts === "string" &&
|
|
28
|
+
typeof parsed.event === "string" &&
|
|
29
|
+
typeof parsed.hook === "string" &&
|
|
30
|
+
(parsed.decision === "block" || parsed.decision === "allow")) {
|
|
31
|
+
events.push(parsed);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// 잘못된 줄 무시
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return events;
|
|
39
|
+
}
|
|
40
|
+
export async function getSessionEvents(projectDir, since) {
|
|
41
|
+
const events = await readEvents(projectDir);
|
|
42
|
+
if (!since)
|
|
43
|
+
return events;
|
|
44
|
+
return events.filter((e) => new Date(e.ts) >= since);
|
|
45
|
+
}
|
|
46
|
+
export function aggregateStats(events) {
|
|
47
|
+
const stats = {
|
|
48
|
+
totalEvents: events.length,
|
|
49
|
+
blockCount: 0,
|
|
50
|
+
allowCount: 0,
|
|
51
|
+
byHook: {},
|
|
52
|
+
};
|
|
53
|
+
for (const e of events) {
|
|
54
|
+
if (e.decision === "block")
|
|
55
|
+
stats.blockCount++;
|
|
56
|
+
else
|
|
57
|
+
stats.allowCount++;
|
|
58
|
+
if (!stats.byHook[e.hook]) {
|
|
59
|
+
stats.byHook[e.hook] = { block: 0, allow: 0 };
|
|
60
|
+
}
|
|
61
|
+
stats.byHook[e.hook][e.decision]++;
|
|
62
|
+
}
|
|
63
|
+
return stats;
|
|
64
|
+
}
|
package/dist/core/generator.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function generate(options) {
|
|
|
15
15
|
await generateSettings({ projectDir, config, hooksOutput });
|
|
16
16
|
files.push(`${projectDir}/.claude/settings.json`);
|
|
17
17
|
// Update .gitignore
|
|
18
|
-
await updateGitignore(projectDir, [".claude/hooks/"]);
|
|
18
|
+
await updateGitignore(projectDir, [".claude/hooks/", ".claude/hooks/.state/"]);
|
|
19
19
|
files.push(`${projectDir}/.gitignore`);
|
|
20
20
|
return { files };
|
|
21
21
|
}
|
|
@@ -18,6 +18,24 @@ async function readJsonFile(filePath) {
|
|
|
18
18
|
return null;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve scripts.test content into a direct executable command.
|
|
23
|
+
* - Prevents watch mode hang (vitest → vitest run)
|
|
24
|
+
* - Adds npx prefix for direct runner invocation
|
|
25
|
+
*/
|
|
26
|
+
function resolveTestCommand(testScript) {
|
|
27
|
+
const trimmed = testScript.trim();
|
|
28
|
+
// vitest without "run" → add --run to prevent watch mode
|
|
29
|
+
if (/^vitest$/.test(trimmed)) {
|
|
30
|
+
return "npx vitest run";
|
|
31
|
+
}
|
|
32
|
+
// Known runners: prefix with npx if not already
|
|
33
|
+
if (/^(vitest|jest|mocha)\b/.test(trimmed)) {
|
|
34
|
+
return trimmed.startsWith("npx ") ? trimmed : `npx ${trimmed}`;
|
|
35
|
+
}
|
|
36
|
+
// Unknown script content: use as-is (e.g. custom shell commands)
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
21
39
|
export const nodeDetector = {
|
|
22
40
|
name: "node",
|
|
23
41
|
detect: async (projectDir) => {
|
|
@@ -53,19 +71,18 @@ export const nodeDetector = {
|
|
|
53
71
|
packageManagers.push("npm");
|
|
54
72
|
detectedFiles.push("package-lock.json");
|
|
55
73
|
}
|
|
56
|
-
// Determine lint command prefix
|
|
74
|
+
// Determine lint command prefix based on package manager
|
|
57
75
|
const lintCmd = pm === "npm" ? "npm run lint" : `${pm} lint`;
|
|
58
|
-
const testCmdDefault = pm === "npm" ? "npm test" : `${pm} test`;
|
|
59
76
|
// Read package.json to inspect scripts
|
|
60
77
|
const pkg = await readJsonFile(packageJsonPath);
|
|
61
78
|
const scripts = pkg && typeof pkg.scripts === "object" && pkg.scripts !== null
|
|
62
79
|
? pkg.scripts
|
|
63
80
|
: {};
|
|
64
|
-
// Detect test runner from scripts.test
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
testCommands.push(
|
|
81
|
+
// Detect test runner from scripts.test — use direct command instead of npm test wrapper
|
|
82
|
+
const rawTestScript = scripts["test"];
|
|
83
|
+
if (typeof rawTestScript === "string" && rawTestScript.trim().length > 0) {
|
|
84
|
+
const resolved = resolveTestCommand(rawTestScript);
|
|
85
|
+
testCommands.push(resolved);
|
|
69
86
|
}
|
|
70
87
|
lintCommands.push(lintCmd);
|
|
71
88
|
// Detect Next.js
|
package/dist/generators/hooks.js
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { mkdir, writeFile, chmod } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
function buildLoggerSnippet(event) {
|
|
4
|
+
return `# --- oh-my-harness event logger ---
|
|
5
|
+
_OMH_STATE_DIR=".claude/hooks/.state"
|
|
6
|
+
mkdir -p "$_OMH_STATE_DIR" 2>/dev/null || true
|
|
7
|
+
_OMH_HOOK_NAME="$(basename "$0")"
|
|
8
|
+
_OMH_EVENT="${event}"
|
|
9
|
+
_OMH_LOGGED=0
|
|
10
|
+
_log_event() {
|
|
11
|
+
_OMH_LOGGED=1
|
|
12
|
+
local decision="\${1:-allow}" reason="\${2:-}"
|
|
13
|
+
printf '{"ts":"%s","event":"%s","hook":"%s","decision":"%s","reason":"%s"}\\n' \\
|
|
14
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$_OMH_EVENT" "$_OMH_HOOK_NAME" "$decision" "$reason" \\
|
|
15
|
+
>> "$_OMH_STATE_DIR/events.jsonl"
|
|
16
|
+
}
|
|
17
|
+
trap '[ "$_OMH_LOGGED" -eq 0 ] && _log_event "allow"' EXIT
|
|
18
|
+
# --- end logger ---`;
|
|
19
|
+
}
|
|
20
|
+
export function wrapWithLogger(script, event = "unknown") {
|
|
21
|
+
const snippet = buildLoggerSnippet(event);
|
|
22
|
+
if (script.includes("INPUT=$(cat)")) {
|
|
23
|
+
return script.replace("INPUT=$(cat)", `INPUT=$(cat)\n\n${snippet}`);
|
|
24
|
+
}
|
|
25
|
+
if (script.includes("set -euo pipefail")) {
|
|
26
|
+
return script.replace("set -euo pipefail", `set -euo pipefail\n\n${snippet}`);
|
|
27
|
+
}
|
|
28
|
+
// shebang 패턴: #!/bin/bash, #!/usr/bin/env bash, #!/bin/sh 등
|
|
29
|
+
const shebangMatch = script.match(/^#!.+$/m);
|
|
30
|
+
if (shebangMatch) {
|
|
31
|
+
return script.replace(shebangMatch[0], `${shebangMatch[0]}\n\n${snippet}`);
|
|
32
|
+
}
|
|
33
|
+
return `${snippet}\n${script}`;
|
|
34
|
+
}
|
|
3
35
|
export async function generateHooks(options) {
|
|
4
36
|
const { projectDir, config } = options;
|
|
5
37
|
const hooksDir = join(projectDir, ".claude/hooks");
|
|
@@ -20,7 +52,8 @@ export async function generateHooks(options) {
|
|
|
20
52
|
const safeId = hook.id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
21
53
|
const scriptName = `${safeId}.sh`;
|
|
22
54
|
const scriptPath = join(hooksDir, scriptName);
|
|
23
|
-
|
|
55
|
+
const wrappedScript = wrapWithLogger(hook.inline, hook.event);
|
|
56
|
+
await writeFile(scriptPath, wrappedScript, "utf8");
|
|
24
57
|
await chmod(scriptPath, 0o755);
|
|
25
58
|
generatedFiles.push(scriptPath);
|
|
26
59
|
const entry = {
|
|
@@ -40,15 +40,18 @@ 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 (use in the hooks field):
|
|
43
|
+
? `\nAvailable building blocks (MUST use in the hooks field — prefer hooks over enforcement):
|
|
44
44
|
${catalogBlocks
|
|
45
45
|
.map((b) => {
|
|
46
46
|
const paramDesc = b.params.length > 0
|
|
47
|
-
? `
|
|
47
|
+
? ` params: ${b.params.map((p) => `${p.name}${p.required ? " (required)" : p.default !== undefined ? ` (default: ${String(p.default)})` : ""}`).join(", ")}`
|
|
48
48
|
: "";
|
|
49
|
-
return `- ${b.id}
|
|
49
|
+
return `- block: ${b.id} — ${b.description}.${paramDesc}`;
|
|
50
50
|
})
|
|
51
|
-
.join("\n")}
|
|
51
|
+
.join("\n")}
|
|
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.
|
|
54
|
+
`
|
|
52
55
|
: "";
|
|
53
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.
|
|
54
57
|
|
|
@@ -57,7 +60,8 @@ The harness.yaml schema has these fields:
|
|
|
57
60
|
- project: object with name (optional string), description (optional string), stacks (array of {name, framework, language, packageManager?, testRunner?, linter?})
|
|
58
61
|
- rules: array of {id, title, content (markdown), priority (number, lower = higher in file)}
|
|
59
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})
|
|
60
|
-
- hooks: array of {block, params} — use catalog building blocks
|
|
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.
|
|
61
65
|
- permissions: object with allow (array of permission strings like "Bash(npm test*)") and deny (array)
|
|
62
66
|
${catalogSection}${factsSection}
|
|
63
67
|
Example 1 - Next.js app:
|
|
@@ -87,15 +91,24 @@ rules:
|
|
|
87
91
|
- Use vitest + @testing-library/react for component tests
|
|
88
92
|
- Every component MUST have a corresponding .test.tsx file
|
|
89
93
|
priority: 21
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
hooks:
|
|
95
|
+
- block: branch-guard
|
|
96
|
+
- block: commit-test-gate
|
|
97
|
+
params:
|
|
98
|
+
testCommand: "pnpm test"
|
|
99
|
+
- block: commit-typecheck-gate
|
|
100
|
+
params:
|
|
101
|
+
typecheckCommand: "npx tsc --noEmit"
|
|
102
|
+
- block: lint-on-save
|
|
103
|
+
params:
|
|
104
|
+
lintCommand: "npx eslint --fix"
|
|
105
|
+
pattern: "*.{ts,tsx}"
|
|
106
|
+
- block: path-guard
|
|
107
|
+
params:
|
|
108
|
+
blockedPaths: ".next/,node_modules/,*.min.js"
|
|
109
|
+
- block: command-guard
|
|
110
|
+
params:
|
|
111
|
+
blockedCommands: "rm -rf /,sudo rm"
|
|
99
112
|
permissions:
|
|
100
113
|
allow:
|
|
101
114
|
- "Bash(pnpm install*)"
|
|
@@ -125,13 +138,21 @@ rules:
|
|
|
125
138
|
- Use Pydantic v2 models for all request/response schemas
|
|
126
139
|
- Use dependency injection for database sessions, auth, and shared services
|
|
127
140
|
priority: 20
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
hooks:
|
|
142
|
+
- block: branch-guard
|
|
143
|
+
- block: commit-test-gate
|
|
144
|
+
params:
|
|
145
|
+
testCommand: "uv run pytest"
|
|
146
|
+
- block: lint-on-save
|
|
147
|
+
params:
|
|
148
|
+
lintCommand: "ruff check --fix"
|
|
149
|
+
pattern: "*.py"
|
|
150
|
+
- block: path-guard
|
|
151
|
+
params:
|
|
152
|
+
blockedPaths: "__pycache__/,.venv/,*.pyc"
|
|
153
|
+
- block: command-guard
|
|
154
|
+
params:
|
|
155
|
+
blockedCommands: "rm -rf /,sudo rm,pip install"
|
|
135
156
|
permissions:
|
|
136
157
|
allow:
|
|
137
158
|
- "Bash(pytest*)"
|