oh-my-harness 0.7.0 → 0.8.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.
Files changed (34) hide show
  1. package/dist/catalog/blocks/compact-context.d.ts +2 -0
  2. package/dist/catalog/blocks/compact-context.js +27 -0
  3. package/dist/catalog/blocks/config-audit.d.ts +2 -0
  4. package/dist/catalog/blocks/config-audit.js +29 -0
  5. package/dist/catalog/blocks/desktop-notify.d.ts +2 -0
  6. package/dist/catalog/blocks/desktop-notify.js +21 -0
  7. package/dist/catalog/blocks/index.js +12 -0
  8. package/dist/catalog/blocks/sql-guard.d.ts +2 -0
  9. package/dist/catalog/blocks/sql-guard.js +34 -0
  10. package/dist/catalog/blocks/test-on-save.d.ts +2 -0
  11. package/dist/catalog/blocks/test-on-save.js +37 -0
  12. package/dist/catalog/blocks/worktree-setup.d.ts +2 -0
  13. package/dist/catalog/blocks/worktree-setup.js +91 -0
  14. package/dist/catalog/converter.js +5 -7
  15. package/dist/catalog/types.d.ts +8 -8
  16. package/dist/catalog/types.js +2 -2
  17. package/dist/cli/commands/catalog.js +1 -0
  18. package/dist/cli/commands/doctor.d.ts +1 -0
  19. package/dist/cli/commands/doctor.js +2 -1
  20. package/dist/cli/commands/init.js +11 -1
  21. package/dist/cli/index.js +4 -1
  22. package/dist/cli/stats/index.js +4 -0
  23. package/dist/core/config-merger.js +28 -0
  24. package/dist/core/harness-converter-v2.js +26 -13
  25. package/dist/core/harness-converter.js +4 -0
  26. package/dist/core/preset-types.d.ts +368 -0
  27. package/dist/core/preset-types.js +4 -0
  28. package/dist/detector/detectors/index.js +2 -0
  29. package/dist/detector/detectors/terraform.d.ts +2 -0
  30. package/dist/detector/detectors/terraform.js +46 -0
  31. package/dist/generators/hooks.js +17 -5
  32. package/dist/generators/settings.js +21 -1
  33. package/package.json +1 -1
  34. package/presets/terraform/preset.yaml +65 -0
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const compactContext: BuildingBlock;
@@ -0,0 +1,27 @@
1
+ export const compactContext = {
2
+ id: "compact-context",
3
+ name: "Compact Context",
4
+ description: "Re-injects project context after context compaction",
5
+ category: "automation",
6
+ event: "SessionStart",
7
+ matcher: "compact",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "contextFile",
12
+ type: "string",
13
+ description: "Path to the context file to re-inject",
14
+ default: "CLAUDE.md",
15
+ required: false,
16
+ },
17
+ ],
18
+ tags: ["context", "compaction", "session", "automation"],
19
+ template: `#!/bin/bash
20
+ set -euo pipefail
21
+ INPUT=$(cat)
22
+ CONTEXT_FILE="{{{contextFile}}}"
23
+ if [[ -f "$CONTEXT_FILE" ]]; then
24
+ cat "$CONTEXT_FILE"
25
+ fi
26
+ exit 0`,
27
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const configAudit: BuildingBlock;
@@ -0,0 +1,29 @@
1
+ export const configAudit = {
2
+ id: "config-audit",
3
+ name: "Config Audit",
4
+ description: "Logs configuration changes for audit trail",
5
+ category: "audit",
6
+ event: "ConfigChange",
7
+ matcher: "",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "logFile",
12
+ type: "string",
13
+ description: "Path to the audit log file",
14
+ default: ".claude/hooks/.state/config-audit.log",
15
+ required: false,
16
+ },
17
+ ],
18
+ tags: ["audit", "config", "logging"],
19
+ template: `#!/bin/bash
20
+ set -euo pipefail
21
+ INPUT=$(cat)
22
+ LOG_FILE="{{{logFile}}}"
23
+ mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
24
+ SOURCE=$(echo "$INPUT" | jq -r '.source // "unknown"' 2>/dev/null)
25
+ FILE=$(echo "$INPUT" | jq -r '.file_path // "unknown"' 2>/dev/null)
26
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
27
+ echo "{\\"ts\\":\\"$TS\\",\\"source\\":\\"$SOURCE\\",\\"file\\":\\"$FILE\\"}" >> "$LOG_FILE"
28
+ exit 0`,
29
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const desktopNotify: BuildingBlock;
@@ -0,0 +1,21 @@
1
+ export const desktopNotify = {
2
+ id: "desktop-notify",
3
+ name: "Desktop Notify",
4
+ description: "Sends desktop notification when Claude needs attention",
5
+ category: "notification",
6
+ event: "Notification",
7
+ matcher: "",
8
+ canBlock: false,
9
+ params: [],
10
+ tags: ["notification", "desktop", "alert"],
11
+ template: `#!/bin/bash
12
+ set -euo pipefail
13
+ INPUT=$(cat)
14
+ MESSAGE=$(echo "$INPUT" | jq -r '.message // "Claude Code needs your attention"' 2>/dev/null)
15
+ if [[ "$(uname)" == "Darwin" ]]; then
16
+ osascript -e "display notification \\"$MESSAGE\\" with title \\"Claude Code\\"" 2>/dev/null || true
17
+ elif command -v notify-send &>/dev/null; then
18
+ notify-send "Claude Code" "$MESSAGE" 2>/dev/null || true
19
+ fi
20
+ exit 0`,
21
+ };
@@ -9,6 +9,12 @@ 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
11
  import { tddGuard } from "./tdd-guard.js";
12
+ import { sqlGuard } from "./sql-guard.js";
13
+ import { testOnSave } from "./test-on-save.js";
14
+ import { desktopNotify } from "./desktop-notify.js";
15
+ import { configAudit } from "./config-audit.js";
16
+ import { compactContext } from "./compact-context.js";
17
+ import { worktreeSetup } from "./worktree-setup.js";
12
18
  export const builtinBlocks = [
13
19
  branchGuard,
14
20
  commitTestGate,
@@ -21,4 +27,10 @@ export const builtinBlocks = [
21
27
  autoPr,
22
28
  secretFileGuard,
23
29
  tddGuard,
30
+ sqlGuard,
31
+ testOnSave,
32
+ desktopNotify,
33
+ configAudit,
34
+ compactContext,
35
+ worktreeSetup,
24
36
  ];
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const sqlGuard: BuildingBlock;
@@ -0,0 +1,34 @@
1
+ export const sqlGuard = {
2
+ id: "sql-guard",
3
+ name: "SQL Guard",
4
+ description: "Blocks dangerous SQL commands in shell",
5
+ category: "security",
6
+ event: "PreToolUse",
7
+ matcher: "Bash",
8
+ canBlock: true,
9
+ params: [
10
+ {
11
+ name: "patterns",
12
+ type: "string[]",
13
+ description: "Dangerous SQL patterns to block",
14
+ default: ["DROP TABLE", "DROP DATABASE", "TRUNCATE TABLE", "DELETE FROM", "DROP COLUMN", "DROP INDEX"],
15
+ required: true,
16
+ },
17
+ ],
18
+ tags: ["security", "sql", "guard", "database"],
19
+ template: `#!/bin/bash
20
+ set -euo pipefail
21
+ INPUT=$(cat)
22
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
23
+ [[ -z "$COMMAND" ]] && exit 0
24
+ COMMAND_LOWER=$(echo "$COMMAND" | tr '[:upper:]' '[:lower:]')
25
+ PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
+ for PATTERN in "\${PATTERNS[@]}"; do
27
+ PATTERN_LOWER=$(echo "$PATTERN" | tr '[:upper:]' '[:lower:]')
28
+ if echo "$COMMAND_LOWER" | grep -qF -- "$PATTERN_LOWER"; then
29
+ echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: SQL command matches blocked pattern: $PATTERN\\"}"
30
+ exit 0
31
+ fi
32
+ done
33
+ exit 0`,
34
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const testOnSave: BuildingBlock;
@@ -0,0 +1,37 @@
1
+ export const testOnSave = {
2
+ id: "test-on-save",
3
+ name: "Test on Save",
4
+ description: "Runs related tests after source file edits",
5
+ category: "quality",
6
+ event: "PostToolUse",
7
+ matcher: "Edit|Write",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "testCommand",
12
+ type: "string",
13
+ description: "Command to run tests (e.g. npx vitest run)",
14
+ required: true,
15
+ },
16
+ {
17
+ name: "filePattern",
18
+ type: "string",
19
+ description: "Regex pattern to match source files",
20
+ default: "\\.(ts|tsx|js|jsx|py)$",
21
+ required: false,
22
+ },
23
+ ],
24
+ tags: ["quality", "testing", "auto-run", "save"],
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
+ FILE_RE='{{{filePattern}}}'
31
+ FILE_RE="\${FILE_RE//\\\\\\\\/\\\\}"
32
+ if [[ "$FILE_PATH" =~ $FILE_RE ]]; then
33
+ echo "oh-my-harness: Running tests after edit..." >&2
34
+ {{{testCommand}}} >&2 2>&1 || true
35
+ fi
36
+ exit 0`,
37
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const worktreeSetup: BuildingBlock;
@@ -0,0 +1,91 @@
1
+ export const worktreeSetup = {
2
+ id: "worktree-setup",
3
+ name: "Worktree Setup",
4
+ description: "Initializes new git worktrees with symlinked dependencies and copied config files",
5
+ category: "automation",
6
+ event: "WorktreeCreate",
7
+ matcher: "",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "symlinkPaths",
12
+ type: "string[]",
13
+ description: "Paths to symlink from main worktree (heavy dependencies)",
14
+ required: false,
15
+ default: ["node_modules", ".venv", "vendor", "target", ".build"],
16
+ },
17
+ {
18
+ name: "copyPaths",
19
+ type: "string[]",
20
+ description: "Paths to hard-copy from main worktree (config files)",
21
+ required: false,
22
+ default: [".env", ".env.local"],
23
+ },
24
+ {
25
+ name: "installCommand",
26
+ type: "string",
27
+ description: "Package install command to run if symlink source missing",
28
+ required: false,
29
+ default: "",
30
+ },
31
+ ],
32
+ tags: ["worktree", "git", "dependencies", "symlink", "automation"],
33
+ template: `#!/bin/bash
34
+ set -euo pipefail
35
+ INPUT=$(cat)
36
+
37
+ # Parse worktree info from stdin
38
+ WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path // empty' 2>/dev/null)
39
+ if [[ -z "$WORKTREE_PATH" ]]; then
40
+ echo "oh-my-harness: worktree-setup — no worktree_path in input" >&2
41
+ exit 0
42
+ fi
43
+
44
+ # Find the main worktree (parent repo)
45
+ MAIN_WORKTREE=$(git -C "$WORKTREE_PATH" worktree list --porcelain 2>/dev/null | head -1 | sed 's/^worktree //')
46
+ if [[ -z "$MAIN_WORKTREE" ]]; then
47
+ echo "oh-my-harness: worktree-setup — cannot determine main worktree" >&2
48
+ exit 0
49
+ fi
50
+
51
+ echo "oh-my-harness: worktree-setup — setting up $WORKTREE_PATH"
52
+
53
+ # Symlink heavy directories from main worktree
54
+ SYMLINKS=({{#each symlinkPaths}}"{{{this}}}" {{/each}})
55
+ for rel in "\${SYMLINKS[@]}"; do
56
+ [[ -z "$rel" ]] && continue
57
+ src="$MAIN_WORKTREE/$rel"
58
+ dst="$WORKTREE_PATH/$rel"
59
+ if [[ -e "$src" && ! -e "$dst" ]]; then
60
+ mkdir -p "$(dirname "$dst")"
61
+ ln -s "$src" "$dst"
62
+ echo " symlinked: $rel"
63
+ fi
64
+ done
65
+
66
+ # Hard-copy config files from main worktree
67
+ COPIES=({{#each copyPaths}}"{{{this}}}" {{/each}})
68
+ for rel in "\${COPIES[@]}"; do
69
+ [[ -z "$rel" ]] && continue
70
+ src="$MAIN_WORKTREE/$rel"
71
+ dst="$WORKTREE_PATH/$rel"
72
+ if [[ -f "$src" && ! -f "$dst" ]]; then
73
+ mkdir -p "$(dirname "$dst")"
74
+ cp "$src" "$dst"
75
+ echo " copied: $rel"
76
+ fi
77
+ done
78
+
79
+ # Run install command if provided and no symlinked deps exist
80
+ INSTALL_CMD="{{{installCommand}}}"
81
+ if [[ -n "$INSTALL_CMD" ]]; then
82
+ first_symlink="\${SYMLINKS[0]}"
83
+ if [[ -n "$first_symlink" && ! -e "$WORKTREE_PATH/$first_symlink" ]]; then
84
+ echo " running: $INSTALL_CMD"
85
+ cd "$WORKTREE_PATH" && eval "$INSTALL_CMD"
86
+ fi
87
+ fi
88
+
89
+ echo "oh-my-harness: worktree-setup complete"
90
+ exit 0`,
91
+ };
@@ -4,7 +4,7 @@ export async function convertHookEntries(entries, registry, projectDir) {
4
4
  const hooksConfig = {};
5
5
  const scripts = new Map();
6
6
  const errors = [];
7
- const seenBlockIds = new Set();
7
+ const blockInstanceCount = new Map();
8
8
  for (const entry of entries) {
9
9
  const block = registry.get(entry.block);
10
10
  if (!block) {
@@ -17,11 +17,6 @@ export async function convertHookEntries(entries, registry, projectDir) {
17
17
  errors.push(...paramErrors);
18
18
  continue;
19
19
  }
20
- if (seenBlockIds.has(entry.block)) {
21
- errors.push(`Duplicate block id skipped: "${entry.block}"`);
22
- continue;
23
- }
24
- seenBlockIds.add(entry.block);
25
20
  let scriptContent;
26
21
  try {
27
22
  scriptContent = renderTemplate(block.template, resolvedParams);
@@ -30,7 +25,10 @@ export async function convertHookEntries(entries, registry, projectDir) {
30
25
  errors.push(`Failed to render block "${entry.block}": ${err.message}`);
31
26
  continue;
32
27
  }
33
- const scriptName = `${entry.block}.sh`;
28
+ // Support multiple instances of the same block with different params
29
+ const count = blockInstanceCount.get(entry.block) ?? 0;
30
+ blockInstanceCount.set(entry.block, count + 1);
31
+ const scriptName = count === 0 ? `${entry.block}.sh` : `${entry.block}-${count}.sh`;
34
32
  const scriptPath = path.join(projectDir, ".claude", "hooks", scriptName);
35
33
  scripts.set(scriptPath, scriptContent);
36
34
  const hookEntry = {
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
- export type HookEvent = "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit";
3
- export type BuildingBlockCategory = "git" | "quality" | "security" | "notification" | "formatting" | "custom" | "auto-fix" | "automation" | "file-protection";
2
+ export type HookEvent = "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "ConfigChange" | "WorktreeCreate" | "WorktreeRemove";
3
+ export type BuildingBlockCategory = "git" | "quality" | "security" | "notification" | "formatting" | "custom" | "auto-fix" | "automation" | "file-protection" | "audit";
4
4
  export interface ParamDefinition {
5
5
  name: string;
6
6
  type: "string" | "boolean" | "number" | "string[]";
@@ -47,8 +47,8 @@ export declare const BuildingBlockSchema: z.ZodObject<{
47
47
  id: z.ZodString;
48
48
  name: z.ZodString;
49
49
  description: z.ZodString;
50
- category: z.ZodEnum<["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection"]>;
51
- event: z.ZodEnum<["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit"]>;
50
+ category: z.ZodEnum<["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection", "audit"]>;
51
+ event: z.ZodEnum<["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit", "ConfigChange", "WorktreeCreate", "WorktreeRemove"]>;
52
52
  matcher: z.ZodOptional<z.ZodString>;
53
53
  canBlock: z.ZodBoolean;
54
54
  params: z.ZodArray<z.ZodObject<{
@@ -85,8 +85,8 @@ export declare const BuildingBlockSchema: z.ZodObject<{
85
85
  template: string;
86
86
  name: string;
87
87
  tags: string[];
88
- category: "custom" | "git" | "quality" | "security" | "notification" | "formatting" | "auto-fix" | "automation" | "file-protection";
89
- event: "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit";
88
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "PreCompact" | "PostCompact" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "WorktreeRemove";
89
+ category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
90
90
  canBlock: boolean;
91
91
  matcher?: string | undefined;
92
92
  }, {
@@ -102,8 +102,8 @@ export declare const BuildingBlockSchema: z.ZodObject<{
102
102
  template: string;
103
103
  name: string;
104
104
  tags: string[];
105
- category: "custom" | "git" | "quality" | "security" | "notification" | "formatting" | "auto-fix" | "automation" | "file-protection";
106
- event: "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit";
105
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "PreCompact" | "PostCompact" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "WorktreeRemove";
106
+ category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
107
107
  canBlock: boolean;
108
108
  matcher?: string | undefined;
109
109
  }>;
@@ -10,8 +10,8 @@ export const BuildingBlockSchema = z.object({
10
10
  id: z.string(),
11
11
  name: z.string(),
12
12
  description: z.string(),
13
- category: z.enum(["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection"]),
14
- event: z.enum(["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit"]),
13
+ category: z.enum(["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection", "audit"]),
14
+ event: z.enum(["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit", "ConfigChange", "WorktreeCreate", "WorktreeRemove"]),
15
15
  matcher: z.string().optional(),
16
16
  canBlock: z.boolean(),
17
17
  params: z.array(ParamDefinitionSchema),
@@ -11,6 +11,7 @@ const CATEGORY_EMOJI = {
11
11
  notification: "🔔",
12
12
  prompt: "💬",
13
13
  permission: "🔐",
14
+ audit: "📝",
14
15
  };
15
16
  function categoryLabel(category) {
16
17
  const emoji = CATEGORY_EMOJI[category] ?? "•";
@@ -3,6 +3,7 @@ export interface DoctorOptions {
3
3
  }
4
4
  export interface DoctorResult {
5
5
  healthy: boolean;
6
+ exitCode: number;
6
7
  checks: {
7
8
  stateFile: boolean;
8
9
  claudeMd: boolean;
@@ -71,6 +71,7 @@ export async function doctorCommand(options = {}) {
71
71
  checks.hooksExecutable = true;
72
72
  }
73
73
  const healthy = Object.values(checks).every(Boolean);
74
+ const exitCode = healthy ? 0 : 1;
74
75
  if (healthy) {
75
76
  console.log("oh-my-harness: all checks passed");
76
77
  }
@@ -80,5 +81,5 @@ export async function doctorCommand(options = {}) {
80
81
  console.log(` ${msg}`);
81
82
  }
82
83
  }
83
- return { healthy, checks, messages };
84
+ return { healthy, exitCode, checks, messages };
84
85
  }
@@ -59,7 +59,17 @@ export async function initCommand(presetNames, options = {}) {
59
59
  const presetsDir = options.presetsDir ?? getDefaultPresetsDir();
60
60
  // If --preset flag is used, go through preset flow
61
61
  if (options.preset && options.preset.length > 0) {
62
- await initWithPresets(options.preset, projectDir, presetsDir, options);
62
+ try {
63
+ await initWithPresets(options.preset, projectDir, presetsDir, options);
64
+ }
65
+ catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ console.error(`oh-my-harness: ${message}`);
68
+ if (!message.includes("Preset not found")) {
69
+ console.error("Run `oh-my-harness init --help` for usage information.");
70
+ }
71
+ process.exitCode = 1;
72
+ }
63
73
  return;
64
74
  }
65
75
  // If preset names are provided as positional args, use legacy flow
package/dist/cli/index.js CHANGED
@@ -36,7 +36,10 @@ export function createCli() {
36
36
  .description("Validate harness configuration health")
37
37
  .action(async () => {
38
38
  const { doctorCommand } = await import("./commands/doctor.js");
39
- await doctorCommand();
39
+ const result = await doctorCommand();
40
+ if (result.exitCode !== 0) {
41
+ process.exitCode = result.exitCode;
42
+ }
40
43
  });
41
44
  program
42
45
  .command("test")
@@ -3,6 +3,10 @@ import React from "react";
3
3
  import { loadStatsData } from "./data.js";
4
4
  import { App } from "./App.js";
5
5
  export async function statsCommand(options = {}) {
6
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
+ console.error("oh-my-harness: stats requires an interactive terminal (TTY). Run this command directly in your terminal.");
8
+ return;
9
+ }
6
10
  const projectDir = options.projectDir ?? process.cwd();
7
11
  const data = await loadStatsData(projectDir);
8
12
  const { waitUntilExit } = render(React.createElement(App, { initialData: data, projectDir }));
@@ -3,6 +3,10 @@ export function mergePresets(presets) {
3
3
  const sectionsMap = new Map();
4
4
  const preToolUseMap = new Map();
5
5
  const postToolUseMap = new Map();
6
+ const sessionStartMap = new Map();
7
+ const notificationMap = new Map();
8
+ const configChangeMap = new Map();
9
+ const worktreeCreateMap = new Map();
6
10
  const allowSet = new Set();
7
11
  const denySet = new Set();
8
12
  const presetNames = [];
@@ -29,6 +33,26 @@ export function mergePresets(presets) {
29
33
  postToolUseMap.set(hook.id, hook);
30
34
  }
31
35
  }
36
+ if (preset.hooks?.sessionStart) {
37
+ for (const hook of preset.hooks.sessionStart) {
38
+ sessionStartMap.set(hook.id, hook);
39
+ }
40
+ }
41
+ if (preset.hooks?.notification) {
42
+ for (const hook of preset.hooks.notification) {
43
+ notificationMap.set(hook.id, hook);
44
+ }
45
+ }
46
+ if (preset.hooks?.configChange) {
47
+ for (const hook of preset.hooks.configChange) {
48
+ configChangeMap.set(hook.id, hook);
49
+ }
50
+ }
51
+ if (preset.hooks?.worktreeCreate) {
52
+ for (const hook of preset.hooks.worktreeCreate) {
53
+ worktreeCreateMap.set(hook.id, hook);
54
+ }
55
+ }
32
56
  // Merge settings (accumulate)
33
57
  if (preset.settings?.permissions?.allow) {
34
58
  for (const a of preset.settings.permissions.allow)
@@ -48,6 +72,10 @@ export function mergePresets(presets) {
48
72
  hooks: {
49
73
  preToolUse: Array.from(preToolUseMap.values()),
50
74
  postToolUse: Array.from(postToolUseMap.values()),
75
+ sessionStart: Array.from(sessionStartMap.values()),
76
+ notification: Array.from(notificationMap.values()),
77
+ configChange: Array.from(configChangeMap.values()),
78
+ worktreeCreate: Array.from(worktreeCreateMap.values()),
51
79
  },
52
80
  settings: {
53
81
  permissions: {
@@ -1,6 +1,15 @@
1
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
+ /** Maps Claude Code event names to HooksConfig field names */
5
+ const eventToField = {
6
+ PreToolUse: "preToolUse",
7
+ PostToolUse: "postToolUse",
8
+ SessionStart: "sessionStart",
9
+ Notification: "notification",
10
+ ConfigChange: "configChange",
11
+ WorktreeCreate: "worktreeCreate",
12
+ };
4
13
  export async function harnessToMergedConfigV2(harness, registry, projectDir) {
5
14
  // Start with base conversion (rules, variables, permissions — no inline enforcement scripts)
6
15
  const base = harnessToMergedConfig(harness);
@@ -15,9 +24,14 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
15
24
  const catalogResult = await convertHookEntries(allHookEntries, resolvedRegistry, projectDir ?? ".");
16
25
  // Convert hooksConfig entries from catalog into HookDefinition format.
17
26
  // Errors are reported as warnings but don't block valid hooks.
18
- const additionalPreToolUse = [];
19
- const additionalPostToolUse = [];
27
+ const additionalHooks = {};
20
28
  for (const [event, entries] of Object.entries(catalogResult.hooksConfig)) {
29
+ const field = eventToField[event];
30
+ if (!field)
31
+ continue; // unknown event — skip
32
+ if (!additionalHooks[field]) {
33
+ additionalHooks[field] = [];
34
+ }
21
35
  for (const entry of entries) {
22
36
  // Find the block id from the script path: .claude/hooks/<block-id>.sh
23
37
  const blockId = entry.command.replace(/.*\/(.+)\.sh$/, "$1");
@@ -27,21 +41,20 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
27
41
  description: `Catalog block: ${blockId}`,
28
42
  inline: catalogResult.scripts.get(entry.command),
29
43
  };
30
- if (event === "PreToolUse") {
31
- additionalPreToolUse.push(hookDef);
32
- }
33
- else if (event === "PostToolUse") {
34
- additionalPostToolUse.push(hookDef);
35
- }
36
- // Other events (SessionStart, etc.) are not yet mapped to MergedConfig hooks
44
+ additionalHooks[field].push(hookDef);
37
45
  }
38
46
  }
47
+ const mergedHooks = {
48
+ preToolUse: [...base.hooks.preToolUse, ...(additionalHooks.preToolUse ?? [])],
49
+ postToolUse: [...base.hooks.postToolUse, ...(additionalHooks.postToolUse ?? [])],
50
+ sessionStart: [...(base.hooks.sessionStart ?? []), ...(additionalHooks.sessionStart ?? [])],
51
+ notification: [...(base.hooks.notification ?? []), ...(additionalHooks.notification ?? [])],
52
+ configChange: [...(base.hooks.configChange ?? []), ...(additionalHooks.configChange ?? [])],
53
+ worktreeCreate: [...(base.hooks.worktreeCreate ?? []), ...(additionalHooks.worktreeCreate ?? [])],
54
+ };
39
55
  return {
40
56
  ...base,
41
- hooks: {
42
- preToolUse: [...base.hooks.preToolUse, ...additionalPreToolUse],
43
- postToolUse: [...base.hooks.postToolUse, ...additionalPostToolUse],
44
- },
57
+ hooks: mergedHooks,
45
58
  ...(catalogResult.errors.length > 0 ? { catalogErrors: catalogResult.errors } : {}),
46
59
  };
47
60
  }
@@ -76,6 +76,10 @@ export function harnessToMergedConfig(harness) {
76
76
  hooks: {
77
77
  preToolUse: [],
78
78
  postToolUse: [],
79
+ sessionStart: [],
80
+ notification: [],
81
+ configChange: [],
82
+ worktreeCreate: [],
79
83
  },
80
84
  settings: {
81
85
  permissions: {
@@ -87,6 +87,94 @@ export declare const HooksConfigSchema: z.ZodObject<{
87
87
  inline?: string | undefined;
88
88
  variables?: Record<string, unknown> | undefined;
89
89
  }>, "many">>;
90
+ sessionStart: z.ZodOptional<z.ZodArray<z.ZodObject<{
91
+ id: z.ZodString;
92
+ matcher: z.ZodString;
93
+ description: z.ZodOptional<z.ZodString>;
94
+ script: z.ZodOptional<z.ZodString>;
95
+ inline: z.ZodOptional<z.ZodString>;
96
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
97
+ }, "strip", z.ZodTypeAny, {
98
+ id: string;
99
+ matcher: string;
100
+ description?: string | undefined;
101
+ script?: string | undefined;
102
+ inline?: string | undefined;
103
+ variables?: Record<string, unknown> | undefined;
104
+ }, {
105
+ id: string;
106
+ matcher: string;
107
+ description?: string | undefined;
108
+ script?: string | undefined;
109
+ inline?: string | undefined;
110
+ variables?: Record<string, unknown> | undefined;
111
+ }>, "many">>;
112
+ notification: z.ZodOptional<z.ZodArray<z.ZodObject<{
113
+ id: z.ZodString;
114
+ matcher: z.ZodString;
115
+ description: z.ZodOptional<z.ZodString>;
116
+ script: z.ZodOptional<z.ZodString>;
117
+ inline: z.ZodOptional<z.ZodString>;
118
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
119
+ }, "strip", z.ZodTypeAny, {
120
+ id: string;
121
+ matcher: string;
122
+ description?: string | undefined;
123
+ script?: string | undefined;
124
+ inline?: string | undefined;
125
+ variables?: Record<string, unknown> | undefined;
126
+ }, {
127
+ id: string;
128
+ matcher: string;
129
+ description?: string | undefined;
130
+ script?: string | undefined;
131
+ inline?: string | undefined;
132
+ variables?: Record<string, unknown> | undefined;
133
+ }>, "many">>;
134
+ configChange: z.ZodOptional<z.ZodArray<z.ZodObject<{
135
+ id: z.ZodString;
136
+ matcher: z.ZodString;
137
+ description: z.ZodOptional<z.ZodString>;
138
+ script: z.ZodOptional<z.ZodString>;
139
+ inline: z.ZodOptional<z.ZodString>;
140
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
141
+ }, "strip", z.ZodTypeAny, {
142
+ id: string;
143
+ matcher: string;
144
+ description?: string | undefined;
145
+ script?: string | undefined;
146
+ inline?: string | undefined;
147
+ variables?: Record<string, unknown> | undefined;
148
+ }, {
149
+ id: string;
150
+ matcher: string;
151
+ description?: string | undefined;
152
+ script?: string | undefined;
153
+ inline?: string | undefined;
154
+ variables?: Record<string, unknown> | undefined;
155
+ }>, "many">>;
156
+ worktreeCreate: z.ZodOptional<z.ZodArray<z.ZodObject<{
157
+ id: z.ZodString;
158
+ matcher: z.ZodString;
159
+ description: z.ZodOptional<z.ZodString>;
160
+ script: z.ZodOptional<z.ZodString>;
161
+ inline: z.ZodOptional<z.ZodString>;
162
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
163
+ }, "strip", z.ZodTypeAny, {
164
+ id: string;
165
+ matcher: string;
166
+ description?: string | undefined;
167
+ script?: string | undefined;
168
+ inline?: string | undefined;
169
+ variables?: Record<string, unknown> | undefined;
170
+ }, {
171
+ id: string;
172
+ matcher: string;
173
+ description?: string | undefined;
174
+ script?: string | undefined;
175
+ inline?: string | undefined;
176
+ variables?: Record<string, unknown> | undefined;
177
+ }>, "many">>;
90
178
  }, "strip", z.ZodTypeAny, {
91
179
  preToolUse?: {
92
180
  id: string;
@@ -104,6 +192,38 @@ export declare const HooksConfigSchema: z.ZodObject<{
104
192
  inline?: string | undefined;
105
193
  variables?: Record<string, unknown> | undefined;
106
194
  }[] | undefined;
195
+ sessionStart?: {
196
+ id: string;
197
+ matcher: string;
198
+ description?: string | undefined;
199
+ script?: string | undefined;
200
+ inline?: string | undefined;
201
+ variables?: Record<string, unknown> | undefined;
202
+ }[] | undefined;
203
+ notification?: {
204
+ id: string;
205
+ matcher: string;
206
+ description?: string | undefined;
207
+ script?: string | undefined;
208
+ inline?: string | undefined;
209
+ variables?: Record<string, unknown> | undefined;
210
+ }[] | undefined;
211
+ configChange?: {
212
+ id: string;
213
+ matcher: string;
214
+ description?: string | undefined;
215
+ script?: string | undefined;
216
+ inline?: string | undefined;
217
+ variables?: Record<string, unknown> | undefined;
218
+ }[] | undefined;
219
+ worktreeCreate?: {
220
+ id: string;
221
+ matcher: string;
222
+ description?: string | undefined;
223
+ script?: string | undefined;
224
+ inline?: string | undefined;
225
+ variables?: Record<string, unknown> | undefined;
226
+ }[] | undefined;
107
227
  }, {
108
228
  preToolUse?: {
109
229
  id: string;
@@ -121,6 +241,38 @@ export declare const HooksConfigSchema: z.ZodObject<{
121
241
  inline?: string | undefined;
122
242
  variables?: Record<string, unknown> | undefined;
123
243
  }[] | undefined;
244
+ sessionStart?: {
245
+ id: string;
246
+ matcher: string;
247
+ description?: string | undefined;
248
+ script?: string | undefined;
249
+ inline?: string | undefined;
250
+ variables?: Record<string, unknown> | undefined;
251
+ }[] | undefined;
252
+ notification?: {
253
+ id: string;
254
+ matcher: string;
255
+ description?: string | undefined;
256
+ script?: string | undefined;
257
+ inline?: string | undefined;
258
+ variables?: Record<string, unknown> | undefined;
259
+ }[] | undefined;
260
+ configChange?: {
261
+ id: string;
262
+ matcher: string;
263
+ description?: string | undefined;
264
+ script?: string | undefined;
265
+ inline?: string | undefined;
266
+ variables?: Record<string, unknown> | undefined;
267
+ }[] | undefined;
268
+ worktreeCreate?: {
269
+ id: string;
270
+ matcher: string;
271
+ description?: string | undefined;
272
+ script?: string | undefined;
273
+ inline?: string | undefined;
274
+ variables?: Record<string, unknown> | undefined;
275
+ }[] | undefined;
124
276
  }>;
125
277
  export type HooksConfig = z.infer<typeof HooksConfigSchema>;
126
278
  export declare const SettingsConfigSchema: z.ZodObject<{
@@ -238,6 +390,94 @@ export declare const PresetConfigSchema: z.ZodObject<{
238
390
  inline?: string | undefined;
239
391
  variables?: Record<string, unknown> | undefined;
240
392
  }>, "many">>;
393
+ sessionStart: z.ZodOptional<z.ZodArray<z.ZodObject<{
394
+ id: z.ZodString;
395
+ matcher: z.ZodString;
396
+ description: z.ZodOptional<z.ZodString>;
397
+ script: z.ZodOptional<z.ZodString>;
398
+ inline: z.ZodOptional<z.ZodString>;
399
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
400
+ }, "strip", z.ZodTypeAny, {
401
+ id: string;
402
+ matcher: string;
403
+ description?: string | undefined;
404
+ script?: string | undefined;
405
+ inline?: string | undefined;
406
+ variables?: Record<string, unknown> | undefined;
407
+ }, {
408
+ id: string;
409
+ matcher: string;
410
+ description?: string | undefined;
411
+ script?: string | undefined;
412
+ inline?: string | undefined;
413
+ variables?: Record<string, unknown> | undefined;
414
+ }>, "many">>;
415
+ notification: z.ZodOptional<z.ZodArray<z.ZodObject<{
416
+ id: z.ZodString;
417
+ matcher: z.ZodString;
418
+ description: z.ZodOptional<z.ZodString>;
419
+ script: z.ZodOptional<z.ZodString>;
420
+ inline: z.ZodOptional<z.ZodString>;
421
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
422
+ }, "strip", z.ZodTypeAny, {
423
+ id: string;
424
+ matcher: string;
425
+ description?: string | undefined;
426
+ script?: string | undefined;
427
+ inline?: string | undefined;
428
+ variables?: Record<string, unknown> | undefined;
429
+ }, {
430
+ id: string;
431
+ matcher: string;
432
+ description?: string | undefined;
433
+ script?: string | undefined;
434
+ inline?: string | undefined;
435
+ variables?: Record<string, unknown> | undefined;
436
+ }>, "many">>;
437
+ configChange: z.ZodOptional<z.ZodArray<z.ZodObject<{
438
+ id: z.ZodString;
439
+ matcher: z.ZodString;
440
+ description: z.ZodOptional<z.ZodString>;
441
+ script: z.ZodOptional<z.ZodString>;
442
+ inline: z.ZodOptional<z.ZodString>;
443
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
444
+ }, "strip", z.ZodTypeAny, {
445
+ id: string;
446
+ matcher: string;
447
+ description?: string | undefined;
448
+ script?: string | undefined;
449
+ inline?: string | undefined;
450
+ variables?: Record<string, unknown> | undefined;
451
+ }, {
452
+ id: string;
453
+ matcher: string;
454
+ description?: string | undefined;
455
+ script?: string | undefined;
456
+ inline?: string | undefined;
457
+ variables?: Record<string, unknown> | undefined;
458
+ }>, "many">>;
459
+ worktreeCreate: z.ZodOptional<z.ZodArray<z.ZodObject<{
460
+ id: z.ZodString;
461
+ matcher: z.ZodString;
462
+ description: z.ZodOptional<z.ZodString>;
463
+ script: z.ZodOptional<z.ZodString>;
464
+ inline: z.ZodOptional<z.ZodString>;
465
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
466
+ }, "strip", z.ZodTypeAny, {
467
+ id: string;
468
+ matcher: string;
469
+ description?: string | undefined;
470
+ script?: string | undefined;
471
+ inline?: string | undefined;
472
+ variables?: Record<string, unknown> | undefined;
473
+ }, {
474
+ id: string;
475
+ matcher: string;
476
+ description?: string | undefined;
477
+ script?: string | undefined;
478
+ inline?: string | undefined;
479
+ variables?: Record<string, unknown> | undefined;
480
+ }>, "many">>;
241
481
  }, "strip", z.ZodTypeAny, {
242
482
  preToolUse?: {
243
483
  id: string;
@@ -255,6 +495,38 @@ export declare const PresetConfigSchema: z.ZodObject<{
255
495
  inline?: string | undefined;
256
496
  variables?: Record<string, unknown> | undefined;
257
497
  }[] | undefined;
498
+ sessionStart?: {
499
+ id: string;
500
+ matcher: string;
501
+ description?: string | undefined;
502
+ script?: string | undefined;
503
+ inline?: string | undefined;
504
+ variables?: Record<string, unknown> | undefined;
505
+ }[] | undefined;
506
+ notification?: {
507
+ id: string;
508
+ matcher: string;
509
+ description?: string | undefined;
510
+ script?: string | undefined;
511
+ inline?: string | undefined;
512
+ variables?: Record<string, unknown> | undefined;
513
+ }[] | undefined;
514
+ configChange?: {
515
+ id: string;
516
+ matcher: string;
517
+ description?: string | undefined;
518
+ script?: string | undefined;
519
+ inline?: string | undefined;
520
+ variables?: Record<string, unknown> | undefined;
521
+ }[] | undefined;
522
+ worktreeCreate?: {
523
+ id: string;
524
+ matcher: string;
525
+ description?: string | undefined;
526
+ script?: string | undefined;
527
+ inline?: string | undefined;
528
+ variables?: Record<string, unknown> | undefined;
529
+ }[] | undefined;
258
530
  }, {
259
531
  preToolUse?: {
260
532
  id: string;
@@ -272,6 +544,38 @@ export declare const PresetConfigSchema: z.ZodObject<{
272
544
  inline?: string | undefined;
273
545
  variables?: Record<string, unknown> | undefined;
274
546
  }[] | undefined;
547
+ sessionStart?: {
548
+ id: string;
549
+ matcher: string;
550
+ description?: string | undefined;
551
+ script?: string | undefined;
552
+ inline?: string | undefined;
553
+ variables?: Record<string, unknown> | undefined;
554
+ }[] | undefined;
555
+ notification?: {
556
+ id: string;
557
+ matcher: string;
558
+ description?: string | undefined;
559
+ script?: string | undefined;
560
+ inline?: string | undefined;
561
+ variables?: Record<string, unknown> | undefined;
562
+ }[] | undefined;
563
+ configChange?: {
564
+ id: string;
565
+ matcher: string;
566
+ description?: string | undefined;
567
+ script?: string | undefined;
568
+ inline?: string | undefined;
569
+ variables?: Record<string, unknown> | undefined;
570
+ }[] | undefined;
571
+ worktreeCreate?: {
572
+ id: string;
573
+ matcher: string;
574
+ description?: string | undefined;
575
+ script?: string | undefined;
576
+ inline?: string | undefined;
577
+ variables?: Record<string, unknown> | undefined;
578
+ }[] | undefined;
275
579
  }>>;
276
580
  settings: z.ZodOptional<z.ZodObject<{
277
581
  permissions: z.ZodOptional<z.ZodObject<{
@@ -329,6 +633,38 @@ export declare const PresetConfigSchema: z.ZodObject<{
329
633
  inline?: string | undefined;
330
634
  variables?: Record<string, unknown> | undefined;
331
635
  }[] | undefined;
636
+ sessionStart?: {
637
+ id: string;
638
+ matcher: string;
639
+ description?: string | undefined;
640
+ script?: string | undefined;
641
+ inline?: string | undefined;
642
+ variables?: Record<string, unknown> | undefined;
643
+ }[] | undefined;
644
+ notification?: {
645
+ id: string;
646
+ matcher: string;
647
+ description?: string | undefined;
648
+ script?: string | undefined;
649
+ inline?: string | undefined;
650
+ variables?: Record<string, unknown> | undefined;
651
+ }[] | undefined;
652
+ configChange?: {
653
+ id: string;
654
+ matcher: string;
655
+ description?: string | undefined;
656
+ script?: string | undefined;
657
+ inline?: string | undefined;
658
+ variables?: Record<string, unknown> | undefined;
659
+ }[] | undefined;
660
+ worktreeCreate?: {
661
+ id: string;
662
+ matcher: string;
663
+ description?: string | undefined;
664
+ script?: string | undefined;
665
+ inline?: string | undefined;
666
+ variables?: Record<string, unknown> | undefined;
667
+ }[] | undefined;
332
668
  } | undefined;
333
669
  settings?: {
334
670
  permissions?: {
@@ -370,6 +706,38 @@ export declare const PresetConfigSchema: z.ZodObject<{
370
706
  inline?: string | undefined;
371
707
  variables?: Record<string, unknown> | undefined;
372
708
  }[] | undefined;
709
+ sessionStart?: {
710
+ id: string;
711
+ matcher: string;
712
+ description?: string | undefined;
713
+ script?: string | undefined;
714
+ inline?: string | undefined;
715
+ variables?: Record<string, unknown> | undefined;
716
+ }[] | undefined;
717
+ notification?: {
718
+ id: string;
719
+ matcher: string;
720
+ description?: string | undefined;
721
+ script?: string | undefined;
722
+ inline?: string | undefined;
723
+ variables?: Record<string, unknown> | undefined;
724
+ }[] | undefined;
725
+ configChange?: {
726
+ id: string;
727
+ matcher: string;
728
+ description?: string | undefined;
729
+ script?: string | undefined;
730
+ inline?: string | undefined;
731
+ variables?: Record<string, unknown> | undefined;
732
+ }[] | undefined;
733
+ worktreeCreate?: {
734
+ id: string;
735
+ matcher: string;
736
+ description?: string | undefined;
737
+ script?: string | undefined;
738
+ inline?: string | undefined;
739
+ variables?: Record<string, unknown> | undefined;
740
+ }[] | undefined;
373
741
  } | undefined;
374
742
  settings?: {
375
743
  permissions?: {
@@ -20,6 +20,10 @@ export const ClaudeMdSectionSchema = z.object({
20
20
  export const HooksConfigSchema = z.object({
21
21
  preToolUse: z.array(HookDefinitionSchema).optional(),
22
22
  postToolUse: z.array(HookDefinitionSchema).optional(),
23
+ sessionStart: z.array(HookDefinitionSchema).optional(),
24
+ notification: z.array(HookDefinitionSchema).optional(),
25
+ configChange: z.array(HookDefinitionSchema).optional(),
26
+ worktreeCreate: z.array(HookDefinitionSchema).optional(),
23
27
  });
24
28
  // --- Settings Config ---
25
29
  export const SettingsConfigSchema = z.object({
@@ -12,6 +12,7 @@ import { dartDetector } from "./dart.js";
12
12
  import { elixirDetector } from "./elixir.js";
13
13
  import { scalaDetector } from "./scala.js";
14
14
  import { zigDetector } from "./zig.js";
15
+ import { terraformDetector } from "./terraform.js";
15
16
  export const allDetectors = [
16
17
  nodeDetector,
17
18
  pythonDetector,
@@ -27,4 +28,5 @@ export const allDetectors = [
27
28
  elixirDetector,
28
29
  scalaDetector,
29
30
  zigDetector,
31
+ terraformDetector,
30
32
  ];
@@ -0,0 +1,2 @@
1
+ import type { Detector } from "../types.js";
2
+ export declare const terraformDetector: Detector;
@@ -0,0 +1,46 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const terraformDetector = {
4
+ name: "terraform",
5
+ detect: async (projectDir) => {
6
+ // Check for *.tf files
7
+ let hasTfFiles = false;
8
+ const detectedFiles = [];
9
+ try {
10
+ const entries = await fs.readdir(projectDir);
11
+ for (const entry of entries) {
12
+ if (entry.endsWith(".tf")) {
13
+ hasTfFiles = true;
14
+ detectedFiles.push(entry);
15
+ }
16
+ }
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ // Fallback: check for .terraform.lock.hcl
22
+ if (!hasTfFiles) {
23
+ try {
24
+ await fs.access(path.join(projectDir, ".terraform.lock.hcl"));
25
+ hasTfFiles = true;
26
+ detectedFiles.push(".terraform.lock.hcl");
27
+ }
28
+ catch {
29
+ // not found
30
+ }
31
+ }
32
+ if (!hasTfFiles) {
33
+ return {};
34
+ }
35
+ return {
36
+ languages: ["hcl"],
37
+ frameworks: ["terraform"],
38
+ packageManagers: ["terraform"],
39
+ testCommands: ["terraform test"],
40
+ lintCommands: ["tflint"],
41
+ buildCommands: ["terraform plan"],
42
+ blockedPaths: [".terraform/", "*.tfstate", "*.tfstate.backup"],
43
+ detectedFiles,
44
+ };
45
+ },
46
+ };
@@ -35,22 +35,34 @@ export function wrapWithLogger(script, event = "unknown") {
35
35
  export async function generateHooks(options) {
36
36
  const { projectDir, config } = options;
37
37
  const hooksDir = join(projectDir, ".claude/hooks");
38
- const allHooks = [
39
- ...config.hooks.preToolUse.map((h) => ({ ...h, event: "PreToolUse" })),
40
- ...config.hooks.postToolUse.map((h) => ({ ...h, event: "PostToolUse" })),
38
+ const eventMap = [
39
+ ["PreToolUse", config.hooks.preToolUse],
40
+ ["PostToolUse", config.hooks.postToolUse],
41
+ ["SessionStart", config.hooks.sessionStart ?? []],
42
+ ["Notification", config.hooks.notification ?? []],
43
+ ["ConfigChange", config.hooks.configChange ?? []],
44
+ ["WorktreeCreate", config.hooks.worktreeCreate ?? []],
41
45
  ];
46
+ const allHooks = eventMap.flatMap(([event, hooks]) => hooks.map((h) => ({ ...h, event })));
42
47
  if (allHooks.length === 0) {
43
48
  return { hooksConfig: {}, generatedFiles: [] };
44
49
  }
45
50
  await mkdir(hooksDir, { recursive: true });
46
51
  const generatedFiles = [];
47
52
  const hooksConfig = {};
53
+ const usedScriptNames = new Set();
48
54
  for (const hook of allHooks) {
49
55
  if (!hook.inline) {
50
56
  continue;
51
57
  }
52
58
  const safeId = hook.id.replace(/[^a-zA-Z0-9_-]/g, "");
53
- const scriptName = `${safeId}.sh`;
59
+ let scriptName = `${safeId}.sh`;
60
+ // Prevent collisions when different events share the same hook.id
61
+ if (usedScriptNames.has(scriptName)) {
62
+ const safeEvent = hook.event.replace(/[^a-zA-Z0-9_-]/g, "").toLowerCase();
63
+ scriptName = `${safeEvent}-${safeId}.sh`;
64
+ }
65
+ usedScriptNames.add(scriptName);
54
66
  const scriptPath = join(hooksDir, scriptName);
55
67
  const wrappedScript = wrapWithLogger(hook.inline, hook.event);
56
68
  await writeFile(scriptPath, wrappedScript, "utf8");
@@ -58,7 +70,7 @@ export async function generateHooks(options) {
58
70
  generatedFiles.push(scriptPath);
59
71
  const entry = {
60
72
  matcher: hook.matcher,
61
- hooks: [{ type: "command", command: `bash .claude/hooks/${safeId}.sh` }],
73
+ hooks: [{ type: "command", command: `bash .claude/hooks/${scriptName}` }],
62
74
  };
63
75
  if (!hooksConfig[hook.event]) {
64
76
  hooksConfig[hook.event] = [];
@@ -17,6 +17,9 @@ export async function generateSettings(options) {
17
17
  const existingPermissions = (existing.permissions ?? {});
18
18
  const mergedAllow = Array.from(new Set([...(existingPermissions.allow ?? []), ...(config.settings.permissions.allow ?? [])]));
19
19
  const mergedDeny = Array.from(new Set([...(existingPermissions.deny ?? []), ...(config.settings.permissions.deny ?? [])]));
20
+ // Preserve managedAt if content is unchanged
21
+ const existingMeta = (existing._ohMyHarness ?? {});
22
+ const previousManagedAt = existingMeta.managedAt;
20
23
  const result = {
21
24
  ...existing,
22
25
  permissions: {
@@ -26,10 +29,27 @@ export async function generateSettings(options) {
26
29
  },
27
30
  hooks: hooksOutput.hooksConfig,
28
31
  _ohMyHarness: {
29
- managedAt: new Date().toISOString(),
32
+ managedAt: "__PLACEHOLDER__",
30
33
  presets: config.presets,
31
34
  },
32
35
  };
36
+ // Compare content without timestamp to decide if managedAt should update
37
+ const newContent = JSON.stringify(result, null, 2) + "\n";
38
+ const oldMeta = existing._ohMyHarness && typeof existing._ohMyHarness === "object" && !Array.isArray(existing._ohMyHarness)
39
+ ? { ...existing._ohMyHarness }
40
+ : {};
41
+ const oldResultForCompare = {
42
+ ...existing,
43
+ _ohMyHarness: oldMeta,
44
+ };
45
+ if (oldMeta.managedAt) {
46
+ oldMeta.managedAt = "__PLACEHOLDER__";
47
+ }
48
+ const oldContent = JSON.stringify(oldResultForCompare, null, 2) + "\n";
49
+ const managedAt = newContent === oldContent && previousManagedAt
50
+ ? previousManagedAt
51
+ : new Date().toISOString();
52
+ result._ohMyHarness.managedAt = managedAt;
33
53
  await fs.mkdir(claudeDir, { recursive: true });
34
54
  await fs.writeFile(settingsPath, JSON.stringify(result, null, 2) + "\n", "utf-8");
35
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,65 @@
1
+ name: terraform
2
+ displayName: "Terraform"
3
+ description: "Infrastructure as Code with Terraform/OpenTofu, tflint, and tfsec"
4
+ version: "1.0.0"
5
+ extends: ["_base"]
6
+ tags: ["terraform", "iac", "infrastructure", "hcl"]
7
+ variables:
8
+ language: "hcl"
9
+ framework: "terraform"
10
+ testRunner: "terraform test"
11
+ linter: "tflint"
12
+ claudeMd:
13
+ sections:
14
+ - id: "terraform-rules"
15
+ title: "Terraform Rules"
16
+ content: |
17
+ ## Terraform Development Rules
18
+
19
+ - Run `terraform fmt` before every commit
20
+ - Run `terraform validate` to check syntax before applying
21
+ - NEVER modify `.tfstate` files directly — use `terraform state` commands
22
+ - NEVER hardcode secrets in `.tf` or `.tfvars` files — use variables with sensitive flag
23
+ - Use `terraform plan` to preview changes before `terraform apply`
24
+ - NEVER use `terraform apply -auto-approve` without explicit user confirmation
25
+ - Use consistent naming: snake_case for resources, variables, and outputs
26
+ - Pin provider versions in `required_providers` block
27
+ - Use modules for reusable infrastructure components
28
+ priority: 20
29
+ - id: "terraform-testing"
30
+ title: "Terraform Testing"
31
+ content: |
32
+ ## Terraform Testing Rules
33
+
34
+ - Write tests in `tests/` directory using `terraform test` (HCL-based)
35
+ - Validate all modules with `terraform validate` before commit
36
+ - Run `tflint` to catch common misconfigurations
37
+ - Run `tfsec` for security scanning
38
+ - Use `terraform plan` as a smoke test for syntax and dependency checks
39
+ priority: 21
40
+ - id: "terraform-structure"
41
+ title: "Terraform Structure"
42
+ content: |
43
+ ## Terraform Project Structure
44
+
45
+ - `main.tf` — Primary resource definitions
46
+ - `variables.tf` — Input variable declarations
47
+ - `outputs.tf` — Output value declarations
48
+ - `providers.tf` — Provider configuration and version constraints
49
+ - `terraform.tfvars` — Variable values (DO NOT commit secrets)
50
+ - `modules/` — Reusable module definitions
51
+ - `tests/` — Terraform test files
52
+ priority: 22
53
+ settings:
54
+ permissions:
55
+ allow:
56
+ - "Bash(terraform *)"
57
+ - "Bash(tofu *)"
58
+ - "Bash(tflint *)"
59
+ - "Bash(tfsec *)"
60
+ - "Bash(terraform-docs *)"
61
+ deny:
62
+ - "Bash(sudo *)"
63
+ - "Bash(rm -rf *)"
64
+ - "Bash(terraform apply -auto-approve*)"
65
+ - "Bash(terraform apply --auto-approve*)"