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.objkeying convention, the suppression-decision signal set, and the lazy-import strategy for the_auth_recoverystub seam. Recorded in this plan and inspec.mdAssumptions. - DIRECTIVE_010 — Specification Fidelity: This plan's file surfaces and acceptance criteria are 1:1 with
spec.mdFR-001 through FR-012 and the Acceptance Criteria section. Any deviation will be flagged in/spec-kitty.analyzeand 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_eventspydantic models. CI enforces viascripts/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 setsctx.obj = {}then writes. - If
ctx.objis a dict: writes the key. - If
ctx.objis a non-dict, non-None object: skips caching (defensive).get_readinessreturns_NOOP_DISABLEDin 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
OutputPolicyis 3-bucket (vs. boolean) — WS2 and WS3 will distinguish CI from JSON output. - Why the
_auth_recoveryimport is module-scope (mypy --strict signal) vs. function-scope (import-cost minimization). - Why we keep
_render_nag_if_neededat its current location and don't move it into the coordinator (C-005 says it must remain importable fromspecify_cli.cli.helpers). - Why
ctx.objkeying 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.objkey 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_enabledresult.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_neededto a counter spy. Callevaluate_readiness(ctx)twice. Assert spy was called exactly once and both results are the same instance. - B: With
SPEC_KITTY_ENABLE_SAAS_SYNCunset, same spy. Two calls → spy called once. - C: After
evaluate_readiness(ctx),get_readiness(ctx) is result. - D: Fresh
ctx(noctx.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.planto return anALLOW_WITH_NAGdecision with a knownrendered_human. Invokeevaluate_readiness(ctx)with hosted mode unset andsys.stderr.isatty=True. Assert the nag string appears on stderr. - B: Same but with
--jsoninsys.argv. Assert the nag string does NOT appear on stderr. - C: Mock
specify_cli.compat.planto raise. Assertevaluate_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.objcontention: distinct keys; defensive non-dict handling. - R3 — module-import cost:
_auth_recoveryimport 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 nextonly 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 targetmain)
Next: /spec-kitty.tasks.