Contracts

deprecation_warning.md

Contract: Deprecation Warning Format and Suppression

Mission: 077-mission-terminology-cleanup Surface: stderr output of src/specify_cli/cli/selector_resolution.py::_emit_deprecation_warning Owner: Scope A (#241), specifically WPA4

Warning Text Format

The deprecation warning is emitted to stderr via rich.console.Console(stderr=True) and uses the existing precedent at src/specify_cli/cli/commands/agent/mission.py:604 exactly:

Warning: <alias_flag> is deprecated; use <canonical_flag>. See: docs/migration/feature-flag-deprecation.md

When rendered through Rich with [yellow]Warning:[/yellow], the visible output is:

Warning: --feature is deprecated; use --mission. See: docs/migration/feature-flag-deprecation.md

(The "Warning:" prefix is yellow; the rest is default.)

For the inverse direction (FR-021), the same format applies:

Warning: --mission is deprecated; use --mission-type. See: docs/migration/mission-type-flag-deprecation.md

Implementation

import os
from rich.console import Console

_warned: set[tuple[str, str]] = set()
_err_console = Console(stderr=True)

def _emit_deprecation_warning(
    canonical_flag: str,
    alias_flag: str,
    suppress_env_var: str,
) -> bool:
    """Emit a single yellow stderr deprecation warning unless suppressed.

    Returns True if a warning was actually emitted, False if suppressed
    or if a warning has already been emitted for this (canonical, alias)
    pair in the current process.
    """
    pair = (canonical_flag, alias_flag)
    if pair in _warned:
        return False
    if os.environ.get(suppress_env_var) == "1":
        return False
    _warned.add(pair)
    doc_path = _doc_path_for(alias_flag)
    _err_console.print(
        f"[yellow]Warning:[/yellow] {alias_flag} is deprecated; "
        f"use {canonical_flag}. See: {doc_path}"
    )
    return True

def _doc_path_for(alias_flag: str) -> str:
    """Map an alias flag to its migration doc path."""
    return {
        "--feature": "docs/migration/feature-flag-deprecation.md",
        "--mission": "docs/migration/mission-type-flag-deprecation.md",
    }[alias_flag]

Suppression Env Vars

Env varDirectionSet toEffect
SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION--feature--mission"1"Skips the warning emit. The helper still resolves the value and still raises BadParameter on conflict.
SPEC_KITTY_SUPPRESS_MISSION_TYPE_DEPRECATION--mission--mission-type"1"Same.

Default behavior: when the env var is not set or is set to anything other than "1", the warning is emitted normally. There is no 0 or false value handling — the env var is strictly opt-in to suppression.

Visibility: both env vars are documented in the same migration doc that the warning links to. They are intentionally verbose and direction-specific so a CI maintainer who needs to suppress one cannot accidentally suppress the other.

Single-Warning-Per-Invocation Guarantee (NFR-002)

The _warned set is module-level. It accumulates (canonical_flag, alias_flag) pairs across all calls within a single Python process. A long-running CLI session that invokes the same deprecated alias multiple times sees the warning only once per direction.

Verified by tests/specify_cli/cli/commands/test_selector_resolution.py::test_warning_emitted_only_once_per_pair.

The set is reset between test cases by the autouse fixture defined in data-model.md.

Migration Documentation

This mission must publish two short migration doc pages:

1. docs/migration/feature-flag-deprecation.md — explains why --feature is being removed, when it will be removed (named conditions only, per spec §15 Q1), how to migrate scripts, and how to suppress the warning during cutover. 2. docs/migration/mission-type-flag-deprecation.md — same shape for the inverse-drift case.

Both docs link back to:

  • The spec at kitty-specs/077-mission-terminology-cleanup/spec.md
  • The ADR at architecture/2.x/adr/2026-04-04-2-mission-type-mission-and-mission-run-terminology-boundary.md
  • The initiative at architecture/2.x/initiatives/2026-04-mission-nomenclature-reconciliation/README.md

The exact content of these docs is finalized in WPA9.

What This Contract Does NOT Specify

  • The Rich color (yellow) is fixed by the agent/mission.py:604 precedent. Implementers should not change it.
  • The exact migration doc paths above are committed contracts. If they need to change, both this contract and the warning text must be updated together in the same PR.
  • The warning does not include a date for removal. Per spec §15 Q1, the removal is gated on named conditions, not a date. Adding a date would violate that decision.

Test Coverage

Test caseWhat it asserts
test_warning_text_formatThe exact stderr line matches the format above for both directions.
test_warning_emitted_only_once_per_pairTwo calls with the same pair produce one stderr line.
test_warning_emitted_again_for_different_pairTwo calls with different pairs produce two stderr lines.
test_suppression_env_var_skips_warningWith env var set to "1", no stderr line is produced.
test_suppression_env_var_only_responds_to_oneWith env var set to "0" or "false" or "true" or unset, the warning IS emitted. Only "1" suppresses.
test_inverse_suppression_env_var_independentSetting SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION=1 does not suppress the inverse direction's warning.

grep_guards.md

Contract: CI Grep Guards for Terminology Drift

Mission: 077-mission-terminology-cleanup Surface: tests/contract/test_terminology_guards.py (new file) Owner: Scope A (#241), specifically WPA6 + WPA7 Authority: FR-022, C-011, spec §12.2

Purpose

These grep guards exist to prevent the canonical terminology drift from returning. They are not historical-artifact rewriters: they must not scan kitty-specs/ (mission specs and history) or architecture/ (ADRs and initiative records). Historical artifacts are append-only history; rewriting them is forbidden by C-011.

The guards run in CI as part of the standard pytest suite. When a future PR re-introduces a forbidden pattern in a live first-party surface, the guard fires and the build fails.

Scope

Per spec FR-022, the guards scan live first-party surfaces including the top-level project files. Historical artifacts and runtime state are explicitly excluded.

PathScanned?Why
src/specify_cli/cli/commands/*/.pyYesLive CLI command surface
src/specify_cli/orchestrator_api/*/.pyYes (read-only verification)Verifies orchestrator-api stays canonical (C-010)
src/doctrine/skills/*/.mdYesLive doctrine skills
docs/*/.mdYesLive first-party docs of every kind; docs/migration/** is excluded separately
README.md (top-level)YesLive primary user-facing doc — explicitly in scope per FR-022
CONTRIBUTING.md (top-level)YesLive contributor doc — explicitly in scope per FR-022
CHANGELOG.md (top-level)Partial — only the "Unreleased" section above the first ## [<version>] headingHistorical version entries are excluded by FR-022's "CHANGELOG-style historical entries" carve-out. The "Unreleased" section is live; everything below the first version heading is frozen history.
docs/migration/*/.mdNoMigration docs are required to mention the deprecated flags by name (this is where the warning text points)
kitty-specs/**NoHistorical mission artifacts; FR-022, C-011
architecture/**NoHistorical ADRs and initiatives; FR-022, C-011
.kittify/**NoRuntime state
tests/**NoTests legitimately mention forbidden patterns to assert against them
kitty-specs/077-mission-terminology-cleanup/**NoThis mission's own artifacts mention forbidden patterns by necessity

Guard Definitions

Guard 1: No --mission-run for tracked-mission selection in live CLI command files

def test_no_mission_run_alias_in_tracked_mission_selectors():
    """Live CLI command files must not declare --mission-run as an alias for tracked-mission selection.

    Authority: FR-002, FR-003, spec §12.2.
    Excludes runtime/session command files (which may legitimately accept --mission-run).
    """
    # Pseudocode:
    for path in glob("src/specify_cli/cli/commands/**/*.py"):
        if "runtime" in path or "session" in path:
            continue  # Runtime/session commands legitimately use --mission-run
        content = read(path)
        # Match: typer.Option(..., "--mission-run", ...) where "--mission-run" is not the canonical primary
        # Allow only if the surrounding parameter's name implies runtime/session context.
        for match in re.finditer(r'typer\.Option\([^)]*"--mission-run"[^)]*\)', content):
            param_context = _surrounding_param_name(content, match)
            if not _is_runtime_session_param(param_context):
                fail(f"{path}: --mission-run used as tracked-mission selector alias at offset {match.start()}")

Guard 2: No "Mission run slug" in tracked-mission CLI help text

def test_no_mission_run_slug_help_text_in_cli_commands():
    """Live CLI command help text must not say 'Mission run slug' for tracked-mission selectors.

    Authority: FR-008, spec §12.2.
    """
    for path in glob("src/specify_cli/cli/commands/**/*.py"):
        content = read(path)
        if "Mission run slug" in content:
            fail(f"{path}: contains 'Mission run slug' help text; tracked-mission selectors must say 'Mission slug'")

Guard 3: No visible --feature declarations in CLI command files

def test_no_visible_feature_alias_in_cli_commands():
    """--feature is acceptable only as a hidden=True alias.

    Authority: Charter §Terminology Canon hyper-vigilance rules,
    spec §11.1 (hidden deprecated alias), Charter Reconciliation in plan.md.
    """
    for path in glob("src/specify_cli/cli/commands/**/*.py"):
        content = read(path)
        for option_block in _iter_typer_option_blocks(content):
            if '"--feature"' not in option_block:
                continue
            if "hidden=True" not in option_block:
                fail(f"{path}: --feature declared without hidden=True; charter requires hidden secondary alias only")

Guard 4: No --mission-run instructions in live doctrine skills

def test_no_mission_run_instructions_in_doctrine_skills():
    """Doctrine skills must teach --mission for tracked-mission selection.

    Authority: FR-009, spec §12.2.
    Scope: src/doctrine/skills/**/*.md only.
    Excludes: kitty-specs/**, architecture/**, .kittify/**, this mission's own artifacts.
    """
    forbidden_patterns = [
        r"--mission-run\s+\d{3}",  # --mission-run 077-foo (tracked-mission slug pattern)
        r"--mission-run\s+<slug>",
        r"--mission-run\s+<mission",
    ]
    for path in glob("src/doctrine/skills/**/*.md"):
        content = read(path)
        for pattern in forbidden_patterns:
            for match in re.finditer(pattern, content):
                fail(f"{path}: doctrine skill instructs --mission-run for tracked-mission selection at offset {match.start()}")

Guard 5: No --mission-run instructions in live agent-facing docs

def test_no_mission_run_instructions_in_agent_facing_docs():
    """Live docs must teach --mission for tracked-mission selection.

    Authority: FR-010, FR-022, spec §12.2.
    Scope: docs/**, top-level README.md, top-level CONTRIBUTING.md, and the
    Unreleased section of top-level CHANGELOG.md.
    EXCLUDES: docs/migration/** (migration docs may name --feature and
    --mission-run by necessity).
    """
    forbidden_patterns = [
        r"--mission-run\s+\d{3}",
        r"--mission-run\s+<slug>",
        r"--mission-run\s+<mission",
    ]
    scan_targets: list[Path] = []
    for glob_pattern in ["docs/**/*.md"]:
        scan_targets.extend(_glob(glob_pattern))
    scan_targets = [
        path for path in scan_targets
        if not path.relative_to(REPO_ROOT).as_posix().startswith("docs/migration/")
    ]
    for top_level in ["README.md", "CONTRIBUTING.md"]:
        path = REPO_ROOT / top_level
        if path.exists():
            scan_targets.append(path)
    # CHANGELOG.md: only the Unreleased section above the first version header.
    changelog_path = REPO_ROOT / "CHANGELOG.md"
    if changelog_path.exists():
        scan_targets.append(("changelog-unreleased", changelog_path))

    for target in scan_targets:
        if isinstance(target, tuple) and target[0] == "changelog-unreleased":
            content = _extract_changelog_unreleased(target[1])
            path = target[1]
        else:
            path = target
            content = _read(path)
        for pattern in forbidden_patterns:
            for match in re.finditer(pattern, content):
                fail(f"{path}: instructs --mission-run for tracked-mission selection at offset {match.start()}")

Guard 5b: No live --feature instructions in first-party docs

def test_no_feature_flag_in_live_first_party_docs():
    """Live first-party docs must not document --feature as a
    current/canonical CLI option.

    Authority: FR-005, FR-022, charter §Terminology Canon hyper-vigilance.
    Scope: docs/**, top-level README.md, top-level CONTRIBUTING.md, and the
    Unreleased section of top-level CHANGELOG.md.
    EXCLUDES: docs/migration/** (migration docs name --feature by necessity)
    and historical version sections of CHANGELOG.md.

    A future PR that legitimately needs to mention --feature in a live top-level
    doc (e.g., to point users at the migration doc) must do so by linking to
    docs/migration/feature-flag-deprecation.md, not by documenting --feature
    as a usable option. The guard fails on raw `--feature <slug>`,
    `--feature ` followed by a slug-like token, and `--feature` inside an
    Options table column.
    """
    forbidden_patterns = [
        r"--feature\s+<slug>",
        r"--feature\s+\d{3}",
        r"--feature\s+[a-z][a-z0-9-]*",  # `--feature` followed by a slug-like token
        r"\|\s*`--feature[\s|<>`]",       # `--feature` cell in a markdown options table
    ]
    scan_targets: list[Path | tuple[str, Path]] = []
    for path in _glob("docs/**/*.md"):
        if path.relative_to(REPO_ROOT).as_posix().startswith("docs/migration/"):
            continue
        scan_targets.append(path)
    for top_level in ["README.md", "CONTRIBUTING.md"]:
        path = REPO_ROOT / top_level
        if path.exists():
            scan_targets.append(path)
    changelog_path = REPO_ROOT / "CHANGELOG.md"
    if changelog_path.exists():
        scan_targets.append(("changelog-unreleased", changelog_path))

    for target in scan_targets:
        if isinstance(target, tuple) and target[0] == "changelog-unreleased":
            content = _extract_changelog_unreleased(target[1])
            path = target[1]
        else:
            path = target
            content = _read(path)
        for pattern in forbidden_patterns:
            for match in re.finditer(pattern, content):
                snippet = content[max(0, match.start() - 30):match.end() + 30]
                fail(
                    f"{path}: documents --feature as a live CLI option at "
                    f"offset {match.start()}: {snippet!r}\n"
                    f"Authority: spec §11.1, charter §Terminology Canon. "
                    f"Replace with --mission or link to docs/migration/feature-flag-deprecation.md."
                )

Helper: extract the Unreleased section from CHANGELOG.md

def _extract_changelog_unreleased(path: Path) -> str:
    """Return the portion of CHANGELOG.md above the first `## [<version>]` heading.

    The `## [Unreleased]` section (if present) and any preamble are returned;
    historical version entries are excluded per FR-022.
    """
    content = _read(path)
    # Match `## [<version>]` where <version> looks like 1.2.3 or 1.2.3a4
    match = re.search(r"^## \[\d+\.\d+\.\d+", content, flags=re.MULTILINE)
    if match is None:
        # No version headings yet — the whole file is "unreleased"
        return content
    return content[: match.start()]

Guard 6: Inverse drift — --mission not used to mean blueprint/template

def test_no_mission_used_to_mean_mission_type_in_cli_commands():
    """CLI command files must not declare --mission with help text that names a mission type.

    Authority: FR-021, spec §8.1.2, spec §12.2.
    The check is heuristic: if a typer.Option declares --mission and its help string
    contains 'mission type' or 'mission key', that is the inverse-drift bug. Such
    sites must instead declare --mission-type as canonical with --mission as a
    hidden alias.
    """
    for path in glob("src/specify_cli/cli/commands/**/*.py"):
        content = read(path)
        for option_block in _iter_typer_option_blocks(content):
            if '"--mission"' not in option_block:
                continue
            help_text = _extract_help(option_block)
            if any(phrase in help_text.lower() for phrase in ["mission type", "mission key"]):
                if '"--mission-type"' not in option_block:
                    fail(f"{path}: --mission declared with mission-type semantics but no --mission-type canonical parameter present")

Guard 7: Orchestrator-api envelope width unchanged (read-only verification)

def test_orchestrator_api_envelope_width_unchanged():
    """The orchestrator-api 7-key envelope must not be widened by this or any future mission.

    Authority: C-010, spec §10.1 item 10.

    The expected key set is verified against the canonical implementation at
    src/specify_cli/orchestrator_api/envelope.py::make_envelope at HEAD
    35d43a25 (validated baseline 54269f7c). If make_envelope is intentionally
    changed in a future PR, this guard's expected_keys must be updated in the
    SAME PR after a documented C-010 amendment.
    """
    from specify_cli.orchestrator_api.envelope import make_envelope
    envelope = make_envelope("test-cmd", success=True, data={})
    expected_keys = {
        "contract_version",
        "command",
        "timestamp",
        "correlation_id",
        "success",
        "error_code",
        "data",
    }
    assert set(envelope.keys()) == expected_keys, (
        f"Orchestrator-api envelope keys must remain exactly {expected_keys}; "
        f"got {set(envelope.keys())}. C-010 forbids widening."
    )
    # Reinforce the count check too, in case a future change replaces a key
    # rather than adding one (still a contract change that needs review).
    assert len(envelope) == 7, (
        f"Orchestrator-api envelope must remain exactly 7 keys; got {len(envelope)}."
    )

Guard 8: Historical-artifact safety check (the meta-guard)

def test_grep_guards_do_not_scan_historical_artifacts():
    """Verify the grep guards' scope excludes historical artifacts.

    Authority: FR-022, C-011.
    This test fails if any of the guard functions in this file accidentally
    scan kitty-specs/, architecture/, .kittify/, or CHANGELOG.md.

    Implementation: introspect the path globs declared at the top of each guard
    function and assert none of them resolve into the forbidden roots.
    """
    forbidden_roots = ["kitty-specs/", "architecture/", ".kittify/", "CHANGELOG.md"]
    # Inspect each guard's declared globs and fail if any glob would match a forbidden path.

File Structure

# tests/contract/test_terminology_guards.py
"""CI grep guards for canonical terminology drift.

These guards prevent the Mission Type / Mission / Mission Run terminology
boundary from drifting back to legacy selector vocabulary. They are scoped
to LIVE first-party surfaces only.

EXPLICITLY DOES NOT SCAN (per FR-022, C-011):
  - kitty-specs/** (historical mission artifacts)
  - architecture/** (historical ADRs and initiative records)
  - .kittify/** (runtime state)
  - tests/** (tests legitimately mention forbidden patterns)
  - CHANGELOG.md (historical change log)
  - docs/migration/** (migration docs explain the deprecation by name)

Authority:
  - spec.md FR-022, C-010, C-011
  - spec.md §12.2 Documentation and Skill Tests
  - charter.md §Terminology Canon hyper-vigilance rules
"""
import re
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]

# ---------- Helpers ----------

def _glob(pattern: str) -> list[Path]:
    return list(REPO_ROOT.glob(pattern))

def _read(path: Path) -> str:
    return path.read_text(encoding="utf-8")

def _iter_typer_option_blocks(content: str):
    """Yield each typer.Option(...) call's text."""
    # ... implementation ...

# ---------- Guards ----------

def test_no_mission_run_alias_in_tracked_mission_selectors():
    ...

def test_no_mission_run_slug_help_text_in_cli_commands():
    ...

def test_no_visible_feature_alias_in_cli_commands():
    ...

def test_no_mission_run_instructions_in_doctrine_skills():
    ...

def test_no_mission_run_instructions_in_agent_facing_docs():
    ...

def test_no_feature_flag_in_live_top_level_docs():
    ...

def test_no_mission_used_to_mean_mission_type_in_cli_commands():
    ...

def test_orchestrator_api_envelope_width_unchanged():
    ...

def test_grep_guards_do_not_scan_historical_artifacts():
    ...

The full guard count is 9 test functions (the original 8 plus Guard 5b for top-level project docs).

Failure Messages

Every guard's failure message must: 1. Name the file path that triggered the failure. 2. Name the offset or line number of the offending content. 3. Cite the FR or C number from the spec that the failure violates. 4. Suggest the canonical replacement.

Example:

FAILED tests/contract/test_terminology_guards.py::test_no_visible_feature_alias_in_cli_commands

src/specify_cli/cli/commands/agent/tasks.py:842: --feature declared without hidden=True

Authority: spec.md FR-005, charter §Terminology Canon hyper-vigilance.
Fix: declare --feature as a separate typer.Option with hidden=True, then call
resolve_selector() from the function body. See contracts/selector_resolver.md
for the canonical pattern.

What These Guards Do NOT Do

  • They do not modify any file. They are read-only.
  • They do not auto-fix. A human (or a future agent under explicit instruction) must fix flagged sites.
  • They do not enforce documentation completeness. They only enforce that if a doc mentions tracked-mission selection, it does so with canonical vocabulary.
  • They do not scan historical artifacts. C-011 forbids this. Guard 8 verifies the scope is correct.

Maintenance

Future PRs that legitimately need to introduce a new pattern (e.g., a new alias for a different reason) must update both the guard and the contract test in the same PR. Reviewers should treat any PR that adds an # noqa or skip marker on these guards with extreme suspicion — the whole point is to prevent silent drift.

selector_resolver.md

Contract: Selector Resolution Helper

Mission: 077-mission-terminology-cleanup Surface: src/specify_cli/cli/selector_resolution.py (new module) Owner: Scope A (#241) Test file: tests/specify_cli/cli/commands/test_selector_resolution.py (new file)

Module Structure

src/specify_cli/cli/selector_resolution.py
├── SelectorResolution             (frozen dataclass; see data-model.md)
├── resolve_selector(...)          (public helper)
├── _emit_deprecation_warning(...) (private sub-helper)
└── _warned: set[tuple[str, str]]  (module-level state for NFR-002)

Public API

resolve_selector

def resolve_selector(
    *,
    canonical_value: str | None,
    canonical_flag: str,
    alias_value: str | None,
    alias_flag: str,
    suppress_env_var: str,
    command_hint: str | None = None,
) -> SelectorResolution:
    ...

Behavioral contract:

Input caseReturnsSide effectsRaises
Both None or emptytyper.BadParameter via require_explicit_feature(None, command_hint=command_hint)
Only canonical_value setSelectorResolution(canonical_value, canonical_flag, alias_used=False, alias_flag=None, warning_emitted=False)None
Only alias_value setSelectorResolution(canonical_value=alias_value.strip(), canonical_flag, alias_used=True, alias_flag, warning_emitted=<actual>)At most one yellow stderr deprecation warning per process per (canonical_flag, alias_flag) pair, suppressible via os.environ.get(suppress_env_var) == "1"
Both set, equal (after .strip())SelectorResolution(canonical_value=canonical_value.strip(), canonical_flag, alias_used=True, alias_flag, warning_emitted=<actual>)One deprecation warning (same suppression rule)
Both set, not equal (after .strip())Nonetyper.BadParameter with the conflict message format defined below

Conflict Error Format

Conflicting selectors: <canonical_flag>=<canonical_value!r> and <alias_flag>=<alias_value!r> were both provided with different values. <alias_flag> is a hidden deprecated alias for <canonical_flag>; pass only <canonical_flag>.

Example for mission current --mission A --feature B:

Conflicting selectors: --mission='A' and --feature='B' were both provided with different values. --feature is a hidden deprecated alias for --mission; pass only --mission.

Example for agent mission create new-thing --mission-type software-dev --mission research (inverse direction):

Conflicting selectors: --mission-type='software-dev' and --mission='research' were both provided with different values. --mission is a hidden deprecated alias for --mission-type; pass only --mission-type.

The format is verified by tests/specify_cli/cli/commands/test_selector_resolution.py::test_conflict_error_format.

Call-Site Pattern

Every tracked-mission command in the main CLI follows this pattern. Two separate typer parameters; one helper call in the function body.

Tracked-Mission Command (Scope A — direction: --feature is the alias)

import typer
from specify_cli.cli.selector_resolution import resolve_selector

@app.command("current")
def current_cmd(
    mission: Annotated[str | None, typer.Option(
        "--mission",
        help="Mission slug",
    )] = None,
    feature: Annotated[str | None, typer.Option(
        "--feature",
        hidden=True,  # ← KEY: charter compliance
        help="(deprecated) Use --mission",
    )] = None,
) -> None:
    """Show the active mission type for a mission."""
    resolved = resolve_selector(
        canonical_value=mission,
        canonical_flag="--mission",
        alias_value=feature,
        alias_flag="--feature",
        suppress_env_var="SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION",
        command_hint="--mission <slug>",
    )
    mission_slug = resolved.canonical_value
    # ... rest of command logic uses mission_slug ...

Inverse-Drift Command (Scope A — direction: --mission is the alias)

import typer
from specify_cli.cli.selector_resolution import resolve_selector

@app.command(name="create")
def create_mission(
    mission_slug: Annotated[str, typer.Argument(help="Mission slug (e.g., 'user-auth')")],
    mission_type: Annotated[str | None, typer.Option(
        "--mission-type",
        help="Mission type (e.g., 'documentation', 'software-dev')",
    )] = None,
    mission: Annotated[str | None, typer.Option(
        "--mission",
        hidden=True,
        help="(deprecated) Use --mission-type",
    )] = None,
    json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
    target_branch: Annotated[str | None, typer.Option("--target-branch", help="Target branch (defaults to current branch)")] = None,
) -> None:
    """Create new mission directory structure in the project root checkout."""
    resolved = resolve_selector(
        canonical_value=mission_type,
        canonical_flag="--mission-type",
        alias_value=mission,
        alias_flag="--mission",
        suppress_env_var="SPEC_KITTY_SUPPRESS_MISSION_TYPE_DEPRECATION",
        command_hint="--mission-type <name>",
    )
    mission_type_value = resolved.canonical_value
    # ... rest of command logic uses mission_type_value ...

Required Test Coverage

The contract test file tests/specify_cli/cli/commands/test_selector_resolution.py must cover all of the following cases for resolve_selector directly, plus one integration test per direction via typer.testing.CliRunner:

Unit Tests (helper, in isolation)

1. test_canonical_only_returns_value — only canonical_value set, returns it, alias_used=False, no warning. 2. test_alias_only_returns_canonical_value — only alias_value set, returns it, alias_used=True, warning emitted. 3. test_both_equal_returns_value_with_warning — both set, equal, returns value, alias_used=True, warning emitted. 4. test_both_different_raises_bad_parameter — both set, different, raises typer.BadParameter with the exact conflict message format. 5. test_neither_raises_bad_parameter — both None, raises via require_explicit_feature. 6. test_both_empty_strings_raise_bad_parameter — both "", raises via require_explicit_feature (after .strip()). 7. test_canonical_whitespace_only_treated_as_nonecanonical_value=" ", alias_value="x", returns "x" with warning. 8. test_warning_emitted_only_once_per_pair — call helper twice with the same (canonical_flag, alias_flag), verify only one stderr line. 9. test_warning_emitted_again_for_different_pair — call with --feature then with --mission (different alias), verify two stderr lines. 10. test_suppression_env_var_skips_warning — set SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION=1, call with alias, verify no stderr line, but warning_emitted=False. 11. test_inverse_direction_works_identically — call with canonical_flag="--mission-type", alias_flag="--mission", suppress_env_var="SPEC_KITTY_SUPPRESS_MISSION_TYPE_DEPRECATION", verify all behaviors. 12. test_conflict_error_format — verify exact message text matches the format in this contract.

Integration Tests (full typer command, via CliRunner)

13. test_mission_current_canonical_succeedsmission current --mission 077-x resolves correctly. 14. test_mission_current_alias_succeeds_with_warningmission current --feature 077-x resolves and emits warning. 15. test_mission_current_dual_flag_conflict_failsmission current --mission A --feature B exits non-zero with conflict message. This is the regression test for the verified bug from spec §8.2. 16. test_agent_mission_create_canonical_succeedsagent mission create new --mission-type software-dev resolves correctly. 17. test_agent_mission_create_alias_succeeds_with_warningagent mission create new --mission software-dev resolves and emits warning. 18. test_agent_mission_create_dual_flag_conflict_failsagent mission create new --mission-type software-dev --mission research exits non-zero with conflict message.

Coverage Requirement

Per NFR-005, line coverage on src/specify_cli/cli/selector_resolution.py must be ≥ 90%. With the case list above, the helper should reach 100% line coverage.

Module-Level State Reset

The autouse fixture from data-model.md (the _reset_selector_resolution_state fixture) must be installed in tests/specify_cli/cli/commands/test_selector_resolution.py and in any other test file that calls the helper directly. Without it, test order affects warning emission and CI is flaky.

Constraints (from spec §7)

  • C-008: The helper must not introduce any abstraction that would make a future Mission → MissionRun rename "easier". Specifically: no aggregate-type indirection, no parallel field shadows, no abstract base classes for selector resolution.
  • C-010: The helper must live in src/specify_cli/cli/, not in src/specify_cli/orchestrator_api/. The orchestrator-api is not wired through this helper.
  • C-011: The helper's tests must not scan kitty-specs/ or architecture/.

Backwards Compatibility for Existing Call Sites

The existing function require_explicit_feature at src/specify_cli/core/paths.py:273 is not modified. It continues to be called by the new helper in the "missing value" path. All existing call sites of require_explicit_feature continue to work unchanged. WPA1 may identify call sites that should additionally be wired through resolve_selector, but the existing helper itself is unchanged.