agent-harness-kit 0.5.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.
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook (matcher: Bash) — denies a small, deterministic set of
3
+ # shell commands that would bypass the harness's safety net. Replaces the
4
+ # "don't disable the structural test" warning in CLAUDE.md with a hard
5
+ # guardrail (Hashimoto axiom: every failure becomes a permanent prevention).
6
+ #
7
+ # What's denied (and why):
8
+ # 1. `git (push|commit) --no-verify` — bypasses pre-push baseline guard
9
+ # 2. `rm -rf .harness/` — wipes lockfile + baseline state
10
+ # 3. `rm -rf .claude/` — wipes skills/agents/hooks config
11
+ # 4. `chmod -x scripts/(structural-test|precompletion|pre-push)…`
12
+ # — disables hook scripts via perm bit
13
+ # 5. `> .harness/structural-baseline.json` (truncation)
14
+ # — wipes baseline without GC ritual
15
+ # 6. `jq … .harness/structural-baseline.json | … > ...`
16
+ # — manual baseline grow (covered by
17
+ # baseline-monotonic guard, but the
18
+ # agent should not even try)
19
+ # 7. Setting `disableAllHooks: true` via sed/jq into .claude/settings.json
20
+ #
21
+ # Allowed escape hatch: `AHK_ALLOW_BYPASS=1` environment variable. When
22
+ # present, the guard logs the attempt to .harness/bypass.log and lets the
23
+ # command through. Use only with explicit user intent (e.g. mass-rename
24
+ # refactor). The bypass leaves a paper trail so it can't be silent.
25
+ #
26
+ # Decision contract:
27
+ # - Pattern match + no bypass → exit 0 + JSON permissionDecision: "deny"
28
+ # with permissionDecisionReason explaining the rule.
29
+ # - No match → exit 0 with no output (defer to model's auto-mode).
30
+ # - Bypass env present → log + exit 0 (defer, command proceeds).
31
+ set -eo pipefail
32
+
33
+ INPUT=$(cat)
34
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
35
+ have_jq() {
36
+ [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
37
+ command -v jq >/dev/null 2>&1
38
+ }
39
+ have_jp() {
40
+ have_jq && return 0
41
+ command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
42
+ return 1
43
+ }
44
+ jp() {
45
+ if have_jq; then
46
+ if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
47
+ else
48
+ if [ -n "$2" ]; then
49
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
50
+ else
51
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
52
+ fi
53
+ fi
54
+ }
55
+
56
+ if ! have_jp; then
57
+ # Without a JSON parser we can't read the command. Skip rather than
58
+ # spuriously block — failing closed here would deny EVERY Bash call.
59
+ exit 0
60
+ fi
61
+
62
+ CMD=$(echo "$INPUT" | jp '.tool_input.command // empty')
63
+ [ -z "$CMD" ] && exit 0
64
+
65
+ # Compose denial reason. Empty when allowed.
66
+ REASON=""
67
+
68
+ # Pattern 1: --no-verify on git push / commit
69
+ if echo "$CMD" | grep -qE '\bgit\s+(push|commit)\b.*--no-verify\b'; then
70
+ REASON="git push/commit --no-verify bypasses the pre-push baseline-monotonic guard. The kit ships that guard because the path of least resistance for new violations is 'append them to the baseline' — which defeats the rule. Fix the underlying violation, then push without --no-verify."
71
+ fi
72
+
73
+ # Pattern 2: rm -rf .harness/ or .claude/
74
+ if echo "$CMD" | grep -qE '\brm\s+(-[rRf]+\s+|--recursive\s+)+\.?\.?/?\.harness(/|\s|$)'; then
75
+ REASON="rm -rf .harness/ removes the lockfile + structural baseline. Use 'agent-harness-kit upgrade' to refresh installed files instead."
76
+ fi
77
+ if echo "$CMD" | grep -qE '\brm\s+(-[rRf]+\s+|--recursive\s+)+\.?\.?/?\.claude(/|\s|$)'; then
78
+ REASON="rm -rf .claude/ removes every skill/agent/hook the kit wrote. Re-init with 'agent-harness-kit init' if you need a clean slate."
79
+ fi
80
+
81
+ # Pattern 3: chmod -x on hook scripts (silently disables them)
82
+ if echo "$CMD" | grep -qE '\bchmod\s+([-+]?[ugoa]?[-+=][rwxX]*)?-x\s+scripts/(structural-test|precompletion|pre-push|session-start|pretooluse)' \
83
+ || echo "$CMD" | grep -qE '\bchmod\s+0?[0-6][0-6][0-6]\s+scripts/(structural-test|precompletion|pre-push|session-start|pretooluse)'; then
84
+ REASON="chmod -x on a hook script silently disables the harness. If you need to skip the check this turn, set AHK_HOOK_MODE=warn for the session — that leaves an audit trail."
85
+ fi
86
+
87
+ # Pattern 4: truncating the structural baseline
88
+ if echo "$CMD" | grep -qE '(^|[;&|]\s*)(:|true|echo\s*("\["|\[\]|"|null))\s*>\s*\.harness/structural-baseline\.json' \
89
+ || echo "$CMD" | grep -qE '>\s*\.harness/structural-baseline\.json\s*$'; then
90
+ # The second pattern is broader (any redirect TO the baseline). Allow
91
+ # 'mv' or 'cp' which produce non-truncating writes; this pattern catches
92
+ # the `> baseline.json` shape only.
93
+ REASON="Direct write to .harness/structural-baseline.json bypasses the monotonic guard. Append entries through the kit's own /garbage-collection skill, or fix the violation in code so the baseline shrinks."
94
+ fi
95
+
96
+ # Pattern 5: setting disableAllHooks via sed/jq
97
+ if echo "$CMD" | grep -qE '(sed|jq).*disableAllHooks.*true.*\.claude/settings\.json' \
98
+ || echo "$CMD" | grep -qE '\.claude/settings\.json.*disableAllHooks.*true' \
99
+ || echo "$CMD" | grep -qE 'disableAllHooks.*true.*\.claude/settings\.json'; then
100
+ REASON="disableAllHooks: true defeats every protection the kit installs. If you need to temporarily disable a specific hook for debugging, remove it explicitly with a commit message explaining why."
101
+ fi
102
+
103
+ if [ -z "$REASON" ]; then
104
+ # Command passed all checks — defer to the auto-mode classifier (or to
105
+ # whatever permission rule the user has set). We do not return
106
+ # permissionDecision: "allow" because that would auto-approve every
107
+ # benign Bash call, robbing the user of explicit approvals when in
108
+ # default mode.
109
+ exit 0
110
+ fi
111
+
112
+ # Bypass escape hatch — leaves an audit trail.
113
+ if [ "${AHK_ALLOW_BYPASS:-}" = "1" ]; then
114
+ mkdir -p .harness
115
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
116
+ SHA=$(git rev-parse --short HEAD 2>/dev/null || echo 'no-git')
117
+ ESCAPED_CMD=${CMD//$'\n'/ }
118
+ ESCAPED_CMD=${ESCAPED_CMD//\"/\\\"}
119
+ printf '{"ts":"%s","sha":"%s","bypass":"AHK_ALLOW_BYPASS","reason":"%s","command":"%s"}\n' \
120
+ "$TS" "$SHA" "${REASON//\"/\\\"}" "$ESCAPED_CMD" >> .harness/bypass.log
121
+ exit 0
122
+ fi
123
+
124
+ # Emit deny. JSON via Node so escaping is honest.
125
+ if command -v node >/dev/null 2>&1; then
126
+ node -e "
127
+ const reason = process.argv[1];
128
+ const out = {
129
+ hookSpecificOutput: {
130
+ hookEventName: 'PreToolUse',
131
+ permissionDecision: 'deny',
132
+ permissionDecisionReason: reason
133
+ }
134
+ };
135
+ process.stdout.write(JSON.stringify(out));
136
+ " "$REASON"
137
+ elif have_jq; then
138
+ jq -nc --arg r "$REASON" \
139
+ '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
140
+ else
141
+ # Fallback: exit 2 with stderr. Older Claude Code versions parse stderr
142
+ # for denial reason when JSON unavailable.
143
+ echo "$REASON" >&2
144
+ exit 2
145
+ fi
146
+ exit 0
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # SessionEnd hook — append a single observability line to PROGRESS.md when
3
+ # the session terminates. Never blocks (SessionEnd is cleanup-only per
4
+ # Claude Code docs).
5
+ #
6
+ # Output line shape:
7
+ # YYYY-MM-DD HH:MM | session_end | <reason> | <branch> | <sha>
8
+ #
9
+ # Example:
10
+ # 2026-05-16 19:00 | session_end | clear | main | abc1234
11
+ #
12
+ # Reasons (per Claude Code docs): clear, resume, logout, prompt_input_exit,
13
+ # bypass_permissions_disabled, other.
14
+ set -eo pipefail
15
+
16
+ INPUT=$(cat)
17
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
+ have_jq() {
19
+ [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
20
+ command -v jq >/dev/null 2>&1
21
+ }
22
+ have_jp() {
23
+ have_jq && return 0
24
+ command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
25
+ return 1
26
+ }
27
+ jp() {
28
+ if have_jq; then jq -r "$1"
29
+ else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
30
+ fi
31
+ }
32
+
33
+ REASON="unknown"
34
+ if have_jp; then
35
+ REASON=$(echo "$INPUT" | jp '.end_reason // "unknown"' 2>/dev/null || echo "unknown")
36
+ fi
37
+
38
+ BR="(no-git)"
39
+ SHA="(no-git)"
40
+ if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
41
+ BR=$(git branch --show-current 2>/dev/null || echo "(detached)")
42
+ SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "(none)")
43
+ fi
44
+
45
+ mkdir -p .harness
46
+ TS=$(date +"%Y-%m-%d %H:%M")
47
+ echo "$TS | session_end | $REASON | $BR | $SHA" >> .harness/PROGRESS.md
48
+ exit 0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # SessionStart hook — inject a compact, deterministic context block when
3
+ # a session begins, resumes, or comes back from compaction. Output goes
4
+ # via JSON stdout `hookSpecificOutput.additionalContext`, which Claude
5
+ # Code feeds into the conversation context before the first turn.
6
+ #
7
+ # Three matchers fire this hook:
8
+ # startup → fresh session. Inject branch + uncommitted summary +
9
+ # current feature (from feature_list.json) + golden-principles
10
+ # cap reminder. ~10-20 lines of structured state.
11
+ # resume → user ran --resume / --continue. Same payload as startup,
12
+ # plus tail of PROGRESS.md so the model picks up where the
13
+ # last session stopped.
14
+ # compact → context was just compacted (mid-session). Pull the snapshot
15
+ # written by the PreCompact hook (.harness/compaction-snapshot.json)
16
+ # and re-inject it. Without this, the model loses everything
17
+ # that mattered about the current feature mid-compaction.
18
+ #
19
+ # The hook never blocks. Exit 0 + JSON to stdout is the *only* control
20
+ # path that Claude reads.
21
+ set -eo pipefail
22
+
23
+ INPUT=$(cat)
24
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
+ have_jq() {
26
+ [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
27
+ command -v jq >/dev/null 2>&1
28
+ }
29
+ have_jp() {
30
+ have_jq && return 0
31
+ command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
32
+ return 1
33
+ }
34
+ jp() {
35
+ if have_jq; then
36
+ if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
37
+ else
38
+ if [ -n "$2" ]; then
39
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
40
+ else
41
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
42
+ fi
43
+ fi
44
+ }
45
+
46
+ SOURCE=""
47
+ if have_jp; then
48
+ SOURCE=$(echo "$INPUT" | jp '.source // "startup"')
49
+ fi
50
+
51
+ # Build the additionalContext payload as plain text first, then JSON-escape
52
+ # the whole thing at the end. Plain text is easier to read while iterating
53
+ # on the hook, and Claude renders it as-is in the conversation view.
54
+ CTX=""
55
+
56
+ # 1. Branch + uncommitted count (always)
57
+ if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
58
+ BR=$(git branch --show-current 2>/dev/null || echo "(detached)")
59
+ COUNT=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
60
+ CTX+="[harness] git: branch=$BR, uncommitted=$COUNT file(s)"$'\n'
61
+ fi
62
+
63
+ # 2. Current feature (from feature_list.json) — picks the first entry with
64
+ # passes=false so the model resumes the in-flight work, not a finished
65
+ # one. Skipped if file missing or jp unavailable.
66
+ if [ -f feature_list.json ] && have_jp; then
67
+ FIRST_OPEN=$(echo '{}' | jp '.placeholder // empty' 2>/dev/null || true) # warm jp
68
+ # Use a transient script — we want { id, title } of first passes:false entry.
69
+ if have_jq; then
70
+ FEAT=$(jq -r 'first(.features[] | select(.passes == false)) | "[harness] feature: \(.id) — \(.title)"' \
71
+ feature_list.json 2>/dev/null || true)
72
+ else
73
+ # Node fallback path: emit (id, title) via a one-liner.
74
+ FEAT=$(node -e "
75
+ const f = JSON.parse(require('fs').readFileSync('feature_list.json', 'utf8'));
76
+ const open = (f.features || []).find(x => x.passes === false);
77
+ if (open) process.stdout.write('[harness] feature: ' + open.id + ' — ' + open.title);
78
+ " 2>/dev/null || true)
79
+ fi
80
+ if [ -n "$FEAT" ]; then
81
+ CTX+="$FEAT"$'\n'
82
+ fi
83
+ fi
84
+
85
+ # 3. PROGRESS.md tail (resume only — fresh sessions don't need it).
86
+ if [ "$SOURCE" = "resume" ] && [ -f .harness/PROGRESS.md ]; then
87
+ TAIL=$(tail -3 .harness/PROGRESS.md 2>/dev/null | sed 's/^/ /')
88
+ if [ -n "$TAIL" ]; then
89
+ CTX+="[harness] PROGRESS.md tail:"$'\n'"$TAIL"$'\n'
90
+ fi
91
+ fi
92
+
93
+ # 4. Re-injection from compaction snapshot. The PreCompact hook writes
94
+ # .harness/compaction-snapshot.json before the model loses context.
95
+ # On `source: compact` we read it back and inline the most useful
96
+ # fields so the post-compaction model knows where it was.
97
+ if [ "$SOURCE" = "compact" ] && [ -f .harness/compaction-snapshot.json ] && have_jp; then
98
+ SNAP_BRANCH=$(jp '.branch // empty' .harness/compaction-snapshot.json 2>/dev/null || true)
99
+ SNAP_SHA=$(jp '.sha // empty' .harness/compaction-snapshot.json 2>/dev/null || true)
100
+ SNAP_FEAT=$(jp '.feature // empty' .harness/compaction-snapshot.json 2>/dev/null || true)
101
+ SNAP_TS=$(jp '.compacted_at // empty' .harness/compaction-snapshot.json 2>/dev/null || true)
102
+ CTX+="[harness] post-compaction snapshot (taken $SNAP_TS):"$'\n'
103
+ [ -n "$SNAP_BRANCH" ] && CTX+=" branch=$SNAP_BRANCH"$'\n'
104
+ [ -n "$SNAP_SHA" ] && CTX+=" sha=$SNAP_SHA"$'\n'
105
+ [ -n "$SNAP_FEAT" ] && CTX+=" current-feature=$SNAP_FEAT"$'\n'
106
+ fi
107
+
108
+ # 5. Layer rule reminder (always — short, deterministic). Lets the model
109
+ # re-establish the forward-only rule without reading CLAUDE.md again.
110
+ if [ -f harness.config.json ] && have_jp; then
111
+ LAYERS=$(jp '.domains[0].layers[]' harness.config.json 2>/dev/null | tr '\n' ' ' | sed 's/ $//' | tr ' ' '>')
112
+ LAYERS=${LAYERS//>/ → }
113
+ if [ -n "$LAYERS" ]; then
114
+ CTX+="[harness] layer rule (forward-only): $LAYERS"$'\n'
115
+ fi
116
+ fi
117
+
118
+ if [ -z "$CTX" ]; then
119
+ # Nothing meaningful to inject. Exit clean with no output — Claude
120
+ # treats this as "hook ran but had nothing to say".
121
+ exit 0
122
+ fi
123
+
124
+ # Emit the JSON envelope. Use Node's JSON.stringify for the escape so we
125
+ # don't have to hand-roll \n / \" handling.
126
+ if command -v node >/dev/null 2>&1; then
127
+ node -e "
128
+ const ctx = process.argv[1];
129
+ const out = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: ctx } };
130
+ process.stdout.write(JSON.stringify(out));
131
+ " "$CTX"
132
+ elif have_jq; then
133
+ jq -nc --arg ctx "$CTX" '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}'
134
+ else
135
+ # Last-resort: emit as plain stdout. Claude Code accepts plain text from
136
+ # SessionStart hooks (it's treated as additionalContext too).
137
+ printf '%s' "$CTX"
138
+ fi
139
+ exit 0
@@ -1,20 +1,47 @@
1
1
  #!/usr/bin/env bash
2
2
  # PostToolUse hook — runs the structural test on the file just edited.
3
3
  # Defensive: never blocks on missing tooling. Exit code 2 = block + Claude reads stderr.
4
- set -e
4
+ #
5
+ # `pipefail` is critical — without it, `cmd | tail` swallows cmd's exit code
6
+ # and a real structural-test failure looks clean to the agent.
7
+ set -eo pipefail
5
8
 
6
9
  INPUT=$(cat)
7
- if ! command -v jq >/dev/null 2>&1; then
8
- exit 0 # jq missing silently skip rather than spuriously blocking
10
+
11
+ # Resolve where this hook lives so we can find _lib/json-pick.mjs (Node-based
12
+ # jq fallback). Pure-Node fallback removes the previous fail-open behaviour
13
+ # when jq is missing — silently skipping the structural check on jq-less
14
+ # environments (minimal CI, Windows without WSL+brew) was a known audit hole.
15
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16
+ have_jq() {
17
+ [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
18
+ command -v jq >/dev/null 2>&1
19
+ }
20
+ have_jp() {
21
+ have_jq && return 0
22
+ command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
23
+ return 1
24
+ }
25
+ jp() {
26
+ if have_jq; then jq -r "$1"
27
+ else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
28
+ fi
29
+ }
30
+ if ! have_jp; then
31
+ echo "[ahk] structural-test-on-edit: no JSON parser available (need jq OR node + scripts/_lib/json-pick.mjs)." >&2
32
+ exit 0
9
33
  fi
10
34
 
11
- FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
35
+ FILE=$(echo "$INPUT" | jp '.tool_input.file_path // empty')
12
36
  [ -z "$FILE" ] && exit 0
13
37
 
14
38
  # Only run on source files, and only inside the configured roots.
15
39
  case "$FILE" in
16
40
  *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) ENGINE=ts ;;
17
41
  *.py) ENGINE=py ;;
42
+ *.rs) ENGINE=node ;;
43
+ *.swift) ENGINE=node ;;
44
+ *.kt|*.kts) ENGINE=node ;;
18
45
  *) exit 0 ;;
19
46
  esac
20
47
 
@@ -25,6 +52,14 @@ if [ "${AHK_HOOK_MODE:-}" = "warn" ]; then
25
52
  exit 0
26
53
  fi
27
54
 
55
+ # Skip cleanly when the structural test is explicitly disabled (polyglot
56
+ # scaffolds where the adapter is not yet wired). Without this guard every
57
+ # edit fires a failing hook that the agent can't actually fix.
58
+ if [ -f harness.config.json ] \
59
+ && grep -qE '"engine"[[:space:]]*:[[:space:]]*"none"' harness.config.json; then
60
+ exit 0
61
+ fi
62
+
28
63
  # Run the structural test scoped to this file. Capture output so we can
29
64
  # return only the relevant lines via stderr to Claude.
30
65
  if [ "$ENGINE" = "ts" ]; then
@@ -46,6 +81,23 @@ Structural test failed for $FILE.
46
81
  Layer order: see harness.config.json.
47
82
  Run \`python -m harness.structural_test\` for full output.
48
83
  Fix the violation before continuing — do NOT disable the test.
84
+ EOF
85
+ exit 2
86
+ fi
87
+ elif [ "$ENGINE" = "node" ]; then
88
+ # Node-based adapters (Rust / Swift / Kotlin). All ship the same
89
+ # harness/structural-check.mjs entry point. Workspace-wide scan because
90
+ # the regex is cheap. Missing script → graceful degrade.
91
+ if [ ! -f harness/structural-check.mjs ]; then
92
+ exit 0
93
+ fi
94
+ if ! node harness/structural-check.mjs 2>&1 | tail -50 >&2; then
95
+ cat >&2 <<EOF
96
+
97
+ Structural test failed (triggered by edit to $FILE).
98
+ Layer order: see harness.config.json.
99
+ Run \`node harness/structural-check.mjs\` for full output.
100
+ Fix the violation before continuing — do NOT disable the test.
49
101
  EOF
50
102
  exit 2
51
103
  fi
@@ -4,23 +4,45 @@
4
4
  #
5
5
  # Used by harness:report to compute per-skill success rate, average duration,
6
6
  # and to surface drift over time.
7
+ #
8
+ # v0.7: migrated from `command -v jq` fail-open gate to the kit's jp() helper
9
+ # so the telemetry record still gets written on jq-less CI / Windows. Without
10
+ # the migration, telemetry quietly went dark anywhere jq wasn't installed.
7
11
  set -e
8
12
 
9
13
  INPUT=$(cat)
10
- if ! command -v jq >/dev/null 2>&1; then
11
- exit 0 # jq missing — skip silently rather than spuriously blocking
12
- fi
13
14
 
14
- TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
15
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16
+ have_jq() {
17
+ [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
18
+ command -v jq >/dev/null 2>&1
19
+ }
20
+ have_jp() {
21
+ have_jq && return 0
22
+ command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
23
+ return 1
24
+ }
25
+ jp() {
26
+ if have_jq; then jq -r "$1"
27
+ else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
28
+ fi
29
+ }
30
+ if ! have_jp; then exit 0; fi
31
+
32
+ TOOL=$(echo "$INPUT" | jp '.tool_name // empty')
15
33
  [ "$TOOL" = "Skill" ] || exit 0
16
34
 
17
- SKILL=$(echo "$INPUT" | jq -r '.tool_input.skill // empty')
35
+ SKILL=$(echo "$INPUT" | jp '.tool_input.skill // empty')
18
36
  [ -z "$SKILL" ] && exit 0
19
37
 
20
38
  mkdir -p .harness
21
- LINE=$(jq -nc --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
22
- --arg skill "$SKILL" \
23
- --arg sha "$(git rev-parse --short HEAD 2>/dev/null || echo 'no-git')" \
24
- '{ts: $ts, event: "skill_invoked", skill: $skill, sha: $sha}')
25
- echo "$LINE" >> .harness/telemetry.jsonl
39
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
40
+ SHA=$(git rev-parse --short HEAD 2>/dev/null || echo 'no-git')
41
+
42
+ # Compose JSONL line by hand same shape as the previous jq-built record.
43
+ # Quoting via printf '%s' so embedded spaces in skill names don't break the
44
+ # line. Skill names are constrained to `[a-z0-9-]+` upstream so we don't
45
+ # need full JSON escaping here.
46
+ printf '{"ts":"%s","event":"skill_invoked","skill":"%s","sha":"%s"}\n' \
47
+ "$TS" "$SKILL" "$SHA" >> .harness/telemetry.jsonl
26
48
  exit 0
@@ -1,39 +0,0 @@
1
- {
2
- "$schema": "https://json.schemastore.org/claude-code-hooks.json",
3
- "hooks": {
4
- "PostToolUse": [
5
- {
6
- "matcher": "Write|Edit|MultiEdit",
7
- "hooks": [
8
- {
9
- "type": "command",
10
- "command": "bash scripts/structural-test-on-edit.sh",
11
- "timeout": 30
12
- }
13
- ]
14
- },
15
- {
16
- "matcher": "Skill",
17
- "hooks": [
18
- {
19
- "type": "command",
20
- "command": "bash scripts/telemetry-on-skill.sh",
21
- "timeout": 5
22
- }
23
- ]
24
- }
25
- ],
26
- "Stop": [
27
- {
28
- "matcher": "",
29
- "hooks": [
30
- {
31
- "type": "command",
32
- "command": "bash scripts/precompletion-checklist.sh",
33
- "timeout": 20
34
- }
35
- ]
36
- }
37
- ]
38
- }
39
- }