oh-my-harness 0.6.1 โ†’ 0.7.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 (63) hide show
  1. package/README.md +3 -3
  2. package/dist/catalog/blocks/command-guard.js +2 -2
  3. package/dist/catalog/blocks/commit-test-gate.js +2 -2
  4. package/dist/catalog/blocks/commit-typecheck-gate.js +2 -2
  5. package/dist/catalog/blocks/format-on-save.js +3 -3
  6. package/dist/catalog/blocks/lint-on-save.js +3 -3
  7. package/dist/catalog/blocks/lockfile-guard.js +1 -1
  8. package/dist/catalog/blocks/path-guard.js +1 -1
  9. package/dist/catalog/blocks/secret-file-guard.js +1 -1
  10. package/dist/catalog/blocks/tdd-guard.js +11 -6
  11. package/dist/catalog/template-engine.js +5 -0
  12. package/dist/cli/command-checker.js +6 -2
  13. package/dist/cli/harness-tester.js +20 -11
  14. package/dist/cli/stats/components/Blocks.js +1 -1
  15. package/dist/cli/stats/components/Overview.js +1 -1
  16. package/dist/cli/stats/data.d.ts +1 -0
  17. package/dist/cli/stats/data.js +24 -3
  18. package/dist/cli/tool-checker.js +7 -1
  19. package/dist/cli/tui/init-flow.d.ts +1 -0
  20. package/dist/cli/tui/init-flow.js +134 -20
  21. package/dist/core/harness-converter-v2.js +2 -4
  22. package/dist/detector/detectors/node.js +38 -0
  23. package/dist/detector/detectors/python.js +56 -8
  24. package/package.json +1 -1
  25. package/presets/actix/preset.yaml +55 -0
  26. package/presets/cargo/preset.yaml +11 -0
  27. package/presets/cpp/preset.yaml +50 -0
  28. package/presets/csharp/preset.yaml +42 -0
  29. package/presets/dart/preset.yaml +43 -0
  30. package/presets/django/preset.yaml +72 -0
  31. package/presets/elixir/preset.yaml +45 -0
  32. package/presets/express/preset.yaml +61 -0
  33. package/presets/fastapi/preset.yaml +1 -1
  34. package/presets/flask/preset.yaml +65 -0
  35. package/presets/flutter/preset.yaml +64 -0
  36. package/presets/gin/preset.yaml +57 -0
  37. package/presets/go/preset.yaml +43 -0
  38. package/presets/gradle/preset.yaml +13 -0
  39. package/presets/java/preset.yaml +48 -0
  40. package/presets/javascript/preset.yaml +44 -0
  41. package/presets/laravel/preset.yaml +70 -0
  42. package/presets/maven/preset.yaml +13 -0
  43. package/presets/nextjs/preset.yaml +1 -1
  44. package/presets/nextjs-fastapi/preset.yaml +1 -1
  45. package/presets/npm/preset.yaml +12 -0
  46. package/presets/phoenix/preset.yaml +64 -0
  47. package/presets/php/preset.yaml +46 -0
  48. package/presets/pip/preset.yaml +13 -0
  49. package/presets/pipenv/preset.yaml +12 -0
  50. package/presets/pnpm/preset.yaml +12 -0
  51. package/presets/python/preset.yaml +48 -0
  52. package/presets/rails/preset.yaml +65 -0
  53. package/presets/react/preset.yaml +61 -0
  54. package/presets/ruby/preset.yaml +45 -0
  55. package/presets/rust/preset.yaml +42 -0
  56. package/presets/scala/preset.yaml +44 -0
  57. package/presets/springboot/preset.yaml +61 -0
  58. package/presets/swift/preset.yaml +44 -0
  59. package/presets/typescript/preset.yaml +46 -0
  60. package/presets/uv/preset.yaml +12 -0
  61. package/presets/vue/preset.yaml +62 -0
  62. package/presets/yarn/preset.yaml +12 -0
  63. package/presets/zig/preset.yaml +41 -0
package/README.md CHANGED
@@ -122,7 +122,7 @@ oh-my-harness automatically detects your project type and injects accurate facts
122
122
  | Language | Detection | Commands |
123
123
  |----------|-----------|----------|
124
124
  | ๐ŸŸฆ TypeScript/JS | package.json, tsconfig | pnpm/npm/yarn test, eslint, tsc |
125
- | ๐Ÿ Python | pyproject.toml, requirements.txt | pytest, ruff, mypy |
125
+ | ๐Ÿ Python | pyproject.toml, requirements.txt, Pipfile, manage.py, .python-version | pytest, ruff, black, isort, mypy |
126
126
  | ๐ŸŽ Swift | Package.swift, .xcodeproj | swift test, xcodebuild |
127
127
  | ๐Ÿฆ€ Rust | Cargo.toml | cargo test, cargo clippy |
128
128
  | ๐Ÿน Go | go.mod | go test, golangci-lint |
@@ -154,7 +154,7 @@ All enforcement is powered by **catalog blocks** โ€” reusable, parameterized hoo
154
154
  | โœ๏ธ `lint-on-save` | auto-fix | Auto-lint on file save |
155
155
  | ๐ŸŽจ `format-on-save` | auto-fix | Auto-format on file save |
156
156
  | ๐Ÿ”€ `auto-pr` | automation | Auto-create PR after push |
157
- | ๐Ÿงช `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS) |
157
+ | ๐Ÿงช `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS/Python) |
158
158
 
159
159
  ### Usage in `harness.yaml`
160
160
 
@@ -331,7 +331,7 @@ oh-my-harness/
331
331
  โ”‚ โ”œโ”€โ”€ parse-intent.ts # claude -p integration
332
332
  โ”‚ โ””โ”€โ”€ prompt-templates.ts # LLM prompt construction
333
333
  โ”œโ”€โ”€ presets/ # Built-in preset definitions
334
- โ””โ”€โ”€ tests/ # 712+ tests (unit + integration)
334
+ โ””โ”€โ”€ tests/ # 745+ tests (unit + integration)
335
335
  ```
336
336
 
337
337
  ---
@@ -21,9 +21,9 @@ set -euo pipefail
21
21
  INPUT=$(cat)
22
22
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
23
23
  [[ -z "$COMMAND" ]] && exit 0
24
- PATTERNS=({{#each patterns}}"{{this}}" {{/each}})
24
+ PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
25
25
  for PATTERN in "\${PATTERNS[@]}"; do
26
- if echo "$COMMAND" | grep -qF "$PATTERN"; then
26
+ if echo "$COMMAND" | grep -qF -- "$PATTERN"; then
27
27
  _log_event "block" "oh-my-harness: command matches blocked pattern: $PATTERN"
28
28
  echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: command matches blocked pattern: $PATTERN\\"}"
29
29
  exit 0
@@ -15,8 +15,8 @@ set -euo pipefail
15
15
  INPUT=$(cat)
16
16
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
17
  if echo "$COMMAND" | grep -qE "git commit"; then
18
- echo "oh-my-harness: Running {{testCommand}} before commit..." >&2
19
- if ! {{testCommand}} >&2 2>&1; then
18
+ echo "oh-my-harness: Running {{{testCommand}}} before commit..." >&2
19
+ if ! {{{testCommand}}} >&2 2>&1; then
20
20
  _log_event "block" "oh-my-harness: pre-commit check failed"
21
21
  echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
22
22
  exit 0
@@ -15,8 +15,8 @@ set -euo pipefail
15
15
  INPUT=$(cat)
16
16
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
17
  if echo "$COMMAND" | grep -qE "git commit"; then
18
- echo "oh-my-harness: Running {{typecheckCommand}} before commit..." >&2
19
- if ! {{typecheckCommand}} >&2 2>&1; then
18
+ echo "oh-my-harness: Running {{{typecheckCommand}}} before commit..." >&2
19
+ if ! {{{typecheckCommand}}} >&2 2>&1; then
20
20
  _log_event "block" "oh-my-harness: pre-commit check failed"
21
21
  echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
22
22
  exit 0
@@ -26,11 +26,11 @@ set -euo pipefail
26
26
  INPUT=$(cat)
27
27
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
28
28
  [[ -z "$FILE_PATH" ]] && exit 0
29
- PATTERN='{{filePattern}}'
29
+ PATTERN='{{{filePattern}}}'
30
30
  BASENAME=$(basename "$FILE_PATH")
31
31
  if [[ "$BASENAME" == $PATTERN ]]; then
32
- echo "oh-my-harness: Running {{command}} on $FILE_PATH..." >&2
33
- {{command}} "$FILE_PATH" >&2 2>&1 || true
32
+ echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
33
+ {{{command}}} "$FILE_PATH" >&2 2>&1 || true
34
34
  fi
35
35
  exit 0`,
36
36
  };
@@ -26,11 +26,11 @@ set -euo pipefail
26
26
  INPUT=$(cat)
27
27
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
28
28
  [[ -z "$FILE_PATH" ]] && exit 0
29
- PATTERN='{{filePattern}}'
29
+ PATTERN='{{{filePattern}}}'
30
30
  BASENAME=$(basename "$FILE_PATH")
31
31
  if [[ "$BASENAME" == $PATTERN ]]; then
32
- echo "oh-my-harness: Running {{command}} on $FILE_PATH..." >&2
33
- {{command}} "$FILE_PATH" >&2 2>&1 || true
32
+ echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
33
+ {{{command}}} "$FILE_PATH" >&2 2>&1 || true
34
34
  fi
35
35
  exit 0`,
36
36
  };
@@ -22,7 +22,7 @@ INPUT=$(cat)
22
22
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
23
23
  [[ -z "$FILE_PATH" ]] && exit 0
24
24
  BASENAME=$(basename "$FILE_PATH")
25
- LOCKFILES=({{#each lockfiles}}"{{this}}" {{/each}})
25
+ LOCKFILES=({{#each lockfiles}}"{{{this}}}" {{/each}})
26
26
  for LOCKFILE in "\${LOCKFILES[@]}"; do
27
27
  if [[ "$BASENAME" == "$LOCKFILE" ]]; then
28
28
  _log_event "block" "oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead."
@@ -20,7 +20,7 @@ set -euo pipefail
20
20
  INPUT=$(cat)
21
21
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
22
22
  [[ -z "$FILE_PATH" ]] && exit 0
23
- BLOCKED_PATHS=({{#each blockedPaths}}"{{this}}" {{/each}})
23
+ 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
@@ -22,7 +22,7 @@ INPUT=$(cat)
22
22
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
23
23
  [[ -z "$FILE_PATH" ]] && exit 0
24
24
  BASENAME=$(basename "$FILE_PATH")
25
- PATTERNS=({{#each patterns}}"{{this}}" {{/each}})
25
+ PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
26
  for PATTERN in "\${PATTERNS[@]}"; do
27
27
  if [[ "$BASENAME" == $PATTERN ]]; then
28
28
  _log_event "block" "oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN"
@@ -38,7 +38,12 @@ STATE_DIR=".claude/hooks/.state"
38
38
  HISTORY_FILE="\$STATE_DIR/edit-history.json"
39
39
  mkdir -p "\$STATE_DIR" 2>/dev/null || true
40
40
 
41
- if echo "\$FILE_PATH" | grep -qE '{{testPattern}}'; then
41
+ TEST_RE='{{{testPattern}}}'
42
+ SRC_RE='{{{srcPattern}}}'
43
+ # Normalize double backslashes to single for [[ =~ ]] compatibility
44
+ TEST_RE="\${TEST_RE//\\\\\\\\/\\\\}"
45
+ SRC_RE="\${SRC_RE//\\\\\\\\/\\\\}"
46
+ if [[ "\$FILE_PATH" =~ \$TEST_RE ]]; then
42
47
  # ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ˆ˜์ • โ†’ ๊ธฐ๋ก + ํ†ต๊ณผ
43
48
  if [[ ! -f "\$HISTORY_FILE" ]]; then
44
49
  echo '{"edits":[]}' > "\$HISTORY_FILE"
@@ -50,13 +55,13 @@ if echo "\$FILE_PATH" | grep -qE '{{testPattern}}'; then
50
55
  exit 0
51
56
  fi
52
57
 
53
- # ์†Œ์Šค ํŒŒ์ผ (.ts/.tsx/.js/.jsx) ์ด ์•„๋‹ˆ๋ฉด ํ†ต๊ณผ
54
- if ! echo "\$FILE_PATH" | grep -qE '{{srcPattern}}'; then
58
+ # ์†Œ์Šค ํŒŒ์ผ์ด ์•„๋‹ˆ๋ฉด ํ†ต๊ณผ
59
+ if [[ ! "\$FILE_PATH" =~ \$SRC_RE ]]; then
55
60
  exit 0
56
61
  fi
57
62
 
58
- # ๋Œ€์‘ ํ…Œ์ŠคํŠธ ํŒŒ์ผ ํ™•์ธ
59
- BASENAME=$(basename "\$FILE_PATH" | sed -E 's/\\.(ts|tsx|js|jsx)$//')
63
+ # ๋Œ€์‘ ํ…Œ์ŠคํŠธ ํŒŒ์ผ ํ™•์ธ โ€” ํ™•์žฅ์ž ์ œ๊ฑฐ
64
+ BASENAME=$(basename "\$FILE_PATH" | sed -E 's/\\.[^.]+$//')
60
65
  TEST_SUFFIX=".test."
61
66
 
62
67
  if [[ ! -f "\$HISTORY_FILE" ]]; then
@@ -66,7 +71,7 @@ if [[ ! -f "\$HISTORY_FILE" ]]; then
66
71
  fi
67
72
 
68
73
  # 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
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
70
75
  # ํ…Œ์ŠคํŠธ ๋จผ์ € ์ˆ˜์ •๋จ โ†’ ์†Œ์Šค ๊ธฐ๋ก + ํ†ต๊ณผ
71
76
  UPDATED=$(jq --arg f "\$FILE_PATH" '.edits += [$f] | .edits |= unique' "\$HISTORY_FILE" 2>/dev/null) || true
72
77
  if [[ -n "\$UPDATED" ]]; then
@@ -9,6 +9,11 @@ export function applyDefaults(block, params) {
9
9
  if (result[param.name] === undefined && param.default !== undefined) {
10
10
  result[param.name] = param.default;
11
11
  }
12
+ // Auto-wrap string into array for string[] params
13
+ if (param.type === "string[]" && typeof result[param.name] === "string") {
14
+ const str = result[param.name];
15
+ result[param.name] = str.includes(",") ? str.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : [str];
16
+ }
12
17
  }
13
18
  return result;
14
19
  }
@@ -3,11 +3,15 @@ import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
4
  // ๋ช…๋ น์–ด์—์„œ ๋ฐ”์ด๋„ˆ๋ฆฌ ์ถ”์ถœ (์ฒซ ํ† ํฐ, ๋ž˜ํผ ์ฒ˜๋ฆฌ)
5
5
  export function extractExecutable(command) {
6
- const parts = command.trim().split(/\s+/);
6
+ let parts = command.trim().split(/\s+/);
7
+ // Skip environment variable prefixes (KEY=VALUE)
8
+ while (parts.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[0])) {
9
+ parts = parts.slice(1);
10
+ }
7
11
  // npm run X โ†’ npm
8
12
  // npx X โ†’ npx
9
13
  // bash script.sh โ†’ bash
10
- return parts[0];
14
+ return parts[0] ?? "";
11
15
  }
12
16
  // ๋ช…๋ น์–ด ์‹คํ–‰ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
13
17
  export async function checkCommandExecutable(binary) {
@@ -189,13 +189,16 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
189
189
  break;
190
190
  }
191
191
  case "lockfile-guard": {
192
- cases.push({
193
- name: "package-lock.json โ†’ BLOCKED",
194
- category: "lockfile-guard",
195
- hookScript,
196
- input: { tool_name: "Edit", tool_input: { file_path: "package-lock.json" } },
197
- expectation: "block",
198
- });
192
+ const lockfiles = params.lockfiles ?? ["package-lock.json"];
193
+ for (const lf of lockfiles) {
194
+ cases.push({
195
+ name: `${lf} โ†’ BLOCKED`,
196
+ category: "lockfile-guard",
197
+ hookScript,
198
+ input: { tool_name: "Edit", tool_input: { file_path: lf } },
199
+ expectation: "block",
200
+ });
201
+ }
199
202
  cases.push({
200
203
  name: "package.json โ†’ ALLOWED",
201
204
  category: "lockfile-guard",
@@ -224,12 +227,18 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
224
227
  }
225
228
  case "tdd-guard": {
226
229
  const stateFile = ".claude/hooks/.state/edit-history.json";
230
+ const srcPat = params.srcPattern ?? "\\.(ts|tsx|js|jsx)$";
231
+ const testPat = params.testPattern ?? "\\.(test|spec)\\.(ts|tsx|js|jsx)$";
232
+ // Derive sample file extensions from patterns
233
+ const srcExt = srcPat.includes(".py") ? ".py" : ".ts";
234
+ const testFile = testPat.includes("test_") ? `test_example${srcExt}` : `example.test${srcExt}`;
235
+ const srcFile = `src/example${srcExt}`;
227
236
  // block case: source file without prior test edit
228
237
  cases.push({
229
- name: "src/event-logger.ts without test โ†’ BLOCKED",
238
+ name: `${srcFile} without test โ†’ BLOCKED`,
230
239
  category: "tdd-guard",
231
240
  hookScript,
232
- input: { tool_name: "Edit", tool_input: { file_path: "src/event-logger.ts" } },
241
+ input: { tool_name: "Edit", tool_input: { file_path: srcFile } },
233
242
  expectation: "block",
234
243
  setup: async (projectDir) => {
235
244
  const historyPath = path.join(projectDir, stateFile);
@@ -252,10 +261,10 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
252
261
  });
253
262
  // allow case: test file edit
254
263
  cases.push({
255
- name: "tests/unit/event-logger.test.ts โ†’ ALLOWED",
264
+ name: `${testFile} โ†’ ALLOWED`,
256
265
  category: "tdd-guard",
257
266
  hookScript,
258
- input: { tool_name: "Edit", tool_input: { file_path: "tests/unit/event-logger.test.ts" } },
267
+ input: { tool_name: "Edit", tool_input: { file_path: testFile } },
259
268
  expectation: "allow",
260
269
  setup: async (projectDir) => {
261
270
  const historyPath = path.join(projectDir, stateFile);
@@ -10,5 +10,5 @@ export function Blocks({ data, selectedIndex }) {
10
10
  }
11
11
  const clampedIndex = Math.max(0, Math.min(selectedIndex, allBlocks.length - 1));
12
12
  const selected = allBlocks[clampedIndex];
13
- return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { flexDirection: "column", width: 28, children: allBlocks.map((block, i) => (_jsxs(Text, { inverse: i === clampedIndex, color: block.hits === 0 ? "gray" : undefined, children: [i === clampedIndex ? "โ–ถ" : " ", " ", block.id, block.hits > 0 ? ` (${block.hits})` : ""] }, block.id))) }), _jsx(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, flexGrow: 1, children: selected && _jsx(BlockDetail, { block: selected }) })] }));
13
+ return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { flexDirection: "column", width: 28, children: allBlocks.map((block, i) => (_jsxs(Text, { inverse: i === clampedIndex, color: block.hits === 0 ? "gray" : undefined, children: [i === clampedIndex ? "โ–ถ" : " ", " ", block.id, block.hits > 0 ? ` (${block.hits})` : ""] }, i))) }), _jsx(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, flexGrow: 1, children: selected && _jsx(BlockDetail, { block: selected }) })] }));
14
14
  }
@@ -2,5 +2,5 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Text, Box } from "ink";
3
3
  import { HitBar } from "./HitBar.js";
4
4
  export function Overview({ data }) {
5
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { bold: true, children: ["Active: ", data.activeBlocks.length] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Events: ", data.totalEvents] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Block rate: ", data.blockRate, "%"] })] }), data.activeBlocks.map(block => (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { color: block.canBlock ? "cyan" : "gray", children: block.id }) }), _jsx(HitBar, { blockCount: block.blockCount, allowCount: block.allowCount }), _jsxs(Text, { children: [" ", block.hits, " hits"] }), block.blockCount > 0 && _jsxs(Text, { color: "red", children: [" (", block.blockCount, " block)"] })] }, block.id))), data.dormantBlocks.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Dormant (0 hits):" }), data.dormantBlocks.map(block => (_jsxs(Text, { dimColor: true, children: [" \u2591 ", block.id] }, block.id)))] }))] }));
5
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { bold: true, children: ["Active: ", data.activeBlocks.length] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Events: ", data.totalEvents] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Block rate: ", data.blockRate, "%"] })] }), data.activeBlocks.map((block, i) => (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { color: block.canBlock ? "cyan" : "gray", children: block.id }) }), _jsx(HitBar, { blockCount: block.blockCount, allowCount: block.allowCount }), _jsxs(Text, { children: [" ", block.hits, " hits"] }), block.blockCount > 0 && _jsxs(Text, { color: "red", children: [" (", block.blockCount, " block)"] })] }, i))), data.dormantBlocks.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Dormant (0 hits):" }), data.dormantBlocks.map((block, i) => (_jsxs(Text, { dimColor: true, children: [" \u2591 ", block.id] }, i)))] }))] }));
6
6
  }
@@ -33,6 +33,7 @@ export interface StatsData {
33
33
  hourlyDistribution: HourlyBucket[];
34
34
  dateRange: DateRange;
35
35
  }
36
+ export declare function deduplicateBlocks(blocks: BlockStats[]): BlockStats[];
36
37
  export declare function getActiveBlocks(hookEntries: HookEntry[], allBlocks: BuildingBlock[]): {
37
38
  block: BuildingBlock;
38
39
  params: Record<string, unknown>;
@@ -4,6 +4,27 @@ import { builtinBlocks } from "../../catalog/blocks/index.js";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import yaml from "js-yaml";
7
+ export function deduplicateBlocks(blocks) {
8
+ const map = new Map();
9
+ for (const b of blocks) {
10
+ const existing = map.get(b.id);
11
+ if (existing) {
12
+ existing.hits += b.hits;
13
+ existing.blockCount += b.blockCount;
14
+ existing.allowCount += b.allowCount;
15
+ if (b.lastHit && (!existing.lastHit || b.lastHit > existing.lastHit)) {
16
+ existing.lastHit = b.lastHit;
17
+ }
18
+ if (b.lastBlockReason) {
19
+ existing.lastBlockReason = b.lastBlockReason;
20
+ }
21
+ }
22
+ else {
23
+ map.set(b.id, { ...b });
24
+ }
25
+ }
26
+ return [...map.values()];
27
+ }
7
28
  export function getActiveBlocks(hookEntries, allBlocks) {
8
29
  const result = [];
9
30
  for (const entry of hookEntries) {
@@ -94,9 +115,9 @@ export async function loadStatsData(projectDir, dateRange = "all") {
94
115
  }
95
116
  const activeBlocksWithParams = getActiveBlocks(hookEntries, builtinBlocks);
96
117
  const dormantIds = getDormantBlocks(activeBlocksWithParams, events);
97
- const blocks = activeBlocksWithParams.map(ab => getBlockDetail(ab.block.id, events, builtinBlocks, ab.params));
98
- const activeBlocks = blocks.filter(b => !dormantIds.includes(b.id));
99
- const dormantBlocks = blocks.filter(b => dormantIds.includes(b.id));
118
+ const blocks = deduplicateBlocks(activeBlocksWithParams.map(ab => getBlockDetail(ab.block.id, events, builtinBlocks, ab.params)));
119
+ const activeBlocks = deduplicateBlocks(blocks.filter(b => !dormantIds.includes(b.id)));
120
+ const dormantBlocks = deduplicateBlocks(blocks.filter(b => dormantIds.includes(b.id)));
100
121
  const hourlyDistribution = getHourlyDistribution(events);
101
122
  const stats = aggregateStats(events);
102
123
  const peakHour = hourlyDistribution.reduce((max, b) => b.total > max.total ? b : max, hourlyDistribution[0]).hour;
@@ -17,7 +17,13 @@ function extractBinary(command) {
17
17
  const trimmed = command.trim();
18
18
  if (!trimmed)
19
19
  return undefined;
20
- const parts = trimmed.split(/\s+/);
20
+ let parts = trimmed.split(/\s+/);
21
+ // Skip environment variable prefixes (KEY=VALUE)
22
+ while (parts.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[0])) {
23
+ parts = parts.slice(1);
24
+ }
25
+ if (parts.length === 0)
26
+ return undefined;
21
27
  // Skip gradle wrapper commands (managed by project)
22
28
  if (parts[0] === "./gradlew" || parts[0] === "gradlew")
23
29
  return undefined;
@@ -4,6 +4,7 @@ import type { ProjectFacts } from "../../detector/types.js";
4
4
  export declare function formatDepResults(deps: DepCheck[]): string;
5
5
  export declare function formatConfigSummary(config: HarnessConfig): string;
6
6
  export declare function formatProjectFacts(facts: ProjectFacts): string;
7
+ export declare function buildPresetExtends(language: string, framework: string | undefined, packageManager: string | undefined): string[];
7
8
  export declare function runInitTUI(options?: {
8
9
  projectDir?: string;
9
10
  presetsDir?: string;
@@ -88,6 +88,16 @@ export function formatProjectFacts(facts) {
88
88
  }
89
89
  return lines.length > 0 ? lines.join("\n") : ` ${chalk.dim("No project signals detected")}`;
90
90
  }
91
+ export function buildPresetExtends(language, framework, packageManager) {
92
+ const result = ["_base", language];
93
+ if (framework && framework !== "none") {
94
+ result.push(framework);
95
+ }
96
+ if (packageManager) {
97
+ result.push(packageManager);
98
+ }
99
+ return result;
100
+ }
91
101
  function handleCancel(value) {
92
102
  if (p.isCancel(value)) {
93
103
  p.cancel("Operation cancelled.");
@@ -190,7 +200,15 @@ export async function runInitTUI(options) {
190
200
  const genSpinner = p.spinner();
191
201
  genSpinner.start("Generating harness configuration...");
192
202
  try {
193
- harnessConfig = await generateHarnessConfig(description, undefined, undefined, projectFacts);
203
+ // Load catalog blocks so LLM knows available building blocks + validation
204
+ const { createDefaultRegistry } = await import("../../catalog/registry.js");
205
+ const catalogRegistry = await createDefaultRegistry();
206
+ const catalogBlocks = catalogRegistry.list().map((b) => ({
207
+ id: b.id,
208
+ description: b.description,
209
+ params: b.params.map((pp) => ({ name: pp.name, required: pp.required, default: pp.default, description: pp.description })),
210
+ }));
211
+ harnessConfig = await generateHarnessConfig(description, undefined, catalogBlocks, projectFacts);
194
212
  genSpinner.stop("Configuration generated");
195
213
  }
196
214
  catch (err) {
@@ -213,28 +231,124 @@ export async function runInitTUI(options) {
213
231
  }
214
232
  }
215
233
  else if (mode === "preset") {
216
- // Step 4b: Preset Mode
217
- const registry = new PresetRegistry();
218
- await registry.discover(presetsDir);
219
- const allPresets = registry.list().filter((e) => e.name !== "_base");
220
- if (allPresets.length === 0) {
221
- p.log.warn("No presets found (besides _base).");
222
- presetNames = ["_base"];
234
+ // Step 4b: Preset Mode โ€” 3-step interactive language/framework/PM selection
235
+ p.log.info(`${chalk.dim("_base preset is always included automatically.")}`);
236
+ // Step 4b-1: Language
237
+ const language = await p.select({
238
+ message: "Choose your language",
239
+ options: [
240
+ { value: "python", label: "Python" },
241
+ { value: "typescript", label: "TypeScript" },
242
+ { value: "javascript", label: "JavaScript" },
243
+ { value: "rust", label: "Rust" },
244
+ { value: "go", label: "Go" },
245
+ { value: "java", label: "Java" },
246
+ { value: "ruby", label: "Ruby" },
247
+ { value: "php", label: "PHP" },
248
+ { value: "swift", label: "Swift" },
249
+ { value: "dart", label: "Dart" },
250
+ { value: "csharp", label: "C#" },
251
+ { value: "elixir", label: "Elixir" },
252
+ { value: "scala", label: "Scala" },
253
+ { value: "zig", label: "Zig" },
254
+ { value: "cpp", label: "C/C++" },
255
+ ],
256
+ });
257
+ handleCancel(language);
258
+ const frameworkOptions = {
259
+ python: [
260
+ { value: "django", label: "Django" },
261
+ { value: "fastapi", label: "FastAPI" },
262
+ { value: "flask", label: "Flask" },
263
+ { value: "none", label: "None" },
264
+ ],
265
+ typescript: [
266
+ { value: "nextjs", label: "Next.js" },
267
+ { value: "react", label: "React" },
268
+ { value: "vue", label: "Vue" },
269
+ { value: "express", label: "Express" },
270
+ { value: "none", label: "None" },
271
+ ],
272
+ javascript: [
273
+ { value: "nextjs", label: "Next.js" },
274
+ { value: "react", label: "React" },
275
+ { value: "vue", label: "Vue" },
276
+ { value: "express", label: "Express" },
277
+ { value: "none", label: "None" },
278
+ ],
279
+ java: [
280
+ { value: "springboot", label: "Spring Boot" },
281
+ { value: "none", label: "None" },
282
+ ],
283
+ ruby: [
284
+ { value: "rails", label: "Rails" },
285
+ { value: "none", label: "None" },
286
+ ],
287
+ php: [
288
+ { value: "laravel", label: "Laravel" },
289
+ { value: "none", label: "None" },
290
+ ],
291
+ go: [
292
+ { value: "gin", label: "Gin" },
293
+ { value: "none", label: "None" },
294
+ ],
295
+ rust: [
296
+ { value: "actix", label: "Actix" },
297
+ { value: "none", label: "None" },
298
+ ],
299
+ dart: [
300
+ { value: "flutter", label: "Flutter" },
301
+ { value: "none", label: "None" },
302
+ ],
303
+ elixir: [
304
+ { value: "phoenix", label: "Phoenix" },
305
+ { value: "none", label: "None" },
306
+ ],
307
+ };
308
+ // Step 4b-2: Framework (only for languages with options)
309
+ let framework;
310
+ const fwOptions = frameworkOptions[language];
311
+ if (fwOptions && fwOptions.length > 1) {
312
+ const fw = await p.select({
313
+ message: "Choose your framework",
314
+ options: fwOptions,
315
+ });
316
+ handleCancel(fw);
317
+ framework = fw;
223
318
  }
224
- else {
225
- p.log.info(`${chalk.dim("_base preset is always included automatically.")}`);
226
- const selected = await p.multiselect({
227
- message: "Select presets to apply:",
228
- options: allPresets.map((entry) => ({
229
- value: entry.name,
230
- label: entry.config.displayName,
231
- hint: entry.config.description,
232
- })),
233
- required: true,
319
+ const pmOptions = {
320
+ python: [
321
+ { value: "uv", label: "uv" },
322
+ { value: "pipenv", label: "pipenv" },
323
+ { value: "pip", label: "pip" },
324
+ ],
325
+ typescript: [
326
+ { value: "pnpm", label: "pnpm" },
327
+ { value: "npm", label: "npm" },
328
+ { value: "yarn", label: "yarn" },
329
+ ],
330
+ javascript: [
331
+ { value: "pnpm", label: "pnpm" },
332
+ { value: "npm", label: "npm" },
333
+ { value: "yarn", label: "yarn" },
334
+ ],
335
+ java: [
336
+ { value: "gradle", label: "gradle" },
337
+ { value: "maven", label: "maven" },
338
+ ],
339
+ };
340
+ // Step 4b-3: Package Manager (only for languages with multiple options)
341
+ let packageManager;
342
+ const pmOpts = pmOptions[language];
343
+ if (pmOpts && pmOpts.length > 1) {
344
+ const pm = await p.select({
345
+ message: "Choose your package manager",
346
+ options: pmOpts,
234
347
  });
235
- handleCancel(selected);
236
- presetNames = ["_base", ...selected];
348
+ handleCancel(pm);
349
+ packageManager = pm;
237
350
  }
351
+ presetNames = buildPresetExtends(language, framework, packageManager);
238
352
  }
239
353
  else if (mode === "import") {
240
354
  // Step 4c: Import Mode
@@ -13,11 +13,8 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
13
13
  // Resolve registry โ€” use provided one or create the default
14
14
  const resolvedRegistry = registry ?? (await createDefaultRegistry());
15
15
  const catalogResult = await convertHookEntries(allHookEntries, resolvedRegistry, projectDir ?? ".");
16
- // If there are errors, return base config with catalogErrors attached
17
- if (catalogResult.errors.length > 0) {
18
- return { ...base, catalogErrors: catalogResult.errors };
19
- }
20
16
  // Convert hooksConfig entries from catalog into HookDefinition format.
17
+ // Errors are reported as warnings but don't block valid hooks.
21
18
  const additionalPreToolUse = [];
22
19
  const additionalPostToolUse = [];
23
20
  for (const [event, entries] of Object.entries(catalogResult.hooksConfig)) {
@@ -45,5 +42,6 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
45
42
  preToolUse: [...base.hooks.preToolUse, ...additionalPreToolUse],
46
43
  postToolUse: [...base.hooks.postToolUse, ...additionalPostToolUse],
47
44
  },
45
+ ...(catalogResult.errors.length > 0 ? { catalogErrors: catalogResult.errors } : {}),
48
46
  };
49
47
  }
@@ -43,6 +43,44 @@ export const nodeDetector = {
43
43
  if (!(await fileExists(packageJsonPath))) {
44
44
  return {};
45
45
  }
46
+ // Check if this is a real JS/TS project or just tooling (husky, linter wrappers)
47
+ const pkgCheck = await readJsonFile(packageJsonPath);
48
+ const deps = pkgCheck && typeof pkgCheck.dependencies === "object" && pkgCheck.dependencies !== null
49
+ ? Object.keys(pkgCheck.dependencies)
50
+ : [];
51
+ const devDeps = pkgCheck && typeof pkgCheck.devDependencies === "object" && pkgCheck.devDependencies !== null
52
+ ? Object.keys(pkgCheck.devDependencies)
53
+ : [];
54
+ const pkgScriptsRaw = pkgCheck && typeof pkgCheck.scripts === "object" && pkgCheck.scripts !== null
55
+ ? Object.values(pkgCheck.scripts).join(" ")
56
+ : "";
57
+ const jsDevIndicators = /typescript|react|vue|angular|next|vite|webpack|eslint|jest|vitest|mocha|babel|svelte|nuxt/;
58
+ const jsScriptIndicators = /vitest|jest|mocha|tsc|eslint|webpack|vite|next|react-scripts/;
59
+ // Also check for JS ecosystem config files
60
+ const jsConfigFiles = [
61
+ "tsconfig.json", ".eslintrc", ".eslintrc.js", ".eslintrc.json",
62
+ "eslint.config.js", "eslint.config.ts", "eslint.config.mjs",
63
+ "biome.json", "next.config.js", "next.config.ts", "next.config.mjs",
64
+ "vite.config.ts", "vite.config.js", "webpack.config.js",
65
+ ];
66
+ let hasJsConfigFile = false;
67
+ for (const cf of jsConfigFiles) {
68
+ if (await fileExists(path.join(projectDir, cf))) {
69
+ hasJsConfigFile = true;
70
+ break;
71
+ }
72
+ }
73
+ const hasWorkspaces = pkgCheck && (Array.isArray(pkgCheck.workspaces) || typeof pkgCheck.workspaces === "object");
74
+ const hasPackageManager = pkgCheck && typeof pkgCheck.packageManager === "string";
75
+ const hasJsIndicator = deps.length > 0
76
+ || devDeps.some((d) => jsDevIndicators.test(d))
77
+ || jsScriptIndicators.test(pkgScriptsRaw)
78
+ || hasJsConfigFile
79
+ || hasWorkspaces
80
+ || hasPackageManager;
81
+ if (!hasJsIndicator) {
82
+ return {};
83
+ }
46
84
  const detectedFiles = ["package.json"];
47
85
  const languages = [];
48
86
  const frameworks = [];