Skip to content

Implementation Plan: CLI Update Prompts — Single Source of Truth + Dismissal UX#768

Merged
Trecek merged 6 commits intointegrationfrom
cli-update-prompts-single-source-of-truth-dismissal-ux/758
Apr 12, 2026
Merged

Implementation Plan: CLI Update Prompts — Single Source of Truth + Dismissal UX#768
Trecek merged 6 commits intointegrationfrom
cli-update-prompts-single-source-of-truth-dismissal-ux/758

Conversation

@Trecek
Copy link
Copy Markdown
Collaborator

@Trecek Trecek commented Apr 12, 2026

Summary

Replace the two independent startup update checks (cli/_stale_check.py +
cli/_source_drift.py) with a single unified update check that reads one source
of truth from direct_url.json (vcs_info.requested_revision), consolidates the
three existing prompts (binary / hook-drift / source-drift) into at most one
[Y/n] prompt per CLI invocation
, and uses branch-aware dismissal windows
(7 days for stable/main, 12 hours for integration/editable).

The approach introduces two new cohesive modules and deletes both legacy check
modules:

  1. cli/_install_info.py — pure classification + policy. Owns InstallInfo,
    InstallType, detect_install(), and the three policy helpers
    comparison_branch(), dismissal_window(), upgrade_command(). No I/O.
  2. cli/_update_checks.py — runtime orchestration. Owns the GitHub fetch cache
    (ported from _stale_check.py), source-repo resolution (ported from
    _source_drift.py), the three signal gatherers, the consolidated prompt, and
    the single run_update_checks() entry point called by main().
  3. cli/_update.py + a new @app.command update subcommand — first-class,
    install-type-aware upgrade path that does not require waiting for a prompt.

is_dev_mode(), _detect_install_type(), _DISMISS_WINDOW, and
_is_drift_dismissed disappear entirely. The ~/.autoskillit/dev marker file
becomes a dead signal (no new reads). Dismissal state is a single unified
record under state["update_prompt"] keyed by time + version-delta, never by
SHA pair.

Architecture Impact

State Lifecycle Diagram

%%{init: {'flowchart': {'nodeSpacing': 50, 'rankSpacing': 60, 'curve': 'basis'}}}%%
flowchart TB
    %% CLASS DEFINITIONS %%
    classDef cli fill:#1a237e,stroke:#7986cb,stroke-width:2px,color:#fff;
    classDef stateNode fill:#004d40,stroke:#4db6ac,stroke-width:2px,color:#fff;
    classDef handler fill:#e65100,stroke:#ffb74d,stroke-width:2px,color:#fff;
    classDef phase fill:#6a1b9a,stroke:#ba68c8,stroke-width:2px,color:#fff;
    classDef newComponent fill:#2e7d32,stroke:#81c784,stroke-width:2px,color:#fff;
    classDef output fill:#00695c,stroke:#4db6ac,stroke-width:2px,color:#fff;
    classDef detector fill:#b71c1c,stroke:#ef5350,stroke-width:2px,color:#fff;
    classDef gap fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#000;
    classDef terminal fill:#1a237e,stroke:#7986cb,stroke-width:2px,color:#fff;

    ENTRY(["● main() — autoskillit <cmd>"])

    subgraph FIELD_CATS ["FIELD LIFECYCLE CATEGORIES"]
        direction LR
        FLD_INIT["INIT_ONLY (frozen=True)<br/>━━━━━━━━━━<br/>★ InstallInfo: install_type<br/>commit_id · requested_revision<br/>url · editable_source<br/>★ Signal: kind · message<br/>Python raises FrozenInstanceError<br/>on any reassignment attempt"]
        FLD_MUT["MUTABLE (disk-persisted)<br/>━━━━━━━━━━<br/>★ update_check.json<br/>update_prompt.dismissed_at<br/>update_prompt.dismissed_version<br/>update_prompt.conditions<br/>binary_snoozed.snoozed_at<br/>binary_snoozed.attempted_version"]
        FLD_CACHE["MUTABLE (TTL-cached)<br/>━━━━━━━━━━<br/>★ github_fetch_cache.json<br/>body · etag · cached_at<br/>TTL: 30 min (env override)<br/>ETag used for conditional GET"]
        FLD_DERIVED["DERIVED (computed, not stored)<br/>━━━━━━━━━━<br/>★ comparison_branch()<br/>7d window → releases/latest<br/>12h window → integration<br/>★ dismissal_window()<br/>★ upgrade_command()"]
    end

    subgraph ENTRY_GATES ["GATES 1–2: ENTRY GUARDS"]
        G_ENV["Gate 1: ENV / TTY Guard<br/>━━━━━━━━━━<br/>CLAUDECODE=1 → skip<br/>CI=1 → skip<br/>AUTOSKILLIT_SKIP_STALE_CHECK=1 → skip<br/>stdin or stdout not isatty() → skip"]
        G_INSTALL["★ detect_install() → InstallInfo<br/>━━━━━━━━━━<br/>reads: direct_url.json (pip metadata)<br/>returns frozen dataclass<br/>all 5 fields INIT_ONLY"]
        G_TYPE["Gate 2: Install Type<br/>━━━━━━━━━━<br/>LOCAL_EDITABLE → skip<br/>LOCAL_PATH → skip<br/>UNKNOWN → skip<br/>GIT_VCS → proceed"]
    end

    subgraph SIGNALS ["GATE 3: SIGNAL GATHERING (parallel, 3 paths)"]
        SIG_BIN["★ _binary_signal()<br/>━━━━━━━━━━<br/>comparison_branch(info) → target<br/>_fetch_latest_version(target)<br/>fires if latest &gt; current<br/>reads/writes: github_fetch_cache.json"]
        SIG_HOOK["★ _hooks_signal()<br/>━━━━━━━━━━<br/>_count_hook_registry_drift()<br/>reads: Claude settings.json hooks key<br/>fires if missing &gt; 0 or orphaned &gt; 0<br/>no I/O side effects"]
        SIG_DRIFT["★ _source_drift_signal()<br/>━━━━━━━━━━<br/>resolve_reference_sha()<br/>git ls-remote / GitHub refs API<br/>fires if ref_sha ≠ commit_id<br/>reads/writes: github_fetch_cache.json"]
    end

    subgraph DISMISS_GATE ["GATE 4: ★ _is_dismissed() — TRIPLE-AXIS DISMISSAL CHECK"]
        DA["Axis A: Time Window<br/>━━━━━━━━━━<br/>dismissed_at + window &gt; now<br/>7 days — stable · main · tag<br/>12 hours — integration<br/>window from dismissal_window(current_install)<br/>not stored install: branch-aware re-eval"]
        DB["Axis B: Version Delta<br/>━━━━━━━━━━<br/>current_version &lt;= dismissed_version<br/>any version advance immediately<br/>overrides the time window<br/>forces re-prompt regardless"]
        DC["Axis C: Condition Match<br/>━━━━━━━━━━<br/>condition ∈ update_prompt.conditions<br/>per-signal granularity<br/>dismiss binary ≠ dismiss hooks<br/>dismiss hooks ≠ dismiss source_drift"]
        ALL3{"All 3 axes<br/>satisfied?"}
    end

    subgraph RESUME_TIERS ["RESUME DETECTION TIERS"]
        RT1["Tier 1: Suppressed<br/>━━━━━━━━━━<br/>All 3 axes pass<br/>signal silently filtered out<br/>no prompt, no output"]
        RT2["Tier 2: Version Expired<br/>━━━━━━━━━━<br/>current_version &gt; dismissed_version<br/>axis B fails → re-prompt<br/>overrides remaining window"]
        RT3["Tier 3: Window Expired<br/>━━━━━━━━━━<br/>now &gt;= dismissed_at + window<br/>axis A fails → re-prompt<br/>fresh 7d or 12h window starts"]
        RT4["Tier 4: Fresh / No State<br/>━━━━━━━━━━<br/>state = {} or key absent<br/>fail-open: all axes fail<br/>always prompt"]
    end

    subgraph MUTATIONS ["STATE MUTATION POINTS (write paths)"]
        M_DISMISS["★ N-Path: Write Dismissal<br/>━━━━━━━━━━<br/>update_prompt = {<br/>  dismissed_at: now() UTC ISO-8601<br/>  dismissed_version: current version<br/>  conditions: [firing signal kinds]<br/>}  → atomic_write"]
        M_UPGRADE["★ _run_update_sequence()<br/>━━━━━━━━━━<br/>upgrade_command(info) subprocess<br/>then: autoskillit install subprocess<br/>both receive SKIP envs injected<br/>prevents recursive update check"]
        M_SNOOZE["binary_snoozed write<br/>━━━━━━━━━━<br/>snoozed_at · attempted_version<br/>⚠ DEAD WRITE: key is written<br/>but never READ by _is_dismissed()<br/>has no suppression effect"]
        M_CLEAR["★ Success: Clear Dismissal<br/>━━━━━━━━━━<br/>pop update_prompt<br/>pop update_snoozed<br/>atomic_write(cleaned state)<br/>next startup re-checks from scratch"]
        M_FETCH["Fetch Cache Write<br/>━━━━━━━━━━<br/>body · etag · cached_at<br/>written on HTTP 200 or 304<br/>atomic_write to cache file"]
    end

    STORAGE[("★ ~/.autoskillit/<br/>update_check.json<br/>atomic writes via os.replace")]
    CACHE_FILE[("★ ~/.autoskillit/<br/>github_fetch_cache.json<br/>read: TTL-gated")]
    SILENT(["silent return"])
    PROMPT{"★ Consolidated prompt<br/>Update now? [Y/n]<br/>single prompt for all signals"}

    %% ENTRY FLOW %%
    ENTRY --> G_ENV
    G_ENV -->|"headless / CI / skip-flags / no-TTY"| SILENT
    G_ENV --> G_INSTALL
    G_INSTALL --> G_TYPE
    G_TYPE -->|"LOCAL · UNKNOWN"| SILENT
    G_TYPE -->|"GIT_VCS"| SIG_BIN
    G_TYPE --> SIG_HOOK
    G_TYPE --> SIG_DRIFT

    %% SIGNALS → DISMISSAL GATE %%
    SIG_BIN --> DA
    SIG_HOOK --> DA
    SIG_DRIFT --> DA
    DA --> DB --> DC --> ALL3

    %% RESUME TIER ROUTING %%
    ALL3 -->|"all pass: suppressed"| RT1
    ALL3 -->|"axis B fails: version advanced"| RT2
    ALL3 -->|"axis A fails: window expired"| RT3
    ALL3 -->|"no prior state"| RT4
    RT1 --> SILENT
    RT2 --> PROMPT
    RT3 --> PROMPT
    RT4 --> PROMPT

    %% ACTION PATHS %%
    PROMPT -->|"N / other → dismiss"| M_DISMISS
    PROMPT -->|"Y / Enter → upgrade"| M_UPGRADE
    M_UPGRADE -->|"version advanced"| M_CLEAR
    M_UPGRADE -->|"version unchanged"| M_SNOOZE

    %% WRITES TO STORAGE %%
    M_DISMISS --> STORAGE
    M_SNOOZE --> STORAGE
    M_CLEAR --> STORAGE
    SIG_BIN --> M_FETCH
    SIG_DRIFT --> M_FETCH
    M_FETCH --> CACHE_FILE

    %% LIFECYCLE CATEGORY READ ANNOTATIONS %%
    FLD_MUT -.->|"READ at startup via _read_dismiss_state()"| STORAGE
    FLD_CACHE -.->|"READ if within TTL"| CACHE_FILE

    %% CLASS ASSIGNMENTS %%
    class ENTRY cli;
    class G_ENV detector;
    class G_INSTALL newComponent;
    class G_TYPE detector;
    class FLD_INIT detector;
    class FLD_MUT phase;
    class FLD_CACHE stateNode;
    class FLD_DERIVED output;
    class SIG_BIN,SIG_HOOK,SIG_DRIFT newComponent;
    class DA,DB,DC detector;
    class ALL3 handler;
    class RT1 output;
    class RT2,RT3,RT4 gap;
    class M_DISMISS,M_CLEAR newComponent;
    class M_UPGRADE handler;
    class M_SNOOZE gap;
    class M_FETCH stateNode;
    class STORAGE,CACHE_FILE stateNode;
    class SILENT,PROMPT terminal;
Loading

Development Diagram

%%{init: {'flowchart': {'nodeSpacing': 50, 'rankSpacing': 70, 'curve': 'basis'}}}%%
flowchart TB
    %% CLASS DEFINITIONS %%
    classDef cli fill:#1a237e,stroke:#7986cb,stroke-width:2px,color:#fff;
    classDef stateNode fill:#004d40,stroke:#4db6ac,stroke-width:2px,color:#fff;
    classDef handler fill:#e65100,stroke:#ffb74d,stroke-width:2px,color:#fff;
    classDef phase fill:#6a1b9a,stroke:#ba68c8,stroke-width:2px,color:#fff;
    classDef newComponent fill:#2e7d32,stroke:#81c784,stroke-width:2px,color:#fff;
    classDef output fill:#00695c,stroke:#4db6ac,stroke-width:2px,color:#fff;
    classDef detector fill:#b71c1c,stroke:#ef5350,stroke-width:2px,color:#fff;
    classDef terminal fill:#1a237e,stroke:#7986cb,stroke-width:2px,color:#fff;

    %% ── BUILD ─────────────────────────────────────────────────── %%
    subgraph BUILD ["BUILD TOOLING"]
        direction TB
        PYPROJECT["● pyproject.toml<br/>━━━━━━━━━━<br/>hatchling build-backend<br/>v0.7.78 (+packaging≥23.0)<br/>uv.lock updated"]
        TASKFILE["Taskfile.yml<br/>━━━━━━━━━━<br/>task test-all (lint+test)<br/>task test-check (CI/MCP)<br/>task sync-plugin-version"]
    end

    %% ── SOURCE ────────────────────────────────────────────────── %%
    subgraph SOURCE ["SOURCE: src/autoskillit/cli/ (L3)"]
        direction TB
        INSTALL_INFO["★ _install_info.py<br/>━━━━━━━━━━<br/>InstallType StrEnum<br/>InstallInfo dataclass<br/>detect_install()<br/>comparison_branch()<br/>dismissal_window()<br/>upgrade_command()<br/><i>Pure — no I/O, no network</i>"]
        UPDATE_CHECKS["★ _update_checks.py<br/>━━━━━━━━━━<br/>Signal(binary/hooks/source_drift)<br/>_read/_write_dismiss_state()<br/>_fetch_with_cache() + ETag<br/>_binary_signal()<br/>_hooks_signal()<br/>_source_drift_signal()<br/>run_update_checks()"]
        UPDATE["★ _update.py<br/>━━━━━━━━━━<br/>run_update_command()<br/>upgrade + autoskillit install<br/>dismissal clear on success"]
        APP["● app.py<br/>━━━━━━━━━━<br/>+update subcommand<br/>wired to cyclopts App"]
        DOCTOR["● _doctor.py<br/>━━━━━━━━━━<br/>install_info check<br/>(network=False mode)"]
    end

    %% ── QUALITY GATES ─────────────────────────────────────────── %%
    subgraph QUALITY ["CODE QUALITY GATES"]
        direction TB
        PRECOMMIT["pre-commit hooks<br/>━━━━━━━━━━<br/>ruff format (auto-fix)<br/>ruff lint (auto-fix)<br/>mypy type-check<br/>uv lock check<br/>gitleaks secret scan"]
        IMPORTLINT["import-linter<br/>━━━━━━━━━━<br/>IL-001…IL-007 contracts<br/>L0/L1/L2/L3 layer isolation<br/>cli must not import upward"]
        ARCH_RULES["● tests/arch/_rules.py<br/>━━━━━━━━━━<br/>AST rule definitions"]
        ARCH_ISO["● test_subpackage_isolation.py<br/>━━━━━━━━━━<br/>layer contract assertions<br/>new modules registered"]
    end

    %% ── TESTS ─────────────────────────────────────────────────── %%
    subgraph TESTS ["TEST FRAMEWORK — pytest-xdist -n 4"]
        direction TB
        T_INSTALL["★ test_install_info.py<br/>━━━━━━━━━━<br/>detect_install() variants<br/>comparison_branch() policy<br/>dismissal_window() policy<br/>upgrade_command() policy"]
        T_CHECKS["★ test_update_checks.py<br/>━━━━━━━━━━<br/>UC-1 env guards<br/>dismiss state I/O<br/>fetch cache + ETag<br/>signal gathering<br/>_is_dismissed() axes<br/>run_update_checks() e2e"]
        T_CMD["★ test_update_command.py<br/>━━━━━━━━━━<br/>update --help registered<br/>upgrade cmd per revision<br/>autoskillit install called<br/>skip-env propagated<br/>dismissal cleared on success<br/>UNKNOWN → SystemExit(2)"]
        T_DOCTOR["● test_doctor.py<br/>━━━━━━━━━━<br/>install_info check updated"]
        T_TTY["● test_input_tty_contracts.py<br/>━━━━━━━━━━<br/>TTY guard coverage"]
        T_ENV["● test_subprocess_env_contracts.py<br/>━━━━━━━━━━<br/>skip-env vars verified"]
        T_DOCS["● test_doc_counts.py<br/>━━━━━━━━━━<br/>+1 doc file registered"]
        T_SCHEMA["● test_schema_version_convention.py<br/>━━━━━━━━━━<br/>v0.7.78 bump verified"]
    end

    %% ── ENTRY POINTS ──────────────────────────────────────────── %%
    subgraph ENTRY ["ENTRY POINTS"]
        direction LR
        CLI_SERVE["autoskillit serve<br/>━━━━━━━━━━<br/>MCP server (default)"]
        CLI_UPDATE["★ autoskillit update<br/>━━━━━━━━━━<br/>branch-aware upgrade<br/>+ hook sync"]
        CLI_DOCTOR["autoskillit doctor<br/>━━━━━━━━━━<br/>16 project health checks<br/>(incl. install_info check)"]
    end

    %% ── DISK ARTIFACTS ─────────────────────────────────────────── %%
    DISMISS_STATE["~/.autoskillit/update_check.json<br/>━━━━━━━━━━<br/>dismissed_at / dismissed_version<br/>conditions list (branch-aware TTL)"]
    FETCH_CACHE["~/.autoskillit/github_fetch_cache.json<br/>━━━━━━━━━━<br/>ETag + body per URL<br/>30-min TTL (rate-limit defense)"]

    %% ── EXTERNAL ───────────────────────────────────────────────── %%
    GITHUB_API["GitHub API<br/>━━━━━━━━━━<br/>releases/latest<br/>pyproject.toml@integration<br/>git refs/heads/{rev}"]

    %% ── FLOW ──────────────────────────────────────────────────── %%
    PYPROJECT --> SOURCE
    TASKFILE --> QUALITY
    TASKFILE --> TESTS

    INSTALL_INFO -->|"imported by"| UPDATE_CHECKS
    INSTALL_INFO -->|"imported by"| UPDATE
    INSTALL_INFO -->|"imported by"| DOCTOR
    UPDATE_CHECKS -->|"imported by"| UPDATE
    APP -->|"calls"| UPDATE_CHECKS
    APP -->|"calls"| UPDATE

    SOURCE --> QUALITY
    QUALITY --> TESTS
    TESTS --> ENTRY

    UPDATE_CHECKS <-->|"reads/writes"| DISMISS_STATE
    UPDATE_CHECKS <-->|"reads/writes"| FETCH_CACHE
    FETCH_CACHE <-->|"conditional GET + ETag"| GITHUB_API

    T_INSTALL -->|"tests"| INSTALL_INFO
    T_CHECKS -->|"tests"| UPDATE_CHECKS
    T_CMD -->|"tests"| UPDATE
    T_DOCTOR -->|"tests"| DOCTOR
    T_ENV -->|"tests"| UPDATE_CHECKS

    APP --> CLI_UPDATE
    APP --> CLI_SERVE
    APP --> CLI_DOCTOR

    %% ── CLASS ASSIGNMENTS ─────────────────────────────────────── %%
    class PYPROJECT,TASKFILE phase;
    class INSTALL_INFO,UPDATE,UPDATE_CHECKS newComponent;
    class APP,DOCTOR handler;
    class PRECOMMIT,IMPORTLINT detector;
    class ARCH_RULES,ARCH_ISO detector;
    class T_INSTALL,T_CHECKS,T_CMD newComponent;
    class T_DOCTOR,T_TTY,T_ENV,T_DOCS,T_SCHEMA handler;
    class CLI_SERVE,CLI_DOCTOR cli;
    class CLI_UPDATE newComponent;
    class DISMISS_STATE,FETCH_CACHE stateNode;
    class GITHUB_API output;
Loading

Scenarios Diagram

%%{init: {'flowchart': {'nodeSpacing': 40, 'rankSpacing': 55, 'curve': 'basis'}}}%%
flowchart LR
    %% CLASS DEFINITIONS %%
    classDef cli fill:#1a237e,stroke:#7986cb,stroke-width:2px,color:#fff;
    classDef stateNode fill:#004d40,stroke:#4db6ac,stroke-width:2px,color:#fff;
    classDef handler fill:#e65100,stroke:#ffb74d,stroke-width:2px,color:#fff;
    classDef phase fill:#6a1b9a,stroke:#ba68c8,stroke-width:2px,color:#fff;
    classDef newComponent fill:#2e7d32,stroke:#81c784,stroke-width:2px,color:#fff;
    classDef output fill:#00695c,stroke:#4db6ac,stroke-width:2px,color:#fff;
    classDef detector fill:#b71c1c,stroke:#ef5350,stroke-width:2px,color:#fff;
    classDef integration fill:#c62828,stroke:#ef9a9a,stroke-width:2px,color:#fff;
    classDef terminal fill:#1a237e,stroke:#7986cb,stroke-width:2px,color:#fff;

    %% ─────────────────────────────────────────────────────────
    %% SCENARIO 1: Startup update check — user accepts update
    %% ─────────────────────────────────────────────────────────
    subgraph S1 ["S1 · Startup Check → Accept & Update"]
        direction LR
        S1_CLI["● app.py main()<br/>━━━━━━━━━━<br/>any non-serve cmd<br/>calls run_update_checks()"]
        S1_GATE["★ run_update_checks()<br/>━━━━━━━━━━<br/>guards: CI / TTY / env<br/>reads dismiss state"]
        S1_DETECT["★ detect_install()<br/>━━━━━━━━━━<br/>reads direct_url.json<br/>→ InstallInfo"]
        S1_SIG["★ signal gatherers<br/>━━━━━━━━━━<br/>_binary_signal()<br/>_hooks_signal()<br/>_source_drift_signal()"]
        S1_CACHE["★ _fetch_with_cache()<br/>━━━━━━━━━━<br/>30-min disk TTL<br/>ETag / GitHub API"]
        S1_FILTER["★ _is_dismissed()<br/>━━━━━━━━━━<br/>time-window check<br/>version-delta check"]
        S1_PROMPT["★ [Y/n] prompt<br/>━━━━━━━━━━<br/>bullets each signal<br/>one prompt only"]
        S1_RUN["★ _run_update_sequence()<br/>━━━━━━━━━━<br/>upgrade_command() subprocess<br/>+ autoskillit install"]
        S1_VERIFY["★ _verify_update_result()<br/>━━━━━━━━━━<br/>importlib.metadata<br/>confirm version advanced"]
    end

    S1_CLI -->|"calls"| S1_GATE
    S1_GATE -->|"classify"| S1_DETECT
    S1_DETECT -->|"InstallInfo"| S1_SIG
    S1_SIG -->|"fetch version / SHA"| S1_CACHE
    S1_SIG -->|"filter dismissed"| S1_FILTER
    S1_FILTER -->|"firing signals"| S1_PROMPT
    S1_PROMPT -->|"Y"| S1_RUN
    S1_RUN -->|"verify"| S1_VERIFY

    %% ─────────────────────────────────────────────────────────
    %% SCENARIO 2: Startup update check — user dismisses
    %% ─────────────────────────────────────────────────────────
    subgraph S2 ["S2 · Startup Check → Dismiss"]
        direction LR
        S2_PROMPT["★ [Y/n] prompt<br/>━━━━━━━━━━<br/>fires after guard/filter<br/>in run_update_checks()"]
        S2_WRITE["★ _write_dismiss_state()<br/>━━━━━━━━━━<br/>conditions: [binary,hooks,drift]<br/>dismissed_version, dismissed_at"]
        S2_STATE["update_check.json<br/>━━━━━━━━━━<br/>~/.autoskillit/<br/>branch-aware window key"]
        S2_NEXT["next invocation<br/>━━━━━━━━━━<br/>_is_dismissed() returns True<br/>prompt suppressed"]
    end

    S2_PROMPT -->|"N"| S2_WRITE
    S2_WRITE -->|"atomic_write"| S2_STATE
    S2_STATE -->|"read on next run"| S2_NEXT

    %% ─────────────────────────────────────────────────────────
    %% SCENARIO 3: autoskillit update (explicit command)
    %% ─────────────────────────────────────────────────────────
    subgraph S3 ["S3 · autoskillit update (explicit)"]
        direction LR
        S3_CLI["● app.py update()<br/>━━━━━━━━━━<br/>subcommand handler<br/>calls run_update_command()"]
        S3_DETECT["★ detect_install()<br/>━━━━━━━━━━<br/>reads direct_url.json<br/>→ InstallInfo"]
        S3_CMD["★ upgrade_command()<br/>━━━━━━━━━━<br/>branch-aware:<br/>uv tool upgrade / force reinstall"]
        S3_PROC["subprocess run<br/>━━━━━━━━━━<br/>upgrade + autoskillit install<br/>SKIP_STALE_CHECK=1"]
        S3_VERIFY["★ _verify_update_result()<br/>━━━━━━━━━━<br/>importlib.metadata check"]
        S3_CLEAR["★ _write_dismiss_state()<br/>━━━━━━━━━━<br/>pop update_prompt<br/>clear stale dismissal"]
    end

    S3_CLI -->|"calls"| S3_DETECT
    S3_DETECT -->|"InstallInfo"| S3_CMD
    S3_CMD -->|"command list"| S3_PROC
    S3_PROC -->|"on success"| S3_VERIFY
    S3_VERIFY -->|"version advanced"| S3_CLEAR

    %% ─────────────────────────────────────────────────────────
    %% SCENARIO 4: autoskillit doctor — install & dismissal audit
    %% ─────────────────────────────────────────────────────────
    subgraph S4 ["S4 · autoskillit doctor (install audit, no network)"]
        direction LR
        S4_CLI["● _doctor.py checks<br/>━━━━━━━━━━<br/>check_source_drift<br/>check_install_classification<br/>check_update_dismissal_state"]
        S4_DETECT["★ detect_install()<br/>━━━━━━━━━━<br/>classify from direct_url.json<br/>UNKNOWN → WARNING"]
        S4_SHA["★ resolve_reference_sha()<br/>━━━━━━━━━━<br/>network=False (doctor mode)<br/>reads disk cache only"]
        S4_CACHE["★ _api_sha() cache read<br/>━━━━━━━━━━<br/>no HTTP; reads existing<br/>github_fetch_cache.json"]
        S4_DISMISS["★ _read_dismiss_state()<br/>━━━━━━━━━━<br/>reads update_check.json<br/>reports expiry date"]
        S4_RESULT["DoctorResult<br/>━━━━━━━━━━<br/>OK / WARNING / ERROR<br/>with human-readable message"]
    end

    S4_CLI -->|"classify"| S4_DETECT
    S4_CLI -->|"drift check"| S4_SHA
    S4_SHA -->|"cache-only lookup"| S4_CACHE
    S4_CLI -->|"dismissal window"| S4_DISMISS
    S4_DETECT -->|"result"| S4_RESULT
    S4_CACHE -->|"SHA or None"| S4_RESULT
    S4_DISMISS -->|"expiry info"| S4_RESULT

    %% CLASS ASSIGNMENTS
    class S1_CLI,S3_CLI,S4_CLI cli;
    class S1_GATE,S1_SIG,S1_FILTER,S1_PROMPT,S2_PROMPT,S1_VERIFY,S3_VERIFY newComponent;
    class S1_DETECT,S3_DETECT,S4_DETECT,S3_CMD,S4_SHA newComponent;
    class S1_CACHE,S4_CACHE,S1_RUN,S3_PROC integration;
    class S1_WRITE,S2_WRITE,S3_CLEAR,S4_DISMISS newComponent;
    class S2_STATE stateNode;
    class S2_NEXT phase;
    class S2_WRITE,S3_CLEAR handler;
    class S4_RESULT output;
Loading

Closes #758

Implementation Plan

Plan file: /home/talon/projects/autoskillit-runs/impl-20260412-120704-922893/.autoskillit/temp/make-plan/cli_update_prompts_single_source_of_truth_plan_2026-04-12_121616.md

🤖 Generated with Claude Code via AutoSkillit

Token Usage Summary

Step uncached output cache_read cache_write count time
plan 339 50.9k 2.7M 141.5k 1 14m 22s
verify 2.1k 25.0k 1.3M 59.7k 1 10m 6s
implement 4.5k 65.2k 10.0M 149.5k 1 18m 56s
fix 1.3k 50.9k 7.0M 138.0k 1 23m 41s
prepare_pr 60 7.9k 210.4k 35.6k 1 2m 1s
run_arch_lenses 213 31.2k 768.7k 141.1k 3 10m 7s
compose_pr 67 12.4k 260.8k 38.4k 1 3m 56s
Total 8.6k 243.5k 22.2M 703.8k 1h 23m

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

13 actionable findings (2 critical, 11 warning). See inline comments.

logger = get_logger(__name__)

_DISMISS_FILE = "update_check.json"
_STABLE_DISMISS_WINDOW = timedelta(days=7)
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] slop: _STABLE_DISMISS_WINDOW and _DEV_DISMISS_WINDOW are module-level constants that duplicate the policy already encoded in dismissal_window() in _install_info.py. run_update_checks() calls dismissal_window(info) directly rather than these constants, making them dead values referenced only by a docstring-mirror test. Remove and derive from dismissal_window() instead.

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. _STABLE_DISMISS_WINDOW and _DEV_DISMISS_WINDOW are imported and actively used in tests/arch/test_subpackage_isolation.py:1215,1232–1233 in test_singleton_exemption_comment_matches_both_windows(). They are exported as part of the public test surface to keep the singleton-allowed exemption comment in sync with the actual window values. Removing them would break that arch test.

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 review found 13 blocking issues (2 critical, 11 warning). See inline comments. [verdict: changes_requested — GitHub self-review constraint prevented REQUEST_CHANGES state; review submitted as COMMENT]

@Trecek Trecek enabled auto-merge April 12, 2026 21:35
@Trecek Trecek force-pushed the cli-update-prompts-single-source-of-truth-dismissal-ux/758 branch 2 times, most recently from 5ea3d37 to 2741fec Compare April 12, 2026 22:23
Trecek and others added 5 commits April 12, 2026 15:33
…re dismissal

Replaces _stale_check.py + _source_drift.py with two new cohesive modules:
- cli/_install_info.py: pure classification + policy (InstallType, InstallInfo,
  detect_install, comparison_branch, dismissal_window, upgrade_command)
- cli/_update_checks.py: runtime orchestration with Signal gatherers, unified
  _is_dismissed (time+version-delta expiry, no SHA keying), and run_update_checks()
  that emits at most one [Y/n] prompt per CLI invocation

Also adds:
- cli/_update.py + app.py `update` subcommand: first-class upgrade path
- Doctor checks 15 (install_classification) and 16 (update_dismissal_state)
- source_version_drift now uses network=True
- docs/updating.md: branch-aware windows, escape hatches, autoskillit update
- docs/installation.md: updated to 16 checks with new rows 13-16 + Updating section
- Tests: test_install_info.py, test_update_checks.py, test_update_command.py
- Version bump: 0.7.77 → 0.7.78

Fixes #758

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add module-level imports for _count_hook_registry_drift and
  _claude_settings_path in _update_checks.py (tests monkeypatched
  these but they were local imports inside functions)
- Add module-level imports for detect_install, upgrade_command,
  terminal_guard in _update.py (same reason)
- Update _is_dismissed docstring to mention '7 days' and '12 hours'
  windows (test_update_checks_docstring_describes_both_windows)
- Update CLAUDE.md architecture section: replace deleted _stale_check
  and _source_drift entries with _install_info, _update, _update_checks
- Rename docs/updating.md -> docs/update-checks.md (noun-phrase rule)
- Update doc references from updating.md to update-checks.md
- Fix doctor test patches: use autoskillit.cli._install_info.detect_install
  (doctor imports detect_install from _install_info, not _update_checks)
- Fix test_check_source_version_drift_ok_when_cache_empty: assert
  'unavailable' in message (doctor now uses network=True, message changed)
- Delete test_check_source_version_drift_never_makes_network_call
  (contradicts network=True design; doctor now makes network calls)
- Remove stale TestSyncRemovalCLI::test_update_command_does_not_exist
  (update command now exists as designed)
- Fix HookDriftResult calls: remove 'ok' kwarg (not a NamedTuple field)
- Update test_doc_counts: assert 18 doctor checks (16 numbered + 2 lettered)
- Update schema convention allowlist line numbers (102/129 -> 104/131)
…unify snooze key

- _update.py and _update_checks._run_update_sequence both called
  _verify_update_result(current, current, ...) — passing current as both
  current and latest. The snooze record now stores attempted_version from
  the fetched latest (via comparison_branch + _fetch_latest_version).
- run_update_command now clears 'binary_snoozed' (consistent with writer)
  instead of 'update_snoozed' (stale key name).
- Remove dead duplicate _INSTALL_FROM_INTEGRATION from _update_checks.py;
  canonical definition lives in _install_info.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…verage

- Remove tautological `or 'source_drift' == sig.kind` arm in
  test_source_drift_signal_fires_when_commit_lags_ref (always True)
- Strengthen test_yes_runs_upgrade_command_from_install_info_not_hardcoded
  to assert the exact command from upgrade_command(info) was invoked, not a
  trivially-satisfied string-match over any subprocess call
- Add test_update_verifies_version_advance_and_warns_on_failure disk-state
  assertion: binary_snoozed.attempted_version must equal the fetched latest
- Add find_source_repo behavioral tests: env-var override valid/invalid,
  CWD-walk pyproject.toml match, no-match returns None
- Add test_check_source_version_drift_returns_ok_when_network_unavailable:
  verifies fail-open (Severity.OK) when resolve_reference_sha returns None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…numbers

- Patch _fetch_latest_version via autoskillit.cli._update_checks (the
  module that owns it) not _update (which lazy-imports it)
- Update _LEGACY_JSON_WRITES line numbers: removal of the dead
  _INSTALL_FROM_INTEGRATION constant shifted the two _update_checks.py
  atomic_write sites from lines 104/131 to 103/130

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Trecek Trecek force-pushed the cli-update-prompts-single-source-of-truth-dismissal-ux/758 branch from 2741fec to 7a64133 Compare April 12, 2026 22:34
Replace bare 'autoskillit' binary invocation with sys.executable -m autoskillit
so the test resolves the CLI via the active venv rather than relying on PATH,
matching the established pattern in test_recording_sigterm.py.
@Trecek Trecek added this pull request to the merge queue Apr 12, 2026
@Trecek Trecek disabled auto-merge April 12, 2026 22:46
Merged via the queue into integration with commit d215133 Apr 12, 2026
2 checks passed
@Trecek Trecek deleted the cli-update-prompts-single-source-of-truth-dismissal-ux/758 branch April 12, 2026 22:51
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