Mission Specification: Charter Delivery Finish and Context Degod

Mission Branch: feat/charter-delivery-finish-context-degod Created: 2026-07-30 Status: Draft Input: Leftover follow-ups from the doctrine-delivery missions — #3082 (generated when prose polish), #3064 (empty-charter dispatch → generic agent + warning + default charter asset), #2532 (decompose the charter/context.py god-module). Operator decision (2026-07-29) governs #3064; operator decision (this mission) is to land all three complete in one mission.

User Scenarios & Testing (mandatory)

The recent doctrine-delivery-activation (#3081) and doctrine-delivery-reachability missions closed their core work but deliberately deferred three tracked follow-ups. This mission finishes all three. Each workstream is an independently testable, independently shippable slice.

User Story 1 - Empty-charter dispatch does the sane, warned thing (Priority: P1)

A developer runs a Spec Kitty dispatch (spec-kitty dispatch "<request>") in a repository whose charter is empty or unconfigured. Today the resolution silently falls back to "all built-ins" — every shipped doctrine artefact is admitted as though the user had activated the whole catalogue. Per the operator's 2026-07-29 decision, the desired behaviour is: dispatch to a generic agent that uses no charter/doctrine at all, and warn the user, advising them to activate their repository's charter first. The software-dev mission type stays always-on regardless. A user who does not want to author a charter can copy a shipped default charter.yml asset and activate it.

Seam note (post-spec squad + plan research, confirmed against code): the "all built-ins" fallback is the shared three-state activation gate (charter/resolver.py via doctrine_service_factory.py), consumed by both routing (ProfileRegistry) and governance-context (build_charter_context) — so it must not be mutated. The plan research pinned the correct, minimal-blast-radius seam: a new invocation/empty_charter.py pre-check inserted at the executor's auto-route branch (executor.py:255-259, the no-profile-hint path), which pins generic-agent as the routing target. This leaves the gate, ProfileRegistry, and every gate/parity fixture untouched — so explicit --profile <specialist> dispatch still works under an empty charter, and the existing parity tests stay green as the "gate untouched" proof. Two corrections the research surfaced: (a) the empty-charter predicate must be composite across all charter-activatable dimensionscharter_activated_urns() covers only 6 URN kinds, so it is ANDed with activated_agent_profiles, activated_mission_step_contracts, activated_glossary_packs all None and no org packs (org_roots == ()); keying on charter_activated_urns() alone would false-fallback on a glossary-pack-only or profile-only configured repo; (b) governance-context does NOT agree for free (post-plan correction) — the dispatch's compact governance block merges project resolver directives, and under an empty charter directive resolution catalog-falls-back to the full built-in DIR-### canon, which would leak into the generic-agent block. So US1 also scopes the empty-charter governance block (FR-010) to suppress that merge on the fallback path, proven by a red-first agreement test asserting the Directive IDs: block is empty. generic-agent is a shipped built-in profile resolved through the ungated built-in repo, so pinning it as a constant routing target keeps "zero specialist doctrine admitted" true.

Why this priority: This is the only workstream that changes user-visible runtime behaviour and closes a governance correctness gap (ungoverned "all built-ins" dispatch). It carries the operator's explicit sign-off and the largest blast radius (the shared activation-gate fixture set — enumerated at plan), so it leads.

Independent Test: In a temp repo with no activated charter, run a dispatch and assert (a) the routing catalog resolves to the generic agent with no doctrine artefacts admitted, (b) build_charter_context for the same repo emits no doctrine-artefact context (the two surfaces agree), (c) a warning naming charter activation is surfaced, (d) software-dev is still available, and (e) spec-kitty doctrine asset path <default-charter-id> resolves the shipped default charter.yml.

Acceptance Scenarios:

1. Given a repository where charter_activated_urns() returns , When a dispatch/routing catalog is built, Then the catalog resolves to the generic agent (a routing-layer synthetic target) with no doctrine artefacts admitted and the software-dev mission type still available. 2. Given the same empty-charter repository, When build_charter_context is resolved for a dispatch, Then its governance Directive IDs: block is empty (or exactly generic-agent's own cited directives) — routing and governance-context give the same "no doctrine" answer — proven by a new convergence test (no existing parity fixture is re-baselined). 3. Given the same empty-charter dispatch, When the tooling routes the request, Then the user receives an explicit warning that the charter is unconfigured and is advised to activate a charter before running. 4. Given a user who does not want to author a charter, When they run the shipped-asset resolution surface, Then a working default charter.yml asset is resolvable/copyable via spec-kitty doctrine asset and can be activated — without any existing user charter being modified. 5. Given a repository where charter_activated_urns() returns ≥1 URN, When the routing catalog is built, Then the empty-charter path does not trigger and the existing activation-gated resolution is used unchanged.


User Story 2 - Generated agent guidance reads grammatically (Priority: P2)

Every agent that loads charter context receives "verbatim-OR-fetch" disclosure stanzas whose second line is When you <clause>, run this command and apply the returned rule. The clause is drawn from each artefact's edge when, which is frequently a full clause/sentence or a gerund — or the STATED_DEFAULT_WHEN fallback — producing ungrammatical output delivered on the mission's primary agent surface, e.g. When you designing or reviewing significant code changes, run this command and When you Fetch when this artefact's guidance applies to the work at hand (no specific trigger authored yet)., run this command. The stanza is functional (the pointer + command still work) but low prose quality.

Why this priority: User-facing quality on the primary delivered surface, but non-blocking — the guidance still functions. It ships independently of the dispatch change.

Contract tension (post-spec squad, confirmed against code): _WHEN_DOING_RE is a closed 6-verb setwhen you (are about to|need to|encounter|introduce|rename|review). So a fix must produce a line that is both grammatical and headed by one of those six lead-ins; a grammatical-but-non-matching rewrite (e.g. When you are designing…) would silently break the contract. The fix must therefore normalize each authored clause into a verb-led form the closed set accepts (the safe default is the existing DEFAULT_WHEN_CLAUSE anchor are about to …) — or, if a clause genuinely cannot be mapped, the contract regex is widened as a deliberate, documented contract change (decided at plan, kept in sync with charter-context-resolver.md). Additionally the current gate applies _WHEN_DOING_RE.search(prompt) over the whole prompt, so one matching line anywhere passes today; per-stanza grammaticality must be asserted by a dedicated check, not the whole-prompt search.

Independent Test: Feed representative clause shapes (leading gerund, full sentence, STATED_DEFAULT_WHEN fallback, and a well-formed trigger phrase) through the fetch-stanza formatter and assert each emitted stanza is grammatical and individually matches _WHEN_DOING_RE / _FETCH_CMD_RE.

Acceptance Scenarios:

1. Given a clause that begins with a gerund (designing or reviewing …), When the fetch stanza is rendered, Then the emitted line is grammatical (no When you <gerund> construction), is headed by one of the closed _WHEN_DOING_RE lead-ins, and matches the contract per-stanza. 2. Given the STATED_DEFAULT_WHEN fallback clause (a full sentence ending in a period), When the stanza is rendered, Then the line does not double a sentence into When you <full-sentence>., …, reads as intended guidance, and still matches the contract. 3. Given an already well-formed trigger phrase (are about to apply a code change), When the stanza is rendered, Then the output is byte-unchanged from today (no regression on the good path).


User Story 3 - The charter/context.py god-module is decomposed (Priority: P3)

A maintainer opening src/charter/context.py today faces a 3243-line module — the largest un-ticketed module in the charter layer — of ~90 free functions separated only by comment banners. This workstream decomposes the module by continuing the already-established src/charter/context_renderers/ package convention (7 modules already extracted across the doctrine-delivery missions: authority_paths, delivery_table, fetch_stanza, profile_sections, reference_pointers, section_bodies, token_budget), reducing context.py to a thin orchestration surface with no behaviour or output-format change to its public entry points.

Why this priority: Pure maintainer/tech-debt value with no user-visible change; it is the largest effort but lowest external risk when the parity invariant holds, so it lands last. Per operator decision it is done complete (full decomposition), not a bounded first-slice.

Fakeability guard (post-spec squad, confirmed against code): context.py already declares __all__ and has zero specify_cli imports today — so the layer-rule, __all__, and dead-symbol gates are already green on the untouched monolith, and NFR-001 byte-parity passes trivially on a no-op. Those gates are regression guards, not decomposition-completion signals. Completion is therefore bound to two measurable outcomes below: a residual-LOC ceiling on context.py, and an assertion that each named seam exists as a real sibling module that callers import from the seam (not re-exported through context.py). This closes the "extract one helper, claim victory" gap.

Independent Test: A parity fixture captures the exact output of build_charter_context, build_charter_context_include, and build_charter_context_json for a corpus that must traverse the behaviour-bearing paths (token-budget substitution, catalog-miss fall-through, first-load state bookkeeping) and asserts byte-identical output after; each extracted sibling module carries focused unit tests; context.py lands at or below the residual-LOC ceiling; the layer-rule / __all__ / dead-symbol gates stay green.

Acceptance Scenarios:

1. Given the pre-decomposition output of build_charter_context / _include / _json captured on a corpus that includes a token-budget-substitution input, a catalog-miss input, and a first-load input, When the module is decomposed and those entry points are re-run, Then the output is byte-identical (0 diffs), and the fixture fails if any of those behaviour-bearing inputs is removed. 2. Given the decomposition, When it lands, Then context.py is at or below the agreed residual-LOC ceiling (set at plan) and each named seam is a distinct sibling module whose functions are imported from that module by their callers (not re-exported through context.py). 3. Given the profile-resolution seam relocation, When callers/tests that import private hooks from charter.context are checked, Then the preserved surface (incl. _reset_agent_profile_cache, imported by 4 test files) still resolves — via re-export shim or by moving those imports with the seam (see FR-009). 4. Given the existing lazy import cycle (context_renderers/profile_sections.py reaches back into charter.context for _render_fetch_stanza, _budget_estimate, _diagnose_catalog_miss, _PROFILE_INLINE_BODY_LIMIT_CHARS), When those symbols are moved to leaf modules profile_sections can import at top level, Then the cycle is dissolved (not relocated) and no new import cycle is introduced. 5. Given seams that already have a partial home (token_budget.py, section_bodies.py), When the corresponding responsibility is extracted, Then it is consolidated into the existing module, not split into a third parallel home.

Edge Cases

  • "Empty/unconfigured" boundary (US1): Defined operationally as charter_activated_urns(repo_root) == ∅. A charter file that exists but activates zero artefacts is unconfigured (generic agent + warning); charter_activated_urns() already excludes the always-on software-dev mission type, so its presence alone does not count as "configured".
  • Gate-vs-routing split-brain avoided by seam choice (US1): Because the fix lives at the executor auto-route branch (not the shared gate), the generic-agent dispatch resolves build_charter_context scoped to generic-agent only — routing and governance-context agree by construction. The existing test_no_activation_key_admits_all_builtins_in_routing stays green (the gate is untouched); a new test proves routing+context convergence. No parity fixture is re-baselined under this seam.
  • Explicit --profile under empty charter (US1): The fallback fires only on the no-hint auto-route path; an explicit --profile <specialist> must still resolve the specialist even when the charter is empty (the pre-check returns "no fallback" whenever a hint is present).
  • Default charter asset vs. an existing user charter (US1): Shipping/resolving the default charter.yml asset must never overwrite, rename, or clobber a user-authored charter — the asset surface is resolve/copy-only (User Customization Preservation + the asset surface's "no install — C-002" contract).
  • Closed-verb-set contract (US2): _WHEN_DOING_RE accepts only are about to | need to | encounter | introduce | rename | review after When you; the normalized clause must land inside that set (or the regex is deliberately widened at plan). See US2 Contract tension.
  • The real cross-workstream collision is US1 ∩ US3, not US2 ∩ US3 (sequencing): US2's fix lands in the already-extracted context_renderers/fetch_stanza.py, which US3 does not touch — that overlap is near-nil. The material collision is US1 ∩ US3 through output parity: retiring the "all built-ins" fallback changes build_charter_context's output for an empty-charter input, so US3's byte-parity baseline (NFR-001) would legitimately break if captured over that input while US1 is in flight, and US1 mutates the activation-wrapper region (_maybe_build_activation_aware_service, ~context.py:1550-1566) that US3 also relocates. Required ordering: US2 and US1 land first; the US3 parity baseline is (re)captured only after both merge (or the corpus excludes empty-charter inputs); US3 decomposes last, against a frozen activation-wrapper seam. This makes the P1→P2→P3 order load-bearing, not merely a risk ranking.
  • Parity corpus must exercise behaviour-bearing paths (US3): token-budget substitution, catalog-miss degradation, and first-load state bookkeeping must be in the corpus, not only the happy path.

Requirements (mandatory)

Functional Requirements

IDTitleUser StoryPriorityStatus
FR-001Normalize the when clause before the When you … prefix so the fetch-stanza disclosure line is grammatical for every clause shape (leading gerund, full sentence, STATED_DEFAULT_WHEN fallback) while preserving byte-output on already-well-formed clauses.US2HighOpen
FR-002Add an empty-charter pre-check at the executor auto-route branch (invocation/executor.py, the no-profile-hint dispatch path) — a new invocation/empty_charter.py helper keyed on the composite predicate: charter_activated_urns(repo_root) == ∅ AND PackContext has activated_agent_profiles, activated_mission_step_contracts, activated_glossary_packs all None AND org_roots == () — that pins the generic-agent built-in profile as the routing target. Do not mutate the shared charter three-state gate or ProfileRegistry (so explicit --profile <specialist> still works under an empty charter).US1HighOpen
FR-010Scope the empty-charter dispatch governance block so it does not leak doctrine: on the empty_charter_fallback path, the Directive IDs: block must not merge the project catalog-fallback directives (which otherwise resolve to the full built-in canon under an empty charter). Bounded to the fallback path — do not globally change directive resolution.US1HighOpen
FR-003On empty-charter dispatch, emit an explicit user-facing warning that the charter is unconfigured and advise activating a repository charter (or copying the default asset) before running.US1HighOpen
FR-004Keep the software-dev mission type always-on; alongside it, the generic-agent routing target is always-available even under the empty-charter path (both are excluded from the "no doctrine admitted" count).US1HighOpen
FR-005Ship a minimal charter scaffold as a built-in doctrine assetcharter_scaffold_minimal.yml + sidecar, id: common-charter-scaffold-minimal (deliberately not "default-charter", to avoid colliding with charter/packs/default.yaml) — resolvable/copyable via spec-kitty doctrine asset, that a user can activate to forego authoring their own. Done-line includes an activatability test (activate in a temp repo → valid charter, no user charter touched), not just resolvability. Net-new.US1MediumOpen
FR-006Add new coverage for the fallback (a test_empty_charter_fallback.py for the composite predicate + generic-agent decision shape + preserved action-derivation, and a dispatch test asserting routing → generic-agent and governance-context agreement — same profile, no specialist roster leak). Because the shared gate and ProfileRegistry are untouched (FR-002), the existing gate/parity fixtures (test_doctrine_service_factory.py, test_registry_builtin_activation_parity.py, tests/charter/* activation) stay green as the "gate untouched" proof — none are re-baselined. The whole-prompt-vs-per-stanza concern belongs to US2/NFR-003, not here.US1HighOpen
FR-007Add a top-of-file retrospective decomposition note to src/charter/context.py recording the siblings it was split into and referencing #2532 (matching the real precedent, doctor.py:10 "was decomposed into cohesive sibling modules") — or fold this into FR-008 if redundant. A forward "pending decomposition" pointer is stale on arrival since FR-008 decomposes in the same mission.US3LowOpen
FR-008Decompose charter/context.py by continuing the existing context_renderers/ package convention, consolidating into already-extracted modules where a partial home exists (token_budget.py, section_bodies.py) and adding new siblings only for genuinely new seams; each seam is assigned a target home (render seams → context_renderers/; non-render seams → flat src/charter/ siblings) and is independently testable, reducing context.py to a thin orchestration surface at/below the residual-LOC ceiling set at plan.US3MediumOpen
FR-009Preserve the imported surface of charter.context: the public entry points build_charter_context / _include / _json and private symbols imported elsewhere (at minimum _reset_agent_profile_cache, imported by 4 test files) — via re-export shim or by moving the import sites with the seam. Behaviour-preserving, no output-format change.US3HighOpen

Non-Functional Requirements

IDTitleRequirementCategoryPriorityStatus
NFR-001Output parity under decompositionThe three public entry points produce byte-identical output before/after WS3 on a corpus that must include a token-budget-substitution input, a catalog-miss input, and a first-load input; the parity fixture fails if any behaviour-bearing input is removed (0 diffs, non-trivial corpus).ReliabilityHighOpen
NFR-002Layer-rule ratchet stays green (regression guard)Zero new specify_cli imports anywhere under src/charter/; test_layer_rules.py and test_runtime_charter_doctrine_boundary.py pass unchanged. Note: already green on the untouched monolith — a regression guard, not a decomposition-completion signal (see FR-008 residual-LOC ceiling for the completion signal).MaintainabilityHighOpen
NFR-003Prompt-governance contract intact, per-stanzaAfter FR-001, every rendered fetch stanza individually matches _WHEN_DOING_RE (closed 6-verb set) and _FETCH_CMD_RE — asserted per-stanza by a dedicated check, not the whole-prompt .search(); test_wp_prompt_governance_contract.py passes.ReliabilityHighOpen
NFR-004Lint/type/coverage cleanNew and changed code passes ruff and mypy --strict with zero issues/warnings and no new suppressions; extracted seams and new branches carry focused tests meeting the ≥90% new-code coverage bar.QualityHighOpen
NFR-005Dead-symbol / __all__ gates green (regression guard)Every module under src/charter/ (including all newly extracted seams) declares __all__ with every exported symbol called from src/; test_no_dead_symbols.py passes. Note: already green pre-work — a regression guard, not proof decomposition happened.MaintainabilityMediumOpen

Constraints

IDTitleConstraintCategoryPriorityStatus
C-001charter → doctrine layeringExtracted modules stay under src/charter/ (or lower: src/doctrine/, src/kernel/); the charter layer must never import specify_cli.TechnicalHighOpen
C-002Asset surface is resolve/copy-onlyThe default charter.yml asset is shipped and resolvable/copyable (no forced install); it must never overwrite or rewrite a user-authored charter (User Customization Preservation).TechnicalHighOpen
C-003Generic-agent path uses no doctrineUnder empty charter the generic-agent dispatch must not silently re-admit built-ins by any route (routing or governance context); always-available items are the software-dev mission type and the generic-agent routing target itself — nothing else.TechnicalHighOpen
C-004No version prescriptionNo patch/minor version numbers are assigned in scope; the PO superimposes the release version at release time.BusinessMediumOpen
C-005Terminology canonWarning copy, asset content, and any new prose use canonical terms (Mission, not Feature); test_no_legacy_terminology.py stays green.TechnicalMediumOpen
C-006ATDD red-first + reviewer≠implementerEach WP lands a failing-first ATDD test through the pre-existing entry point before implementation; the reviewer is a distinct role from the implementer.ProcessHighOpen

Key Entities

  • Fetch stanza / when-clause: The two-line "verbatim-OR-fetch" disclosure emitted per cited artefact; line 2 embeds the normalized when clause (context_renderers/fetch_stanza.py:133, the single composition choke-point).
  • Shared activation gate: The three-state activated_<kind> is None → admit all built-ins authority in charter/resolver.py (via doctrine_service_factory.py), consumed by both routing (ProfileRegistry) and governance context (build_charter_context). Not to be mutated by US1.
  • Empty-charter signal: charter.pack_context.charter_activated_urns(repo_root) — the single authority that returns when nothing is activated (already excludes always-on software-dev); the pre-check keys on this.
  • Generic-agent routing target: A routing-layer synthetic/constant dispatch target used under empty charter — distinct from the shipped generic-agent doctrine profile, so "no doctrine admitted" stays literally true.
  • Default charter asset: A shipped charter.yml under the built-in doctrine asset tier with a .asset.yaml sidecar, discoverable via spec-kitty doctrine asset list/path.
  • context_renderers/ package + charter/context.py seams: The established render subpackage (7 modules already extracted) that WS3 continues; remaining responsibility clusters (bootstrap assembly, artifact-body formatting, selection rendering, governance/activation rendering, profile resolution, org-pack discovery, JSON builder, template include, state bookkeeping, charter.md parsing, token budget) are consolidated into existing homes or new siblings.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: On an unconfigured-charter repository (charter_activated_urns() == ∅), 100% of dispatches route to the generic agent with zero doctrine artefacts admitted in both routing and governance context, and surface an activation warning — the "all built-ins" fallback occurs 0 times (down from always).
  • SC-002: A user can obtain and activate a working default charter in a single documented step via the shipped asset, without hand-authoring a charter and without any existing user charter being modified.
  • SC-003: Zero ungrammatical When you … disclosure lines across the enumerated clause-shape corpus, with every stanza individually matching the prompt-governance contract (per-stanza check, closed 6-verb set).
  • SC-004: src/charter/context.py is reduced from 3243 lines to ≤600 (grounded floor ≈500–540; ≤500 aspirational), asserted by a wired completion test whose primary signal is a seam-existence manifest (each named seam is a real sibling imported from its own home, not just re-exported through context.py); the three public entry points produce byte-identical output on the non-trivial parity corpus (0 diffs, per-case markers asserted); and the layer-rule, __all__, and dead-symbol regression gates remain green.