Rectify: Stale-Hooks Check Infinite Prompt Loop#682
Conversation
T-DRIFT-3: user hooks falsely counted as orphaned (_extract_cmds no filter) T-DRIFT-4: cross-env path mismatch causes false drift (full-path compare) SC-23: hooks YES-path writes no state and has no return (infinite prompt) All three tests fail against current code, proving the gaps exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add canonical_script_basenames() and _is_own_hook() to hook_registry.py - Replace _extract_cmds() with _extract_script_basenames() that filters to autoskillit-owned hooks and normalizes to bare filenames - Update _count_hook_registry_drift() to compare basenames, making it immune to pkg_root() path differences across environments - Move _is_autoskillit_hook_command from cli/_hooks.py (L3) to hook_registry.py (L0) as _is_own_hook, resolving layer violation - Update cli/_doctor.py to use canonical_script_basenames() and simplified orphan display for basename-valued orphaned_cmds - Add HR-FILTER-* and HR-BASENAME-* unit tests for new L0 functions - Update T-DRIFT-1/T-DRIFT-2 ghost paths to include "autoskillit" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add _verify_hooks_result() mirroring _verify_update_result() pattern: re-checks drift after install, writes hooks_snoozed on failure - Fix hooks YES-path to call _verify_hooks_result() and return, preventing infinite re-prompt when install fails to resolve drift - Add _is_snoozed(state, "hooks") guard to hooks drift condition, mirroring binary check's existing snooze protection - SC-23 test now passes: state written after YES, function returns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Trecek
left a comment
There was a problem hiding this comment.
AutoSkillit PR Review — Verdict: changes_requested. 3 actionable findings (warning), 3 informational.
| if answer in ("", "y", "yes"): | ||
| with terminal_guard(): | ||
| subprocess.run(["autoskillit", "install"], check=False, env=_skip_env) | ||
| _verify_hooks_result(_home, state, settings_path, current) |
There was a problem hiding this comment.
[warning] defense: No subprocess returncode inspection before _verify_hooks_result. If autoskillit install exits non-zero (e.g. permission error, missing binary), _verify_hooks_result reads stale settings.json and may silently write a hooks_snoozed record instead of notifying the user that the install itself failed.
There was a problem hiding this comment.
Investigated — this is intentional. The design uses outcome-based verification rather than returncode inspection, mirroring the binary update path (lines 312-332). _verify_hooks_result (lines 217-239) re-reads actual drift from settings_path via _count_hook_registry_drift after the subprocess exits. If install exits non-zero and hooks remain unwritten, drift detection catches it (drift.missing > 0 or drift.orphaned > 0 → writes hooks_snoozed and returns False). The ground truth is the actual state of settings.json, not the process exit code — see commit e38dedc.
|
|
||
| def _is_own_hook(command: str) -> bool: | ||
| """Check if a hook command belongs to autoskillit (any format).""" | ||
| if "autoskillit" in command: |
There was a problem hiding this comment.
[warning] defense: _is_own_hook uses a broad substring match ("autoskillit" in command) that could misclassify user hooks whose paths contain "autoskillit" (e.g. /home/autoskillit_user/my_guard.py). Pre-existing behavior (moved from _hooks.py) but now in a higher-visibility L0 location.
|
|
||
| # Build settings.json with a DIFFERENT path prefix than current pkg_root() | ||
| foreign_hooks_dir = ( | ||
| "/home/user/.local/share/uv/tools/autoskillit/lib/python3.13" |
There was a problem hiding this comment.
[info] tests: Foreign path string hardcodes python3.13 version. Consider using a clearly synthetic placeholder (e.g. /foreign/install/hooks) to avoid accidental staleness.
There was a problem hiding this comment.
Valid observation — flagged for design decision. The test logic uses Path(cmd.split()[-1]).name (basename extraction) so the python3.13 version in the path has zero effect on pass/fail. The path is already synthetic (/home/user/ prefix). Whether to replace with a fully opaque placeholder is a style preference that warrants human input.
The test docstring promised "A second call to run_stale_check() must NOT prompt again" but only one call was made. The _write_dismiss_state mock now persists state to disk so _read_dismiss_state finds the snooze record on the second call, and the second-call assertion verifies no re-prompt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wrap the deferred import of _count_hook_registry_drift inside _verify_hooks_result with try/except ImportError, returning False on failure. Prevents unformatted traceback surfacing to CLI users when the package install is broken. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace dead reference to _extract_cmds() with the actual function name _extract_script_basenames() in the test docstring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Three compounding defects in the hook drift detection and stale-check prompt system create an inescapable infinite prompt loop after
autoskillit install. The architectural weakness is twofold: (1) hook identity comparison uses full absolute paths rather than stable basename identifiers, creating environment-dependent fragility; (2) the interactive prompt flow lacks the verify-persist-return pattern that the binary-version path implements correctly. The fix introduces a basename-normalized hook identity model in L0 and a verified prompt action pattern in the stale-check flow, making the entire class of bugs structurally impossible.Architecture Impact
Module Dependency Diagram
%%{init: {'flowchart': {'nodeSpacing': 50, 'rankSpacing': 70, 'curve': 'basis'}}}%% graph TB %% CLASS DEFINITIONS %% classDef cli fill:#1a237e,stroke:#7986cb,stroke-width:2px,color:#fff; classDef phase fill:#6a1b9a,stroke:#ba68c8,stroke-width:2px,color:#fff; classDef handler fill:#e65100,stroke:#ffb74d,stroke-width:2px,color:#fff; classDef stateNode fill:#004d40,stroke:#4db6ac,stroke-width:2px,color:#fff; classDef newComponent fill:#2e7d32,stroke:#81c784,stroke-width:2px,color:#fff; classDef integration fill:#c62828,stroke:#ef9a9a,stroke-width:2px,color:#fff; classDef output fill:#00695c,stroke:#4db6ac,stroke-width:2px,color:#fff; subgraph Tests ["TESTS"] direction LR T_HR["★ test_hook_registry<br/>━━━━━━━━━━<br/>NEW — unit tests for<br/>hook identity model"] T_SC["● test_stale_check<br/>━━━━━━━━━━<br/>Stale-check + drift<br/>YES-path tests"] T_DR["● test_doctor<br/>━━━━━━━━━━<br/>Doctor hook-health<br/>check tests"] end subgraph L3_CLI ["L3 — CLI"] direction LR APP["cli/app.py<br/>━━━━━━━━━━<br/>CLI entry point<br/>dispatches commands"] HOOKS["● cli/_hooks.py<br/>━━━━━━━━━━<br/>Hook sync to<br/>settings.json"] STALE["● cli/_stale_check.py<br/>━━━━━━━━━━<br/>Version + drift<br/>check at startup"] DOCTOR["● cli/_doctor.py<br/>━━━━━━━━━━<br/>12 health checks<br/>including hook drift"] CLI_INIT["cli/__init__.py<br/>━━━━━━━━━━<br/>Re-exports:<br/>HookDriftResult"] end subgraph L3_SRV ["L3 — SERVER"] direction LR SRV_HELP["server/helpers.py<br/>━━━━━━━━━━<br/>Deferred hook<br/>diagnostic warning"] end subgraph L0 ["L0 — FOUNDATION"] direction LR HR["● hook_registry.py<br/>━━━━━━━━━━<br/>HookDef, HOOK_REGISTRY<br/>generate_hooks_json<br/>Fan-in: 6"] HOOKS_PKG["hooks/__init__.py<br/>━━━━━━━━━━<br/>Re-exports HOOK_REGISTRY<br/>HookDef, generate_hooks_json"] CORE["core/<br/>━━━━━━━━━━<br/>pkg_root, get_logger<br/>atomic_write, Severity<br/>Fan-in: high"] end %% === TEST → PRODUCTION DEPENDENCIES === %% T_HR -->|"imports 5 symbols"| HR T_SC -->|"imports HookDriftResult"| HR T_SC -->|"imports _stale_check module"| STALE T_SC -->|"imports cli namespace"| CLI_INIT T_DR -->|"imports cli namespace"| CLI_INIT %% === L3 CLI INTERNAL WIRING === %% APP -->|"deferred: run_stale_check()"| STALE APP -->|"deferred: run_doctor()"| DOCTOR CLI_INIT -->|"re-exports HookDriftResult"| HR CLI_INIT -->|"re-exports _check_hook_health"| DOCTOR %% === L3 CLI → L0 DEPENDENCIES === %% HOOKS -->|"_build_hook_entry,<br/>_claude_settings_path,<br/>_load_settings_data,<br/>_is_own_hook"| HR HOOKS -->|"HOOK_REGISTRY"| HOOKS_PKG HOOKS -->|"atomic_write, pkg_root"| CORE STALE -->|"deferred: _count_hook_registry_drift"| HR STALE -->|"deferred: _claude_settings_path"| HOOKS STALE -->|"get_logger, pkg_root"| CORE DOCTOR -->|"_count_hook_registry_drift,<br/>canonical_script_basenames,<br/>find_broken_hook_scripts"| HR DOCTOR -->|"_claude_settings_path,<br/>_load_settings_data"| HOOKS DOCTOR -->|"Severity, _ROOT_GITIGNORE_ENTRIES"| CORE %% === L3 SERVER → L0 === %% SRV_HELP -.->|"deferred: _claude_settings_path,<br/>_count_hook_registry_drift,<br/>find_broken_hook_scripts"| HR %% === L0 INTERNAL === %% HR -->|"pkg_root"| CORE HOOKS_PKG -->|"re-exports"| HR %% CLASS ASSIGNMENTS %% class APP,HOOKS,STALE,DOCTOR,CLI_INIT cli; class SRV_HELP phase; class HR stateNode; class HOOKS_PKG,CORE handler; class T_HR newComponent; class T_SC,T_DR output;Closes #680
Implementation Plan
Plan file:
/home/talon/projects/autoskillit-runs/remediation-20260408-183501-840011/.autoskillit/temp/rectify/rectify_stale-hooks-infinite-prompt_2026-04-08_190500.md🤖 Generated with Claude Code via AutoSkillit
Token Usage Summary