diff --git a/doctor.sh b/doctor.sh index ca36f08..58442be 100644 --- a/doctor.sh +++ b/doctor.sh @@ -213,11 +213,20 @@ print(len(d.get('permissions',{}).get('deny',[]))) if [ "$DENY_COUNT" = "?" ]; then warn "Could not parse deny count (python3 unavailable or JSON parse error)" else - EXPECTED_DENY=100 - if [ "$DENY_COUNT" -eq "$EXPECTED_DENY" ] 2>/dev/null; then - pass "Deny rules: $DENY_COUNT" + # Expected = deny count in the last COMMITTED settings.json. A hardcoded + # number drifts on every legit deny-list edit (false-warned for weeks at + # 100 vs 99 — LRN-047 class); deriving from HEAD auto-tracks legit edits + # and still flags live-vs-committed divergence. + EXPECTED_DENY=$(git -C "$REPO" show HEAD:settings.json 2>/dev/null | python3 -c " +import json,sys +print(len(json.load(sys.stdin).get('permissions',{}).get('deny',[]))) +" 2>/dev/null || echo "?") + if [ "$EXPECTED_DENY" = "?" ]; then + warn "Could not derive expected deny count from committed settings.json" + elif [ "$DENY_COUNT" -eq "$EXPECTED_DENY" ] 2>/dev/null; then + pass "Deny rules: $DENY_COUNT (matches committed settings.json)" else - warn "Deny rules: $DENY_COUNT (expected $EXPECTED_DENY) — settings may have been manually modified" + warn "Deny rules: $DENY_COUNT (committed: $EXPECTED_DENY) — live settings diverge from last commit" fi fi else @@ -310,8 +319,11 @@ else warn "gstack/browse/dist/ symlink missing — run: bash link.sh" fi -# Check owned skills have disable-model-invocation (skip external/symlinked skills) -MISSING_DMI=() +# BDR-019 (2026-06-09) stripped disable-model-invocation repo-wide so the +# model/orchestrators can self-route. The old check required the key on +# every owned skill — permanent false-warn since. Inverted: warn if any +# owned skill REintroduces the key (regression watch on BDR-019). +PRESENT_DMI=() for f in "$HOME/.claude/skills/"*/SKILL.md; do [ -f "$f" ] || continue dir=$(dirname "$f") @@ -319,19 +331,22 @@ for f in "$HOME/.claude/skills/"*/SKILL.md; do [ -L "$dir" ] && continue [ -L "$f" ] && continue name=$(basename "$dir") - if ! grep -q "disable-model-invocation" "$f" 2>/dev/null; then - MISSING_DMI+=("$name") + if grep -q "disable-model-invocation" "$f" 2>/dev/null; then + PRESENT_DMI+=("$name") fi done -if [ ${#MISSING_DMI[@]} -eq 0 ]; then - pass "All owned skills have disable-model-invocation" +if [ ${#PRESENT_DMI[@]} -eq 0 ]; then + pass "No owned skill carries disable-model-invocation (BDR-019)" else - warn "Owned skills missing disable-model-invocation: ${MISSING_DMI[*]}" + warn "Owned skills reintroduce disable-model-invocation (BDR-019 regression): ${PRESENT_DMI[*]}" fi -# Check expected skills are present +# Check expected skills are present. Repo-owned skills only: gstack skills +# (health, status, …) are OFF by default and toggled per profile — requiring +# them here false-warns on a default install, and "run link.sh" cannot +# restore them (they are profile-managed, not link.sh-managed). EXPECTED_SKILLS=( - "analyze" "doc" "health" "init-project" "onboard" "plugin-check" + "analyze" "doc" "init-project" "onboard" "plugin-check" "refactor" "ship-feature" "status" ) MISSING_SKILLS=() @@ -341,7 +356,7 @@ for skill in "${EXPECTED_SKILLS[@]}"; do fi done if [ ${#MISSING_SKILLS[@]} -eq 0 ]; then - pass "All ${#EXPECTED_SKILLS[@]} expected skills present (analyze, doc, health, init-project, onboard, plugin-check, refactor, ship-feature, status)" + pass "All ${#EXPECTED_SKILLS[@]} expected skills present (${EXPECTED_SKILLS[*]})" else warn "Missing skills: ${MISSING_SKILLS[*]} — run: bash link.sh" fi diff --git a/hooks/.rtk-hook.sha256 b/hooks/.rtk-hook.sha256 index 79741f9..f908504 100644 --- a/hooks/.rtk-hook.sha256 +++ b/hooks/.rtk-hook.sha256 @@ -1 +1 @@ -ef0d630994fd7ef5f2b84fb66cd6249c493bb8736bcacd4734d7c798125018fb rtk-rewrite.sh +871efa28daf7c06a9c9039a2875407e2536646f5d82f7e7a9c6a80dd3742929c rtk-rewrite.sh diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index f7a42b5..21dc98e 100755 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -7,8 +7,17 @@ # which is the single source of truth (src/discover/registry.rs). # To add or change rewrite rules, edit the Rust registry — not this file. # +# INTEGRITY PIN: the rtk binary verifies this file against +# hooks/.rtk-hook.sha256 at execution time and refuses to run on mismatch. +# ANY edit here must re-pin: (cd hooks && sha256sum rtk-rewrite.sh > .rtk-hook.sha256) +# # Exit code protocol for `rtk rewrite`: -# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow +# 0 + stdout Rewrite found, no rtk deny/ask rule matched → rewrite. NO +# permissionDecision is emitted (auto-allow dropped 2026-07-02: +# it made rtk's registry a parallel permission authority that +# bypassed settings.json deny/ask). The REWRITTEN command goes +# through native evaluation; explicit `rtk ` allow rules +# in settings.json keep read-only forms frictionless. # 1 No RTK equivalent → pass through unchanged # 2 Deny rule matched → pass through (Claude Code native deny handles it) # 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user @@ -18,14 +27,27 @@ if ! command -v jq &>/dev/null; then exit 0 fi -if ! command -v rtk &>/dev/null; then +# PATH heal: hook/tool-shell PATH may lack the cargo bin dir (hand-managed +# ~/.bashrc can lose the cargo line — LRN-036 class). Resolve the ABSOLUTE +# binary path: the rewritten command executes in the tool shell, whose PATH +# the hook cannot fix — a bare `rtk …` rewrite would exit 127 there. +RTK_BIN="$(command -v rtk 2>/dev/null || true)" +RTK_ON_PATH=1 +if [ -z "$RTK_BIN" ]; then + RTK_ON_PATH=0 + for _d in "$HOME/.cargo/bin" "$HOME/.local/bin"; do + if [ -x "$_d/rtk" ]; then RTK_BIN="$_d/rtk"; break; fi + done +fi + +if [ -z "$RTK_BIN" ]; then echo "[rtk] WARNING: rtk is not installed or not in PATH. Hook cannot rewrite commands. Install: https://github.com/rtk-ai/rtk#installation" >&2 exit 0 fi # Version guard: rtk rewrite was added in 0.23.0. # Older binaries: warn once and exit cleanly (no silent failure). -RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +RTK_VERSION=$("$RTK_BIN" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) if [ -n "$RTK_VERSION" ]; then MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1) MINOR=$(echo "$RTK_VERSION" | cut -d. -f2) @@ -44,13 +66,13 @@ if [ -z "$CMD" ]; then fi # Delegate all rewrite + permission logic to the Rust binary. -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) +REWRITTEN=$("$RTK_BIN" rewrite "$CMD" 2>/dev/null) EXIT_CODE=$? case $EXIT_CODE in 0) - # Rewrite found, no permission rules matched — safe to auto-allow. - # If the output is identical, the command was already using RTK. + # Rewrite found. If the output is identical, the command was + # already using RTK — nothing to do. [ "$CMD" = "$REWRITTEN" ] && exit 0 ;; 1) @@ -70,29 +92,33 @@ case $EXIT_CODE in ;; esac +# When rtk is NOT on PATH, a bare `rtk …` rewrite exits 127 in the tool +# shell (whose PATH the hook cannot fix). Substitute the absolute path at +# the string head — the only position safe to rewrite. Compound commands +# (`a && b`) can carry further bare rtk segments we canNOT substitute +# safely (quoted text, e.g. commit messages, may contain the same +# pattern): if any remain at a command position, pass through unrewritten +# — lose the compression, never emit a command that 127s. +if [ "$RTK_ON_PATH" -eq 0 ]; then + case "$REWRITTEN" in + rtk\ *) REWRITTEN="$RTK_BIN ${REWRITTEN#rtk }" ;; + esac + if printf '%s' "$REWRITTEN" | grep -Eq '(^|[;&|][[:space:]]*)rtk[[:space:]]'; then + exit 0 + fi +fi + ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') -if [ "$EXIT_CODE" -eq 3 ]; then - # Ask: rewrite the command, omit permissionDecision so Claude Code prompts. - jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "updatedInput": $updated - } - }' -else - # Allow: rewrite the command and auto-allow. - jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' -fi +# Rewrite WITHOUT a permissionDecision (exit 0 and exit 3 alike): the +# rewritten command goes through Claude Code's native allow/deny/ask +# evaluation. Permission control lives in settings.json, not in rtk. +jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": $updated + } + }' diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 016061b..31a9bcf 100644 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -182,7 +182,7 @@ printf "│ 📦 v%-45s│\n" "$CONFIG_VERSION" # Version check: compare local vs remote (non-blocking) _remote_ver="" if [ -n "$REPO_DIR" ] && [ -d "$REPO_DIR/.git" ]; then - _remote_ver=$(cd "$REPO_DIR" 2>/dev/null && git fetch origin --quiet 2>/dev/null && git show origin/master:version.txt 2>/dev/null) || _remote_ver="" + _remote_ver=$(cd "$REPO_DIR" 2>/dev/null && git fetch origin --quiet 2>/dev/null && git show origin/main:version.txt 2>/dev/null) || _remote_ver="" fi if [ -n "$_remote_ver" ] && [ "$_remote_ver" != "$CONFIG_VERSION" ]; then printf "│ 🔄 update available: v%-27s│\n" "$_remote_ver" diff --git a/lib/detect-plugins.sh b/lib/detect-plugins.sh index 7f0da36..4635306 100644 --- a/lib/detect-plugins.sh +++ b/lib/detect-plugins.sh @@ -10,7 +10,9 @@ # --- Always-on plugins --- detect_rtk() { - command -v rtk &>/dev/null + command -v rtk &>/dev/null && return 0 + # PATH heal: hook/session PATH may lack the cargo bin dir (LRN-036 class) + [ -x "$HOME/.cargo/bin/rtk" ] || [ -x "$HOME/.local/bin/rtk" ] } detect_superpowers() { diff --git a/lib/tests/run-reconcile.sh b/lib/tests/run-reconcile.sh index b83c2c3..03d8609 100755 --- a/lib/tests/run-reconcile.sh +++ b/lib/tests/run-reconcile.sh @@ -65,7 +65,10 @@ if has "$cand" "--help"; then ok "T5 surfaced --help candidate (BDR-001 ⇄ --he echo; echo "=== T6 live oracle smoke — oracles QUERY real git/fs (not a name) ===" if reconcile_oracle_merge_done "$REPO" "prune-memory"; then ok "T6a merge_done(prune-memory) via git log"; else no "T6a merge not found in git"; fi if reconcile_oracle_sha_exists "$REPO" "be1dcef"; then ok "T6b sha_exists(be1dcef) via cat-file"; else no "T6b sha missing"; fi -dk="$MEM/../skills/darwin-skill" +# $REPO here = lib/ (see line 12) → lib/../skills = the real skills/ dir. +# Was "$MEM/../skills" = .claude/skills/ — the LRN-042 parasite dir, removed +# 2026-06-30 by make plugin Step 8.5: green-for-wrong-reason (LRN-077 class). +dk="$REPO/../skills/darwin-skill" if reconcile_oracle_path_present "$dk"; then ok "T6c path_present(darwin-skill) via fs"; else no "T6c path absent"; fi echo; echo "================ $pass GREEN / $fail RED ================"