oh-my-harness 0.10.0 → 0.10.2

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.
@@ -44,17 +44,14 @@ SRC_RE='{{{srcPattern}}}'
44
44
  TEST_RE="\${TEST_RE//\\\\\\\\/\\\\}"
45
45
  SRC_RE="\${SRC_RE//\\\\\\\\/\\\\}"
46
46
  if [[ "\$FILE_PATH" =~ \$TEST_RE ]]; then
47
- # 테스트 파일 수정 → 기록 + 통과 (flock으로 원자적 읽기/쓰기)
48
- (
49
- flock -x 200
50
- if [[ ! -f "\$HISTORY_FILE" ]]; then
51
- echo '{"edits":[]}' > "\$HISTORY_FILE"
52
- fi
53
- UPDATED=$(jq --arg f "\$FILE_PATH" '.edits += [$f] | .edits |= unique' "\$HISTORY_FILE" 2>/dev/null) || true
54
- if [[ -n "\$UPDATED" ]]; then
55
- echo "\$UPDATED" > "\$HISTORY_FILE"
56
- fi
57
- ) 200>"\$HISTORY_FILE.lock"
47
+ # 테스트 파일 수정 → 기록 + 통과 (tmp+mv로 원자적 쓰기, 크로스 플랫폼)
48
+ if [[ ! -f "\$HISTORY_FILE" ]]; then
49
+ echo '{"edits":[]}' > "\$HISTORY_FILE"
50
+ fi
51
+ UPDATED=$(jq --arg f "\$FILE_PATH" '.edits += [$f] | .edits |= unique' "\$HISTORY_FILE" 2>/dev/null) || true
52
+ if [[ -n "\$UPDATED" ]]; then
53
+ echo "\$UPDATED" > "\$HISTORY_FILE.tmp" && mv "\$HISTORY_FILE.tmp" "\$HISTORY_FILE"
54
+ fi
58
55
  exit 0
59
56
  fi
60
57
 
@@ -73,25 +70,20 @@ if [[ ! -f "\$HISTORY_FILE" ]]; then
73
70
  exit 0
74
71
  fi
75
72
 
76
- # edit-history에서 테스트 파일 검색 (flock으로 원자적 읽기/쓰기)
77
- DECISION=$(
78
- (
79
- flock -x 200
80
- if jq -e --arg b "\$BASENAME" '.edits[] | select(contains($b) and (contains(".test.") or contains(".spec.") or contains("test_")))' "\$HISTORY_FILE" >/dev/null 2>&1; then
81
- # 테스트 먼저 수정됨 매칭 테스트 기록 소비(제거) + 소스 기록 + 통과
82
- UPDATED=$(jq --arg b "\$BASENAME" --arg f "\$FILE_PATH" '
83
- .edits |= [.[] | select((contains($b) and (contains(".test.") or contains(".spec.") or contains("test_"))) | not)]
84
- | .edits += [$f] | .edits |= unique
85
- ' "\$HISTORY_FILE" 2>/dev/null) || true
86
- if [[ -n "\$UPDATED" ]]; then
87
- echo "\$UPDATED" > "\$HISTORY_FILE"
88
- fi
89
- echo "allow"
90
- else
91
- echo "block"
92
- fi
93
- ) 200>"\$HISTORY_FILE.lock"
94
- )
73
+ # edit-history에서 테스트 파일 검색 (tmp+mv로 원자적 쓰기, 크로스 플랫폼)
74
+ if jq -e --arg b "\$BASENAME" '.edits[] | select(contains($b) and (contains(".test.") or contains(".spec.") or contains("test_")))' "\$HISTORY_FILE" >/dev/null 2>&1; then
75
+ # 테스트 먼저 수정됨 → 매칭 테스트 기록 소비(제거) + 소스 기록 + 통과
76
+ UPDATED=$(jq --arg b "\$BASENAME" --arg f "\$FILE_PATH" '
77
+ .edits |= [.[] | select((contains($b) and (contains(".test.") or contains(".spec.") or contains("test_"))) | not)]
78
+ | .edits += [$f] | .edits |= unique
79
+ ' "\$HISTORY_FILE" 2>/dev/null) || true
80
+ if [[ -n "\$UPDATED" ]]; then
81
+ echo "\$UPDATED" > "\$HISTORY_FILE.tmp" && mv "\$HISTORY_FILE.tmp" "\$HISTORY_FILE"
82
+ fi
83
+ DECISION="allow"
84
+ else
85
+ DECISION="block"
86
+ fi
95
87
 
96
88
  if [[ "\$DECISION" == "allow" ]]; then
97
89
  exit 0
@@ -61,7 +61,7 @@ export async function checkHarnessCommands(hooks, projectDir) {
61
61
  const fs = await import("node:fs/promises");
62
62
  const path = await import("node:path");
63
63
  for (const hook of hooks) {
64
- const scriptPath = hook.command.replace(/^bash\s+/, "");
64
+ const scriptPath = hook.command.replace(/^bash\s+/, "").replace(/^"|"$/g, "");
65
65
  const fullPath = path.join(projectDir, scriptPath);
66
66
  let content;
67
67
  try {
@@ -2,7 +2,7 @@ export interface HookEvent {
2
2
  ts: string;
3
3
  event: string;
4
4
  hook: string;
5
- decision: "block" | "allow";
5
+ decision: "block" | "allow" | "error";
6
6
  reason?: string;
7
7
  tool?: string;
8
8
  }
@@ -13,9 +13,11 @@ export interface EventStats {
13
13
  totalEvents: number;
14
14
  blockCount: number;
15
15
  allowCount: number;
16
+ errorCount: number;
16
17
  byHook: Record<string, {
17
18
  block: number;
18
19
  allow: number;
20
+ error: number;
19
21
  }>;
20
22
  }
21
23
  export declare function aggregateStats(events: HookEvent[]): EventStats;
@@ -27,7 +27,7 @@ export async function readEvents(projectDir) {
27
27
  if (typeof parsed.ts === "string" &&
28
28
  typeof parsed.event === "string" &&
29
29
  typeof parsed.hook === "string" &&
30
- (parsed.decision === "block" || parsed.decision === "allow")) {
30
+ (parsed.decision === "block" || parsed.decision === "allow" || parsed.decision === "error")) {
31
31
  events.push(parsed);
32
32
  }
33
33
  }
@@ -48,15 +48,18 @@ export function aggregateStats(events) {
48
48
  totalEvents: events.length,
49
49
  blockCount: 0,
50
50
  allowCount: 0,
51
+ errorCount: 0,
51
52
  byHook: {},
52
53
  };
53
54
  for (const e of events) {
54
55
  if (e.decision === "block")
55
56
  stats.blockCount++;
57
+ else if (e.decision === "error")
58
+ stats.errorCount++;
56
59
  else
57
60
  stats.allowCount++;
58
61
  if (!stats.byHook[e.hook]) {
59
- stats.byHook[e.hook] = { block: 0, allow: 0 };
62
+ stats.byHook[e.hook] = { block: 0, allow: 0, error: 0 };
60
63
  }
61
64
  stats.byHook[e.hook][e.decision]++;
62
65
  }
@@ -125,11 +125,11 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
125
125
  };
126
126
  const aliases = blockIdAliases[block.id] ?? [block.id];
127
127
  const matchedHook = registeredHooks?.find((h) => {
128
- const scriptName = h.command.replace(/^bash\s+/, "");
128
+ const scriptName = h.command.replace(/^bash\s+/, "").replace(/^"|"$/g, "");
129
129
  return aliases.some((alias) => scriptName.includes(alias));
130
130
  });
131
131
  const hookScript = matchedHook
132
- ? matchedHook.command.replace(/^bash\s+/, "")
132
+ ? matchedHook.command.replace(/^bash\s+/, "").replace(/^"|"$/g, "")
133
133
  : `.claude/hooks/catalog-${block.id}.sh`;
134
134
  switch (block.id) {
135
135
  case "path-guard": {
@@ -1,5 +1,5 @@
1
1
  import type { MergedConfig } from "../core/preset-types.js";
2
- export declare function wrapWithLogger(script: string, event?: string): string;
2
+ export declare function wrapWithLogger(script: string, event?: string, projectDir?: string): string;
3
3
  export interface GenerateHooksOptions {
4
4
  projectDir: string;
5
5
  config: MergedConfig;
@@ -1,8 +1,11 @@
1
1
  import { mkdir, writeFile, chmod, readFile, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- function buildLoggerSnippet(event) {
3
+ function buildLoggerSnippet(event, projectDir) {
4
+ const stateDir = projectDir
5
+ ? `${projectDir}/.claude/hooks/.state`
6
+ : ".claude/hooks/.state";
4
7
  return `# --- oh-my-harness event logger ---
5
- _OMH_STATE_DIR=".claude/hooks/.state"
8
+ _OMH_STATE_DIR="${stateDir}"
6
9
  mkdir -p "$_OMH_STATE_DIR" 2>/dev/null || true
7
10
  _OMH_HOOK_NAME="$(basename "$0")"
8
11
  _OMH_EVENT="${event}"
@@ -14,11 +17,11 @@ _log_event() {
14
17
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$_OMH_EVENT" "$_OMH_HOOK_NAME" "$decision" "$reason" \\
15
18
  >> "$_OMH_STATE_DIR/events.jsonl"
16
19
  }
17
- trap '[ "$_OMH_LOGGED" -eq 0 ] && _log_event "allow"' EXIT
20
+ trap '_OMH_EXIT_CODE=$?; if [ "$_OMH_LOGGED" -eq 0 ]; then if [ "$_OMH_EXIT_CODE" -ne 0 ]; then _log_event "error" "hook exited with code $_OMH_EXIT_CODE"; else _log_event "allow"; fi; fi' EXIT
18
21
  # --- end logger ---`;
19
22
  }
20
- export function wrapWithLogger(script, event = "unknown") {
21
- const snippet = buildLoggerSnippet(event);
23
+ export function wrapWithLogger(script, event = "unknown", projectDir) {
24
+ const snippet = buildLoggerSnippet(event, projectDir);
22
25
  if (script.includes("INPUT=$(cat)")) {
23
26
  return script.replace("INPUT=$(cat)", `INPUT=$(cat)\n\n${snippet}`);
24
27
  }
@@ -108,13 +111,13 @@ export async function generateHooks(options) {
108
111
  }
109
112
  usedScriptNames.add(scriptName);
110
113
  const scriptPath = join(hooksDir, scriptName);
111
- const wrappedScript = wrapWithLogger(hook.inline, hook.event);
114
+ const wrappedScript = wrapWithLogger(hook.inline, hook.event, projectDir);
112
115
  await writeFile(scriptPath, wrappedScript, "utf8");
113
116
  await chmod(scriptPath, 0o755);
114
117
  generatedFiles.push(scriptPath);
115
118
  const entry = {
116
119
  matcher: hook.matcher,
117
- hooks: [{ type: "command", command: `bash .claude/hooks/${scriptName}` }],
120
+ hooks: [{ type: "command", command: `bash "${scriptPath}"` }],
118
121
  };
119
122
  if (!hooksConfig[hook.event]) {
120
123
  hooksConfig[hook.event] = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {