Research: Remove hidden --feature alias from user-facing CLI commands

Mission: feature-alias-removal-01KW0N87 Phase: Plan — Phase 0 Research Branch: feat/feature-alias-removal Date: 2026-06-26


Summary

This document records the codebase audit performed during planning. All findings are derived from src/specify_cli/cli/commands/ in the clone at /tmp/sk-1797/m1060-feature-alias-removal. No design decisions remain open; the spec is fully self-answering for this scope.


Caller-Audit Evidence Table

Table 1 — In-Scope Files: Option Site, resolve_selector Calls, Rename Targets

File--feature Typer Option site (file:line)resolve_selector call(s) with alias_flag="--feature" (file:line)Internal feature/feature_slug params/vars to rename
implement.py:934 — `feature: Annotated[str\None, typer.Option("--feature", hidden=True, ...)] = None in implement()`None — uses detect_feature_context() / resolve_mission_handle directly
merge.py:412feature: str = typer.Option(None, "--feature", hidden=True, ...) in merge()None — uses (mission or feature or "").strip() directlyfeature param in merge() :412; feature param in _resolve_slug_or_exit() :231; feature param in _dispatch_abort() :277; feature param in _dispatch_resume() :328; resolved_feature local var in merge() :453+, in _run_real_merge() :350, and in _dispatch_resume()
next_cmd.py:71-74 — `feature: Annotated[str\None, typer.Option("--feature", hidden=True, ...)] = None in next()`:333 — inside private helper _resolve_mission_slug(mission, feature, repo_root)
research.py:33-38 — `feature: str\None = typer.Option(None, "--feature", hidden=True, ...) in research()`:67 — direct call, result unwrapped with .canonical_value
context.py:244 — `feature: Annotated[str\None, typer.Option("--feature", hidden=True, ...)] = None in mission_resolve_command()`:269 — direct call, result unwrapped with .canonical_value
accept.py:231-236 — `feature: str\None = typer.Option(None, "--feature", hidden=True, ...) in accept()`None — uses raw_handle = mission or feature then resolve_mission_handle
lifecycle.py:169 — `feature: str\None = typer.Option(None, "--feature", hidden=True, ...) in plan(); :258 — same pattern in tasks()`:176 — in plan() inside if mission is not None or feature is not None: block; :266 — in tasks() inside if mission is not None or feature is not None: block
mission_type.py:207-210 — `feature: Annotated[str\None, typer.Option("--feature", hidden=True, ...)] = None in current_cmd()`:246 — inside if mission is None and feature is None: else: branch

Table 2 — resolve_selector Callers Repo-Wide: In-Scope vs Out-of-Scope Classification

resolve_selector is defined at src/specify_cli/cli/selector_resolution.py:123.

Caller fileCall site (file:line)alias_flag valueClassificationAction
research.py:67"--feature"IN-SCOPERemove call; replace with inline whitespace-normalization guard
next_cmd.py:333"--feature"IN-SCOPERemove call inside _resolve_mission_slug(); inline guard
context.py:269"--feature"IN-SCOPERemove call; inline guard
lifecycle.py:176"--feature"IN-SCOPE (in plan())Remove call; inline guard
lifecycle.py:266"--feature"IN-SCOPE (in tasks())Remove call; inline guard
mission_type.py:246"--feature"IN-SCOPE (in current_cmd())Remove call; inline guard
lifecycle.py:136"--mission"OUT-OF-SCOPE — aliases --mission-type--mission; unrelated to --featureUntouched
charter/generate.py:276"--mission"OUT-OF-SCOPE — aliases --mission-type--missionUntouched
charter/interview.py:92"--mission"OUT-OF-SCOPE — aliases --mission-type--missionUntouched
agent/mission_create.py:123"--mission"OUT-OF-SCOPE — aliases --mission-type--missionUntouched

Key finding: Every out-of-scope resolve_selector caller uses alias_flag="--mission" (aliasing --mission-type), NOT "--feature". They are completely unrelated to this mission's change. resolve_selector itself is retained (C-005).

materialize.py:64 and verify.py:65 mention resolve_selector() in comments only — no live calls.


Table 3 — _legacy_aliases.py Absence Audit

CheckResult
find src/ -name "_legacy_aliases.py"File does not exist — confirmed absent
grep -rn "_legacy_aliases" src/No matches

FR-005: confirmed absent at planning time. Implementation must re-confirm at WP execution time.


Table 4 — Stored JSON feature_slug Keys (Immutable per C-003)

The following source locations READ "feature_slug" as a dict key from persisted artifacts (meta.json, status.events.jsonl, feature-runs.json). These string constants MUST NOT be renamed.

FileUsage
status/validate.py:91if "mission_slug" not in event and "feature_slug" not in event:
status/models.py:253,308data.get("mission_slug") or data.get("feature_slug", "")
status/store.py:269raw.get("mission_slug") or raw.get("feature_slug") or ""
identity/aliases.py:21-22Back-compat alias enrichment from feature_slugmission_slug
charter_activate.py:197meta.get("mission_slug") or meta.get("feature_slug")
retrospective/summary.py:198meta.get("mission_slug") or meta.get("feature_slug")
migration/mission_state.py:241,248,251,1113Migration constants and legacy alias table
upgrade/migrations/m_2_0_6_consistency_sweep.py:324Migration data field comparison
audit/shape_registry.py:51,69,162Back-compat field in audit shape definitions
audit/detectors.py:32Back-compat field in detector definitions
sync/batch.py:106Field name in sync batch processor
cli/commands/tracker.py:104data.get("feature_slug") or data.get("slug")

These are all in files outside the 8 in-scope files and are reading persisted data schemas. None of these will be touched by this mission.

The feature_slug parameter names in accept.py's helpers (_spec_artifact_dirty_paths, _commit_residual_acceptance_artifacts) are Python variable names (not JSON key strings) — they are in scope for rename per FR-002 and Assumption 5.


Design Decisions

D-01 — Inline guard pattern (replaces resolve_selector alias branch)

Decision: After removing the alias_value/alias_flag arguments, replace each resolve_selector call with a two-line inline guard:

mission_norm = mission.strip() if isinstance(mission, str) else None
if not mission_norm:
    raise typer.BadParameter("--mission <slug> is required")
mission_slug = mission_norm

Rationale: typer.BadParameter produces exit code 2 natively (satisfying SC-003), formats a clean user-facing message ("Error: Invalid value for '--mission': …"), and requires no new imports. The isinstance(str) guard replicates _normalize_selector's OptionInfo-sentinel protection (the PR #1985 adversarial finding that caused TypeError). Two lines; complexity-neutral (NFR-003).

Rejected alternative: Passing alias_value=None to resolve_selector — leaves dead arguments in place and makes the alias-free path non-obvious. Spec FR-006 says "prefer inlining the simplified guard."

Rejected alternative: Calling require_explicit_feature() directly — it raises ValueError, not typer.BadParameter, requiring an extra wrapping except ValueError block.

D-02 — Exit code standardization on no-selector

Decision: Standardize all 8 commands to exit code 2 on no-selector (SC-003).

Where this changes existing behavior:

BadParameter and lets Typer handle it naturally (exit 2).

  • accept.py:279 currently raises typer.Exit(1) — updated to typer.Exit(2) (or typer.BadParameter).
  • merge.py:503 currently raises typer.Exit(1) — updated to typer.Exit(2).
  • research.py currently converts BadParameter to typer.Exit(1) — the new inline guard raises

D-03 — resolve_selector import cleanup

Decision: Remove from specify_cli.cli.selector_resolution import resolve_selector imports from the 6 files that no longer call it: research.py, next_cmd.py, context.py, lifecycle.py (if lifecycle's specify() resolveSelector for --mission-type branch still needs it — see below), mission_type.py.

Special case — lifecycle.py: lifecycle.py still has resolve_selector in specify() at line 136 (aliasing --mission-type). The import stays. Only the alias_flag="--feature" call sites are removed.

D-04 — SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION env var

Decision: After this mission the env var becomes inert (no --feature warnings emitted anywhere in the 8 commands). Update docs/reference/environment-variables.md to note it is now unused. Do NOT delete the env var check from selector_resolution.py itself — that would be a change to an out-of-scope file. The variable is simply never triggered by the in-scope commands post-cleanup.

D-05 — lifecycle.specify positional featuremission rename (orchestrator ruling)

Decision: Rename positional param featuremission in lifecycle.specify(). CLI invocation is unchanged: spec-kitty lifecycle specify my-mission-name still works. The help metavar changes from FEATURE to MISSION (Typer auto-derives from param name). Call _slugify_feature_input(mission) after rename. The function _slugify_feature_input itself is not renamed (it has param value: str, not feature).

Rationale: Orchestrator ruling to remove the last feature positional in the lifecycle surface.

D-06 — Existing tests that assert merge keeps --feature

Decision: Update tests/contract/test_feature_alias_scope.py — flip the three merge-specific assertions: test_merge_still_accepts_feature_alias → now asserts exit 2 / "No such option"; test_merge_feature_and_mission_both_accepted → now asserts --feature is rejected, --mission works; test_merge_feature_alias_is_hidden_in_cli_introspection → now asserts merge has NO --feature param.

These tests were written for the previous mission's scope (where merge was "out of scope"). This mission brings merge in scope. NFR-001 requires test updates, not deletions.


Findings on Existing Test Infrastructure

Test fileCurrent behaviorAfter this mission
tests/contract/test_terminology_guards.pyINSCOPE_FEATURE_FREE_FILES has 10 filesExtend to 18 (add the 8 new files)
tests/contract/test_feature_alias_scope.pyAsserts merge/next/implement still have --featureUpdate: merge/next/implement are now also removed; flip assertions for merge; update INSCOPE list
tests/specify_cli/cli/test_no_visible_feature_alias.pytest_every_feature_flag_is_hidden passes vacuouslyAdd test_zero_feature_flags_exist asserting 0 --feature params CLI-wide
tests/integration/test_legacy_feature_alias.pytest_no_unhidden_feature_typer_options_in_commands_tree passesStill passes (no --feature options at all)