✨ Added
- Coord/primary placement-partition lock — one topology-aware seam owns where
every mission artifact is stored and read (#1716, #1878/G2). Formalizes
PlacementSeam.write_target(kind)/read_dir(kind)as the single thin
authority over the existingresolve_action_contextSSOT and routes every
remaining write site through it — fail-closed viaPlacementResolutionRequired
(a real resolution failure raises rather than silently committing to the
operator's checkout). An architectural ratchet forbids the
CommitTarget(ref=<checkout>)grammar (a self-test proves it bites), and an
end-to-end characterization test locks the behaviour. Canonical partition:
coord branch = lifecycle (status/notes/trace/issue-matrix/move-task); primary
= stable planning (spec/plan/WP outlines); no-coordination topology → all
primary. RETROSPECTIVE delegates to the existingresolve_retrospective_home
(no second authority). Also fixes #2091 (an emptymid8no longer builds a
malformedkitty/mission-<slug>-coordination branch →git worktree add
exit-128; theCoordinationWorkspacecomposition seam fail-loud-guards it) and
#2250 (a never-coordinated mission no longer reports
COORDINATION_BRANCH_DELETED). The read side is now locked too (#1716
closeout): every remaining coordination-surface read — the kind-blind
resolve_feature_dir_for_missionsweep (#2453) and the inlinemeta.json
reads (#2100) — routes through the same topology-aware seam kind-correctly, so
spec-kitty acceptno longer reads a stale-coordacceptance matrix (#2404)
and planning artifacts always resolve to their canonical surface regardless of
the working directory. A non-vacuous architectural ratchet keeps the routed
read classes from silently regrowing. Closes epic #1716 and #2453 / #2100
/ #2088 / #2404. - Doctrine/charter pack paths support environment-variable indirection
(#2437). Pack-location fields (e.g. an org-pack'slocal_path) previously
had to be hardcoded absolute paths, so a shared, committed.kittifyconfig
resolved only on the machine of the developer who wrote it and broke for every
teammate and CI runner. Pack-path templates now expand${VAR}/$VARand~
at read time (viaos.path.expandvars+expanduser), so a portable form
like${SPEC_KITTY_PACK_HOME}/acme-doctrinecan be committed once and each
environment resolves it against its own base. Expansion is fail-closed: an
unset/empty env token raises rather than silently collapsing to a wrong path
(e.g.${UNSET}/acme→ repo root). Purely additive — literal absolute paths
keep working. - Review-time regression gate at
move-task --to for_review(#572). When a
work package moves tofor_review, Spec Kitty now auto-scopes the CI shards
that cover the WP's changed files and re-runs them, so a WP that broke a
shared contract pinned by a test outside itsowned_filesis caught at
review time instead of only at merge. Warn-only by default; opt in to a hard
block withreview.fail_on_pre_review_regression(enforced only when
review.test_commandis set;move-task --forceoverrides), and override the
scope per-WP via frontmatterpre_review_test_scope. See
review-gates.md. spec-kitty review --check-residual+ environment-parity preflight
(#2283). The new--check-residualflag runs CI's always-on
unit-contract-residual-mselection overtests/locally — the-m
expression is read live from.github/workflows/ci-quality.yml, so a
previously CI-only marker-orphan failure can be reproduced before pushing. The
spec-kitty reviewpreflight also detects local-vs-CI environment skew
(installed package versions diverging fromuv.lock, plus typer/click
lock-parity), warning by default and failing closed under
SPEC_KITTY_ENV_SKEW_FAIL_CLOSED.
🐛 Fixed
- Post-merge stale-assertion analyzer stops false-positive storms on
behavior-preserving refactors (#2031, #2343). A relocation/re-export (a
WP05 extraction produced 180 false findings and tripped the drift ceiling)
and generic-literal noise both flagged as stale assertions. The analyzer now
(a) suppresses a removed identifier only when the origin file's module-level
head still re-exports/imports it (keyed on head-importability — not
bare-name-anywhere, so a genuine deletion of a common name likerun/main
is still flagged; a nestedimport parsedoes not mask a realdef parse()
deletion), and (b) suppresses literal-only noise by genuineness (a pinned
generic-token set / all-punctuation), never by length (a short literal like
"E001"can be assert-critical). Both paths suppress rather than
info-downgrade, so render surfaces and the FP ceiling are unchanged. - Review-prompt files no longer accumulate unbounded; coverage-allowlist
repointed off a removed module (#2439, #2443).
write_review_prompt_with_metadata()now prunes review-prompt files to a
newest-preserving cap after each write — fail-safe, and never pruning the
current invocation's own file — closing the LC-7 retention residual of #1842.
Separately, thediff-coveragecritical-path--includeallowlist still
referenced the stalesrc/specify_cli/core/mission_detection.py(removed in a
rename); it is repointed tosrc/specify_cli/lanes/branch_naming.pyin both
the CI workflow and its test authority in lockstep, with a glob-aware existence
guard so a phantom allowlist entry reds instead of silently covering nothing. spec-kitty agent mission finalize-tasksaccepts glob-onlyowned_files
(#2446). A work package whoseowned_filesused a filename glob (e.g.
src/foo/*.py) with no explicitauthoritative_surfacewas rejected because
infer_authoritative_surfaceproduced an invalidsrc/foo/*.py/path. It now
reduces a glob-bearing final path segment to its directory, so glob-only
ownership infers a valid authoritative surface. (Fix landing via PR #2454.)- A retired skill's projected files are now drained by surface repair instead
of orphaned forever (#2409). When a skill was retired from the registry,
spec-kitty upgrade --projectcould not reconcile its leftover projections:
the repair path warnedCannot repair … skill not found in registryon every
subsequent upgrade and left the files in place (committed, where the repo
tracks skill surfaces; a dangling symlink, where the global root had dropped
the skill). Repair now reconciles retirements manifest-driven: entries in
.kittify/skills-manifest.jsonwhose skill no longer exists in a live
registry have their projected files removed — symlinks unlinked, hash-clean
copies deleted, user-modified copies archived to
.kittify/.migration-backup/agent-skills/instead of deleted — emptied skill
dirs pruned, and the manifest entries dropped, silencing the repeat warning.
Only ledger-recorded paths are ever touched (a user-authored skill sharing
the projection root is never scanned), and an empty registry never
retires anything (a broken canonical source must not mass-drain the
manifest). - Test-session state leaks closed: per-mission prompt-tmp namespace +
workspace-context tombstone (#1842, #2032). Runtime prompt files are now
written under a per-repo/per-mission namespaced temp directory
(<tmp>/spec-kitty-prompts/<repo-id>/…) instead of a shared flat/tmp
path, and a lane's workspace-context sidecar is now tombstoned when its
worktree is torn down at merge (or when the lane is canceled) — clearing the
stale-context and prompt-litter residue flagged by the #1931/tmp-hygiene
audit. A session-scoped pytest reaper (controller-gated, run-uid-scoped, and
never touching the real~/.spec-kitty) now fails the suite if a test leaks
repo-root residue, so new leaks are caught at their source. - Dashboard: PR-bound missions planned on a feature branch are visible again
(#2430). The dashboard scanner resolved ONE directory per mission,
coord-worktree-first — but under coordination topology the two partitions
live on different surfaces:spec-commitlands planning artifacts
(spec.md/plan.md/tasks.md/tasks//meta.json) on the primary
surface, while the live event log lives on the coordination branch. The
scanned-coordcopy therefore held only status writes, so the legacy
existence filter (numeric prefix ortasks/present) silently dropped any
in-flight post-083 mission from/api/features, and artifact viewers/kanban
read the wrong surface. The scanner now splits the read per partition —
planning artifacts primary-first through the same kind-aware read seam the
#2331 identity fix uses (resolve_planning_read_dir), live lane state from
the gather-resolved coord surface — so an in-flight coordination mission
lists with its real spec/plan/tasks AND live kanban lanes. Same
coord-shadows-primary class as #2331; the accept-side sibling is #2404. - Session banner no longer recommends a downgrade (#2413). The
session-presence health check decided "upgrade-available" with a bare
inequality (avail != current), so any machine whose installed CLI was
newer than the cached PyPI latest — a fresh release not yet in the 1-hour
cache, or an rc/dev install — got "⚠ Upgrade available: 3.2.2" while running
3.2.4. Both health branches now usepackaging.Versionordering
(avail > current); unparseable versions are treated as no-upgrade so
session start never breaks on a weird cache value. - The shared Agent-Skills projection root and the per-machine skill install
ledger are now gitignored (#2412). The skills installer projects global
canonical skills into.agents/skills/(codex/vibe/pi/letta) preferring
absolute symlinks into the user-global root — machine-local content by
construction — but unlike.claude/and the other agent dirs (gitignored
wholesale at init), bare.agents/was never covered by any init path,
registry entry, or migration, so the/Users/<name>/...symlink blobs were
committable (and the upgrade auto-commit would land them automatically).
.kittify/skills-manifest.json(per-machine timestamps/hashes/delivery
modes) had the same gap. Both are now registeredIGNOREDstate surfaces
(freshspec-kitty initgitignores them, andspec-kitty doctor's
present-but-not-gitignored warning covers them), and a sibling backfill
migration (3.2.5_agents_skills_gitignore_backfill) adds the entries on
spec-kitty upgradefor already-initialised projects. The symlink-vs-copy
delivery question (ask 3 of #2412) is tracked separately on the issue. spec-kitty upgradenow commits worktree churn on every checkout, not just
main (#2392, closes #2385/#1873, pins #2105). The upgrade auto-commit
captured a porcelain baseline for the main checkout only, so migration writes
it made in sibling worktrees were left uncommitted — dirtying them and
blocking coordination-topology merges (NFR-002). The porcelain-baseline commit
routine is extracted into a canonical per-checkout seam
(upgrade/autocommit.py::commit_touched_checkout); the migration runner now
snapshots each worktree before its writes and commits only that new churn on
the worktree's own branch (in-flight WP edits are never swept in;
manual_review_requiredmigrations skip the commit with a warning;
detached-HEAD skips instead of guessing a ref;--dry-runstays a strict
no-op). #1873's self-healing path is restored (metadata synthesized from
Noneis marked dirty so it's saved even when detected version == target).- Legacy-topology warning no longer over-fires on intentional
coordination-less missions (#2351). The once-per-mission warning keyed on
coordination_branchabsence, so it fired for genuinely deliberate
single_branch/lanesshapes (#2218) as well as truly pre-SSOT missions. A
new warning-only classifier gates just the emit on the canonical stored
MissionTopology+ flattened flag (warn iff coordination branch absent AND
stored topology null AND not flattened; malformed still warns); the shared
_is_legacy_missionpredicate that drives worktree routing and write-contract
selection is left byte-for-byte unchanged. .kittify/migrations/and.kittify/logs/are gitignored and backfilled
(#2384, completes the #2369 sweep). Two more generated.kittify/subtrees
(mission-state repair manifests + quarantine backups; orchestrator per-WP
logs) were neither IGNORED state surfaces nor backfilled, so they dirtied the
tree and failedaccept's git-dirty check. Both are now registered IGNORED
local-runtime surfaces (freshinitgitignores them) with a sibling
backfill migration for already-initialised projects on upgrade.saas_clientno longer defaults to a hardcodedapi.spec-kitty.io; it
fails closed (#2248).load_auth_contextfell back to a baked-in SaaS URL
when neitherSPEC_KITTY_SAAS_URLnor.kittify/saas-auth.jsonsupplied one,
risking silently pointing the client at the wrong server. With the #2146
target-authority decision in place, the default is removed and a no-URL
resolution now raisesSaasAuthError(caught where the no-token path already
was).- Sync runtime callers route through the canonical resolved target authority
(#2146). The tracker client, sharing client, and background sync read the
rawSyncConfig.get_server_url()(config-only, hardcoded default) instead of
resolve_runtime_target().resolved_server_url, so withSPEC_KITTY_SAAS_URL
set, auth/readiness hit the env target while these surfaces silently posted to
the config target (the SC-008 split-brain). All three now resolve the same
target as auth/readiness, andsync status/sync doctorreport the resolved
URL. Separately,sync server <url>now accepts loopback HTTP
(http://localhost/127.0.0.1/::1) for the documented local-Docker-SaaS
dev workflow while still requiring HTTPS for remote hosts. - compat-planner contract checks are revived (#2419). Two test surfaces
validatedupgrade/render_jsonpayloads against the committed
compat-planner.jsoncontract but silently no-op'd in CI since 2026-04-27
(each resolved the contract via a maintainer-worktree-only path, so it was
Noneand validation was skipped). Both now anchor on the repo root, load
unconditionally, and fail hard on a missing/unreadable contract — which caught
one real drift (a migration description trimmed to satisfymaxLength). - migration_id contract pattern widened to admit the dotted convention
(#2339). Thecompat-planner.jsonmigration_idpattern
(^[a-z0-9_]{1,128}$) rejected 83 of 89 real dotted ids (e.g.
3.2.0rc45_...) — a contract-authoring bug that let #2339-class drift slip
through unnoticed. Widened to^[a-z0-9_.]{1,128}$(a backward-compatible
widening; no shippedmigration_idrenamed, so persisted per-project ledgers
keep validating).
♻️ Changed
- Internal: version-comparison primitives consolidated into one canonical module (#2417, landing via PR #2414).
Landing the #2413 fix surfaced three independent "is version A newer than
version B" implementations that had drifted apart in edge-case handling:
cli/commands/upgrade.py::_version_is_newer(caught the broadException
instead of the specific parse failure),core/upgrade_probe.py::_classify's
inline comparison, and the_upgrade_is_availablehelper #2413 had just
added tosession_presence/manager.py. All three now delegate to a new
pure module,specify_cli.core.version_compare
(try_parse_version/is_version_newer), with no CLI/click/typer
imports so it is safe to import from any layer; the two duplicate boolean
helpers are deleted outright, no aliases left behind. No user-facing
behavior change. - Internal: CI-topology census freshness gate is now LOC-insensitive
(#2416). The gate compared each worklist entry's exact line count and an
LOC-derived sort order, so any PR that shifted the line count of a worklist
directory went red regardless of touching CI routing — a maintenance tax two
PRs paid inside 24h. It now checks membership + committed routing plan only
(an order/LOC-insensitive index at the shared derivation), fixing both the
pytest gate and the--verify-censusCLI by construction. Zerosrcchanges. - Internal: test-suite
/tmphygiene sweep (#1842, epic #1931). 97
grandfathered test files were converted off literal/tmppaths to
tmp_path/sentinels, and the empty-/tmpratchet was flipped to a
self-consistent hard gate so new literal-/tmpleaks fail fast. Also fixed a
test-only--output=parser leak that could write an invalid Windows filename
(breaking the Windows critical CI job), with a new arch guard blocking
Windows-illegal names and shell-expansion-leak telltales across tracked files
(#2169).
💥 Breaking Changes
- Pre-3.2.x legacy (meta-less) missions are no longer supported for
coordination operations (#2091 / #2462). A mission with no resolvable
mission identity — nomission_id/mid8inmeta.json, the pre-3.2.x
mission-identity model — can no longer drive coordination operations (status
transitions,move-task, review/merge coordination writes). Such a mission
now fails loud with a customer-actionable error instead of silently
composing a malformedkitty/mission-<slug>-ref (the old #2091 defect that
surfaced only as an opaquegit worktree addexit-128). This intentionally
drops the dual-era legacy-bridge fallback: legacy missions are dwindling and
the dual-support path was a persistent source of split-brain routing bugs.
Migration: runspec-kitty migrate backfill-identityto mint a ULID
mission_id(and derive itsmid8) for any affected mission — audit first
withspec-kitty doctor identity. See
mission-id-canonical-identity.md.