Implementation Plan: CLI Startup Readiness Coordinator Skeleton

Mission ID: 01KS7JRVSFFBWPD2XZ7B8162E6 Mission slug: cli-startup-readiness-coordinator-skeleton-01KS7JRV Branch: kitty/mission-cli-startup-readiness-coordinator-skeleton-01KS7JRV (PR will target main) Date: 2026-05-22 Spec: spec.md Issue: Priivacy-ai/spec-kitty#1093

Summary

Introduce a single CLI startup readiness coordinator (src/specify_cli/readiness/coordinator.py) and wire it into the root CLI callback (src/specify_cli/cli/helpers.py). The coordinator's first gate is is_saas_sync_enabled(); when hosted mode is disabled it returns a no-op result, calls the existing _render_nag_if_needed() to preserve legacy upgrade-nag behavior, and emits no Teamspace-labeled output. When hosted mode is enabled, the coordinator derives an OutputPolicy (interactive / non-interactive / machine-output) from the same signals _should_suppress_nag() consults, stages an AuthStatus stub seam for Workstream 2, wraps the nag call, and caches a typed ReadinessResult on ctx.obj so subcommands can read it via get_readiness(ctx). This is the seam every downstream readiness workstream plugs into; no auth or upgrade UX is implemented in this mission.

Technical Context

Language/Version: Python 3.11+ (existing spec-kitty codebase requirement) Primary Dependencies: typer (existing), rich (existing — used only by the wrapped legacy nag; no new rich output added by the coordinator), stdlib (dataclasses, enum.StrEnum, sys, os, typing) Storage: None added; ctx.obj is the only state, in-memory per CLI invocation Testing: pytest with the existing CliRunner / direct-callback-invocation patterns from tests/cli_gate/test_ci_determinism.py; mypy --strict on the new package Target Platform: All existing spec-kitty platforms (Linux, macOS, Windows 10+) Project Type: Single CLI package addition (one new module under src/specify_cli/) Performance Goals: ≤1ms p50 coordinator overhead when hosted mode is disabled; ≤2ms p50 additional overhead beyond the existing nag-render path when hosted mode is enabled (NFR-001) Constraints: No new pip dependencies; no network I/O; no SaaS DB / queue / readiness counter mutation; no new disk reads beyond what the wrapped nag already does; no Teamspace string in default-mode stdout/stderr (FR-011); no modification of _render_nag_if_needed() itself (C-003, C-005) Scale/Scope: One new package (~120 LOC implementation), one modified callback (~5 LOC delta), three test files (~250 LOC total)

Charter Check

GATE: Must pass before Phase 0. Re-check after Phase 1.

Charter directives loaded for the plan action: DIRECTIVE_003 (Decision Documentation), DIRECTIVE_010 (Specification Fidelity). Both apply:

  • DIRECTIVE_003 — Decision Documentation: This plan documents the architectural choice to own the existing nag call site from inside the coordinator (rather than duplicate it), the ctx.obj keying convention, the suppression-decision signal set, and the lazy-import strategy for the _auth_recovery stub seam. Recorded in this plan and in spec.md Assumptions.
  • DIRECTIVE_010 — Specification Fidelity: This plan's file surfaces and acceptance criteria are 1:1 with spec.md FR-001 through FR-012 and the Acceptance Criteria section. Any deviation will be flagged in /spec-kitty.analyze and resolved upstream before tasks.

Other doctrine relevant to this mission:

  • Producer canonicality (program-wide operating rule): no new event producers expected in this mission. If any are added during implementation, they must use spec_kitty_events pydantic models. CI enforces via scripts/lint_canonical_producers.py.
  • Pre-launch hidden mode (program-wide operating rule): coordinator no-ops unless is_saas_sync_enabled() returns True. Encoded as FR-003 and C-002.
  • Suppression contract (program-wide operating rule): no human prompts in --json / --quiet / --help / --version / CI / non-TTY. Encoded as FR-004, FR-011 and the test matrix.

Charter check: PASS (no violations; all relevant directives have explicit handling).

Project Structure

Documentation (this feature)

kitty-specs/cli-startup-readiness-coordinator-skeleton-01KS7JRV/
├── plan.md              # This file
├── spec.md              # Authoritative spec (committed)
├── research.md          # Phase 0 output — design-rationale capture
├── data-model.md        # Phase 1 output — ReadinessResult / OutputPolicy / AuthStatus
├── quickstart.md        # Phase 1 output — how a downstream agent uses the seam
├── contracts/
│   └── readiness-api.md # Phase 1 output — public API surface of specify_cli.readiness
└── tasks.md             # Phase 2 output (generated by /spec-kitty.tasks)

Source Code (repository root)

src/specify_cli/readiness/
├── __init__.py                                          # public API re-exports (new)
└── coordinator.py                                       # implementation (new)

src/specify_cli/cli/
└── helpers.py                                           # MODIFIED: replace inline _render_nag_if_needed call in callback() with evaluate_readiness(ctx)

tests/readiness/
├── __init__.py                                          # package marker (new)
├── test_coordinator_suppression_matrix.py               # 7-row matrix + no-Teamspace-leakage (new)
├── test_coordinator_caching.py                          # once-per-ctx invariant + ctx.obj edge cases (new)
└── test_coordinator_nag_passthrough.py                  # nag passthrough byte-for-byte (new)

No other files modified. Diff scope is enforced by AC #9 (git diff main...HEAD --stat).

Architecture

The seam

# src/specify_cli/readiness/coordinator.py (sketch — actual implementation in WP02)

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum

import typer

from specify_cli.saas.rollout import is_saas_sync_enabled

# WS2: auth probe wiring — imported as a typed stub seam; not exercised in this mission.
from specify_cli.cli.commands._auth_recovery import detect_logged_out_with_connected_teamspace  # noqa: F401

_READINESS_CTX_KEY = "readiness"


class OutputPolicy(StrEnum):
    INTERACTIVE = "interactive"
    NON_INTERACTIVE = "non_interactive"
    MACHINE_OUTPUT = "machine_output"


class AuthStatus(StrEnum):
    NOT_CHECKED = "not_checked"          # used this mission for the enabled path
    DISABLED = "disabled"                 # used this mission for the disabled path


@dataclass(frozen=True, slots=True)
class ReadinessResult:
    enabled: bool
    ran: bool
    output_policy: OutputPolicy
    auth_status: AuthStatus
    nag_invoked: bool


_NOOP_DISABLED: ReadinessResult = ReadinessResult(
    enabled=False,
    ran=False,
    output_policy=OutputPolicy.NON_INTERACTIVE,
    auth_status=AuthStatus.DISABLED,
    nag_invoked=False,
)


def evaluate_readiness(ctx: typer.Context) -> ReadinessResult:
    # Defensive once-per-ctx cache
    cached = _read_cached(ctx)
    if cached is not None:
        return cached

    try:
        result = _evaluate_uncached(ctx)
    except Exception:
        # FR-010: never raise out of the coordinator
        result = _NOOP_DISABLED

    _write_cached(ctx, result)
    return result


def get_readiness(ctx: typer.Context) -> ReadinessResult:
    cached = _read_cached(ctx)
    return cached if cached is not None else _NOOP_DISABLED

_evaluate_uncached is the only function with branching:

def _evaluate_uncached(ctx: typer.Context) -> ReadinessResult:
    output_policy = _derive_output_policy()  # uses sys.argv + is_ci_env + sys.stdout.isatty

    if not is_saas_sync_enabled():
        # Disabled path: still run the legacy nag (preserves existing behavior),
        # but emit zero Teamspace-labeled output.
        _invoke_nag(ctx)
        return ReadinessResult(
            enabled=False,
            ran=False,
            output_policy=output_policy,
            auth_status=AuthStatus.DISABLED,
            nag_invoked=True,
        )

    # Enabled path: wrap the legacy nag, stub the auth probe (WS2 seam).
    _invoke_nag(ctx)
    return ReadinessResult(
        enabled=True,
        ran=True,
        output_policy=output_policy,
        auth_status=AuthStatus.NOT_CHECKED,  # WS2: auth probe wiring
        nag_invoked=True,
    )


def _invoke_nag(ctx: typer.Context) -> None:
    # Lazy import to avoid circular import between helpers.py and coordinator.py
    from specify_cli.cli.helpers import _render_nag_if_needed
    _render_nag_if_needed(ctx)

ctx.obj keying

  • Key: "readiness" (distinct from "compat_plan_result" written by _render_nag_if_needed).
  • If ctx.obj is None: coordinator sets ctx.obj = {} then writes.
  • If ctx.obj is a dict: writes the key.
  • If ctx.obj is a non-dict, non-None object: skips caching (defensive). get_readiness returns _NOOP_DISABLED in that case.

The callback hook

src/specify_cli/cli/helpers.py callback() diff (minimal): remove the inline _render_nag_if_needed(ctx) call; insert two lines that import and invoke evaluate_readiness(ctx) in the same position. The existing maybe_emit_no_upgrade_notice(command_name) block is unchanged.

Why import _auth_recovery at all this mission?

To make the WS2 hand-off unambiguous: the next mission's reviewer can grep for # WS2: auth probe wiring and find the exact line where the auth probe needs to be wired. Importing detect_logged_out_with_connected_teamspace (with # noqa: F401) means the WS2 mission lights up the call site with one line and doesn't have to discover where the seam should live. _auth_recovery only imports stdlib modules at top-level (asyncio, os, sys, enum, pathlib, typing); the heavy imports are all lazy. Decision: keep the import at module scope of coordinator.py for type-check clarity and grep-ability.

How OutputPolicy is derived

Mirrors _should_suppress_nag()'s signals but produces a 3-bucket value instead of a single boolean:

1. If --json or --quiet in sys.argv[1:]MACHINE_OUTPUT. 2. Else if --help/-h/--version/-v in sys.argv[1:] OR is_ci_env() OR not sys.stdout.isatty()NON_INTERACTIVE. 3. Else → INTERACTIVE.

The single source of truth for nag suppression remains _should_suppress_nag inside _render_nag_if_needed. _derive_output_policy is the coordinator's record for downstream consumers.

Phase 0: Research

Phase 0 for this mission is a design-rationale capture rather than a technology investigation, because all technical choices are forced by the existing codebase. The research note records:

  • Why coordinator owns the nag call site (vs. shadowing the nag with a parallel render).
  • Why OutputPolicy is 3-bucket (vs. boolean) — WS2 and WS3 will distinguish CI from JSON output.
  • Why the _auth_recovery import is module-scope (mypy --strict signal) vs. function-scope (import-cost minimization).
  • Why we keep _render_nag_if_needed at its current location and don't move it into the coordinator (C-005 says it must remain importable from specify_cli.cli.helpers).
  • Why ctx.obj keying is "readiness" (sibling to existing "compat_plan_result").

Output: research.md.

Phase 1: Design & Contracts

Data model

data-model.md documents:

  • ReadinessResult — frozen dataclass; field list, types, invariants.
  • OutputPolicy — 3-bucket StrEnum with semantics for each bucket.
  • AuthStatus — StrEnum with the two values this mission ships; reservations for WS2 values listed as future-only.
  • ctx.obj key contract — key name "readiness", dict-typed value, must coexist with "compat_plan_result".

API surface

contracts/readiness-api.md documents the public symbols:

  • evaluate_readiness(ctx: typer.Context) -> ReadinessResult — entry point.
  • get_readiness(ctx: typer.Context) -> ReadinessResult — accessor.
  • ReadinessResult (frozen dataclass).
  • OutputPolicy, AuthStatus (StrEnums).

Stability tier: mission-internal seam. WS2 will widen the API once the auth probe is wired; this mission only commits to symbol names and dataclass field names.

Quickstart

quickstart.md shows the downstream consumer pattern:

import typer
from specify_cli.readiness import get_readiness, OutputPolicy

def my_subcommand(ctx: typer.Context) -> None:
    readiness = get_readiness(ctx)
    if not readiness.enabled:
        # hosted mode is off; this command should fall back to local-only
        ...
    if readiness.output_policy == OutputPolicy.MACHINE_OUTPUT:
        # JSON / quiet path; do not prompt; structured output only
        ...

Test Strategy

(Reproduced from spec for plan completeness.)

tests/readiness/test_coordinator_suppression_matrix.py

One parameterized test with the 7-row matrix plus one extra row for hosted-mode-enabled. For every row:

1. monkeypatch.delenv("SPEC_KITTY_ENABLE_SAAS_SYNC", raising=False) (or set to "1" for the enabled-mode row). 2. monkeypatch.setattr(sys, "argv", ["spec-kitty", *row.argv]). 3. monkeypatch.setattr(sys.stdout, "isatty", lambda: row.isatty). 4. Set / unset CI env var per row. 5. Build a fake typer.Context with ctx.obj = None. 6. Capture sys.stdout and sys.stderr via pytest's capsys. 7. Call evaluate_readiness(ctx) and assert:

  • result.enabled == row.expected_enabled
  • result.output_policy == row.expected_policy
  • "teamspace" not in captured.out.lower()
  • "teamspace" not in captured.err.lower()

tests/readiness/test_coordinator_caching.py

  • A: With SPEC_KITTY_ENABLE_SAAS_SYNC=1, monkeypatch _render_nag_if_needed to a counter spy. Call evaluate_readiness(ctx) twice. Assert spy was called exactly once and both results are the same instance.
  • B: With SPEC_KITTY_ENABLE_SAAS_SYNC unset, same spy. Two calls → spy called once.
  • C: After evaluate_readiness(ctx), get_readiness(ctx) is result.
  • D: Fresh ctx (no ctx.obj): get_readiness(ctx) returns _NOOP_DISABLED, does not raise.
  • E: ctx.obj = SomeNonDictObject(): get_readiness(ctx) returns _NOOP_DISABLED, does not raise.

tests/readiness/test_coordinator_nag_passthrough.py

  • A: Mock specify_cli.compat.plan to return an ALLOW_WITH_NAG decision with a known rendered_human. Invoke evaluate_readiness(ctx) with hosted mode unset and sys.stderr.isatty=True. Assert the nag string appears on stderr.
  • B: Same but with --json in sys.argv. Assert the nag string does NOT appear on stderr.
  • C: Mock specify_cli.compat.plan to raise. Assert evaluate_readiness(ctx) does NOT raise (preserves existing exception swallowing inside _render_nag_if_needed).

Existing tests unchanged

tests/cli_gate/test_ci_determinism.py exercises _render_nag_if_needed directly. No changes required. Acceptance criterion #5 asserts it still passes on the mission branch.

Risks (re-stated from spec)

  • R1 — call-site move regression: mitigated by test file 3.
  • R2 — ctx.obj contention: distinct keys; defensive non-dict handling.
  • R3 — module-import cost: _auth_recovery import is cheap (stdlib-only at top level); coordinator overhead bounded by NFR-001.
  • R4 — subcommand re-fires root callback: once-per-ctx cache is the safety net.

Operating Rules Applied

  • Pre-launch hidden mode → FR-003, C-002.
  • Suppression contract → FR-004, FR-011, test file 1.
  • No SaaS DB / queue / readiness mutation → C-004.
  • Canonical producers → C-007 (probably no producers in this mission).
  • No new pip deps → C-001.
  • spec-kitty next only entry point → C-008.
  • No direct push to main → C-010, gates Phase 9.

Branch Contract (re-stated)

  • Current branch at plan close: kitty/mission-cli-startup-readiness-coordinator-skeleton-01KS7JRV
  • Planning/base branch: kitty/mission-cli-startup-readiness-coordinator-skeleton-01KS7JRV (this mission's lane branch)
  • Final merge target for the PR: main
  • branch_matches_target: yes (mission branch matches itself for planning; PR will target main)

Next: /spec-kitty.tasks.