Skip to content

Rectify: Stale-Hooks Check Infinite Prompt Loop#682

Merged
Trecek merged 7 commits intointegrationfrom
stale-hooks-check-prompts-infinitely-never-clears-after-inst/680
Apr 9, 2026
Merged

Rectify: Stale-Hooks Check Infinite Prompt Loop#682
Trecek merged 7 commits intointegrationfrom
stale-hooks-check-prompts-infinitely-never-clears-after-inst/680

Conversation

@Trecek
Copy link
Copy Markdown
Collaborator

@Trecek Trecek commented Apr 9, 2026

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;
Loading

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

Step uncached output cache_read cache_write count time
investigate 24 7.9k 507.9k 46.0k 1 6m 5s
rectify 11.3k 21.1k 948.9k 77.4k 1 9m 51s
dry_walkthrough 24 16.1k 1.5M 92.5k 1 5m 37s
implement 73 25.6k 5.5M 103.7k 1 10m 4s
prepare_pr 9 5.8k 137.1k 31.3k 1 1m 40s
run_arch_lenses 44 17.4k 1.1M 128.8k 3 7m 16s
compose_pr 9 4.0k 153.6k 22.1k 1 1m 5s
Total 11.5k 97.9k 9.8M 501.7k 41m 41s

Trecek and others added 4 commits April 8, 2026 19:07
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>
Copy link
Copy Markdown
Collaborator Author

@Trecek Trecek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AutoSkillit PR Review — Verdict: changes_requested. 3 actionable findings (warning), 3 informational.

Comment thread tests/cli/test_stale_check.py
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)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread tests/cli/test_doctor.py Outdated
Comment thread tests/cli/test_doctor.py

# 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"
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[info] tests: Foreign path string hardcodes python3.13 version. Consider using a clearly synthetic placeholder (e.g. /foreign/install/hooks) to avoid accidental staleness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/autoskillit/cli/_stale_check.py Outdated
Trecek and others added 3 commits April 8, 2026 19:52
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>
@Trecek Trecek added this pull request to the merge queue Apr 9, 2026
Merged via the queue into integration with commit f92d781 Apr 9, 2026
2 checks passed
@Trecek Trecek deleted the stale-hooks-check-prompts-infinitely-never-clears-after-inst/680 branch April 9, 2026 03:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant