Research: Charter Delivery Finish and Context Degod
Phase 0 output. Two profile-loaded research passes (architect-alphonso — WS3 decomposition map; paula-patterns — WS1 seam + blast radius), each grounded in spec-kitty_TWO HEAD and re-verified by the orchestrator. Builds on notes/post-spec-squad-findings.md.
Decision 1 — WS2/#3082: normalize the when clause into the closed contract set
- Decision: Normalize each authored
whenclause in the single choke-pointcontext_renderers/fetch_stanza.py:133(fetch_stanza_lines) so the emitted line is grammatical and headed by one of the closed_WHEN_DOING_RElead-ins. Assert per-stanza (not whole-prompt.search()). - Rationale:
_WHEN_DOING_RE = when you (are about to|need to|encounter|introduce|rename|review)(verifiedtest_wp_prompt_governance_contract.py:221) is a closed 6-verb set. Authored clauses are frequently gerund phrases ("designing or reviewing …") or full sentences (STATED_DEFAULT_WHEN). A grammatical-but-non-matching rewrite (When you are designing…) silently breaks the contract. Mapping the clause to a verb-led form the set accepts (default anchor: the existingDEFAULT_WHEN_CLAUSE = "are about to apply a code change") satisfies both. - Default chosen: Map/normalize, do not widen the regex. Widening
_WHEN_DOING_REis a wire-contract change (kept in sync withcharter-context-resolver.md) — reserved for a clause that genuinely cannot be mapped, decided explicitly at tasks. - Alternatives considered: (a) widen the regex to accept gerunds — rejected as default: expands the contract surface for a prose nit; (b) strip the
When youprefix when the clause is a full sentence — rejected: breaks the_WHEN_DOING_REanchor.
Decision 2 — WS1/#3064 seam: executor auto-route pre-check, NOT the shared gate or ProfileRegistry
- Decision: Add
src/specify_cli/invocation/empty_charter.py::resolve_generic_fallback(repo_root, request_text)and call it at the executor auto-route branchinvocation/executor.py:255-259(theelif self._router is not None:/ no-profile-hint path):result = resolve_generic_fallback(...) or self._router.route(request_text). - Rationale: The "all built-ins" behaviour is the shared three-state gate in
charter/resolver.py(viadoctrine_service_factory.py:38-86), consumed by routing andbuild_charter_context. Mutating it (or guttingProfileRegistry._build_merged_profiles) has a blast radius acrossprofiles list,--include, theagent workflowschema resolve (agent/workflow.py:1047), and breaks explicit--profile <specialist>under an empty charter. The auto-route branch is the only place the "no hint" dispatch signal exists, so the override is surgical. - Consequence (de-risking): the shared gate,
ProfileRegistry, and every gate/parity fixture stay untouched — no re-baseline.test_no_activation_key_admits_all_builtins_in_routingstays green as the "gate untouched" proof. - Alternatives considered: (a) pre-check in
ProfileRegistry— rejected: over-reaches, breaks explicit-profile dispatch + workflow resolve; (b) mutate the three-state gate — rejected: cross-subsystem blast radius, re-resolves governance context for every consumer.
Decision 3 — WS1 predicate is COMPOSITE across ALL charter-activatable dimensions (post-plan correction)
charter_activated_urns(repo_root) == ∅ (covers the 6 URN kinds: directives/tactics/toolguides/procedures/paradigms/styleguides) AND PackContext.from_config(repo_root) has activated_agent_profiles is None AND activated_mission_step_contracts is None AND activated_glossary_packs is None AND PackContext.org_roots == () (no org/project packs).
- Decision: empty-charter ⇔ all of:
- Rationale:
charter_activated_urns()(pack_context.py:496) is restricted to 6 URN kinds via_ACTIVATION_URN_KINDSand excludes the other charter-activatable dimensions.PackContextcarries three-stateactivated_agent_profiles,activated_mission_step_contracts,activated_glossary_packs(pack_context.py:167-176), andglossary-pack+mission-step-contractare charter-activatable (CHARTER_KIND_TOKENS,artifact_kinds.py).anti_patternis NOT charter-activatable (excluded with template/asset) — omit it. A repo that activated only a glossary-pack (or step-contract, or an org pack) would otherwise satisfy the naive predicate and false-fallback to generic-agent with a wrong "unconfigured" warning on a demonstrably configured repo. Decision 3's meaning — "the operator activated nothing at all" — requires all dimensions. - Truth-table cases the WP test MUST cover: nothing activated → fallback; URN-only / profile-only / glossary-pack-only / step-contract-only / org-pack-present → NO fallback.
- Alternatives considered: single-authority
charter_activated_urns()==∅— rejected (misfires on 3 dimensions + org packs); re-enumerating rawactivated_*keys — rejected (divergent "empty" definition).
Decision 4 — WS1 governance context does NOT agree for free; the empty-charter path must scope the governance block (post-plan CORRECTION)
- Decision: US1 scope includes a governance-context adjustment on the empty-charter/generic-agent path — the dispatch governance block must NOT merge the project catalog-fallback directives. Add a red-first agreement test that asserts the
Directive IDs:block is empty/generic-agent-scoped. - Rationale (retracts the earlier "agrees by construction"):
_render_compact_governance→render_compact_view(compact.py:216-230) MERGESresolver_directivesfrom_resolve_governance_summary(repo_root)into theDirective IDs:block, independent of the profile. Under a wholly-empty charter,resolve_project_governance→_resolve_directives_selection(resolver.py:233-260) has emptydoctrine.selected_directives, so it returnsfallback = sorted(doctrine_catalog.directives)— the full built-in directive canon (catalog_fallback). So an unadjusted generic-agent dispatch would emit every built-inDIR-###in its governance block — a doctrine leak contradicting C-003/SC-001. This is the split-brain the first squad warned of, proven real. - Implementation options (bounded, no shared-gate mutation): (a) preferred — when
empty_charter_fallbackfired, the executor resolves governance in a mode that suppresses the resolver-directive merge (e.g. abuild_charter_context(..., suppress_project_resolver=True)param or a dedicated minimal-governance render for the fallback), leavingrender_compact_viewuntouched for all other callers; (b) alternative — a scoped render helper for the fallback path. Do NOT change_resolve_directives_selection's catalog-fallback globally (broad blast radius across every compact-context consumer). - Test (new, red-first): dispatch under a wholly-empty charter; assert
payload.profile_id == "generic-agent", the governanceDirective IDs:block is empty (or exactly generic-agent's own cited directives), no specialist marker, andempty_charter_fallback is True. The test is RED before the governance scoping lands.
Decision 5 — WS1 warning surface
- Decision: Emit once in
cli/commands/dispatch.py::_render_rich_payload(lines 59-91), gated on a new explicitInvocationPayload.empty_charter_fallback: boolflag (added to__slots__/annotationsexecutor.py:139-163, serialized into_dictfor--json). Rich callers get a yellow panel;--jsoncallers get the boolean. - Rationale: The router (pure, ADR-3 no-I/O) and registry must not print. Keying on
payload.profile_id == "generic-agent"alone would false-warn a deliberate--profile generic-agenton a configured charter; the dedicated flag prevents that.
Decision 6 — WS1 default charter asset (FR-005)
- Decision: Ship
src/doctrine/assets/built-in/charter_scaffold_minimal.yml+ sidecarcharter_scaffold_minimal.yml.asset.yaml(id: common-charter-scaffold-minimal— deliberately NOT "default-charter", to avoid colliding withsrc/charter/packs/default.yaml;mime: application/yaml,path: built-in/charter_scaffold_minimal.yml,title: Minimal charter scaffold); resolvable viaspec-kitty doctrine asset path common-charter-scaffold-minimal(resolve/copy-only, no install). Content = a minimal curated starter charter, NOT a copy ofpacks/default.yaml. Add a one-line cross-reference comment in each of the two files pointing at the other so a later agent does not "consolidate" them. - Rationale:
packs/default.yamlactivates all built-ins across 9 kinds — the semantic inverse of "empty"; naming a second asset "default charter" invites drift. A minimal curated starter matches the user intent ("forego authoring, get a sane baseline"). Declaremime: application/yaml;mimetypes.guess_type('x.yml')already returnsapplication/yamlon this repo's Python 3.11, so the sidecar passes_check_asset_mimetoday — thepack_validatorregression is a guard, not a 3.13-forward fix (earlier rationale corrected). - Activatability (post-plan): FR-005's done-line is not just resolvability — a test must activate the shipped scaffold in a temp repo and assert the resulting charter validates (non-empty, activatable) without touching any pre-existing user charter.
- OPEN DECISION for operator visibility (non-blocking): minimal-curated-starter is the chosen default; if the operator prefers the asset to point at
packs/default.yaml(activate-all), that is a one-line content swap.
Decision 7 — WS3 decomposition: continue context_renderers/, consolidate, dissolve the cycle
- Decision: Decompose
context.py(3243 LOC) into the seams mapped in data-model.md, continuing the existingcontext_renderers/package; consolidate intotoken_budget.py,section_bodies.py,reference_pointers.py,profile_sections.py,fetch_stanza.py,delivery_table.pywhere a partial home exists; mint new siblings only for genuinely new seams. Residual ceilingcontext.py≤ 500 LOC (stretch 400). - Cycle dissolution (verified one-directional):
profile_sections.py:160-165function-locally imports 4 symbols fromcharter.context. Pin them to LEAF homes —_budget_estimate+_PROFILE_INLINE_BODY_LIMIT_CHARS→token_budget.py;_render_fetch_stanza→ fold intofetch_stanza.py;_diagnose_catalog_miss(+_available_catalog_ids) → new leafcatalog_diagnosis.py(deps:charter._catalog_missonly).profile_sections.pythen imports all four at top level; bothnoqa: PLC0415deleted. No back-edge intocontext.py. - FR-009 preserve surface (verified): zero production/
srccallers import private symbols fromcharter.context— the entire private surface (~40 symbols incl. the 4-test anchor_reset_agent_profile_cache) is test-only. Keep one explicit# FR-009 preserved surfacere-export block incontext.py(lowest-regression; ~55 LOC). Optionally migrate pure-seam tests to import from new homes later (follow-up, not a completion gate). - Extraction sequence (low-risk → high-risk): (1)
catalog_diagnosis→ (2)token_budgetconsolidate → (3)fetch_stanzaconsolidate → (4)artifact_bodies→ (5)charter_md_parsing+reference_pointersconsolidate → (6)context_state→ (7)template_include→ (8)selection_block→ (9)activation_block→ (10)compact_governance+bootstrap_text→ (11)context_json→ (12)org_pack_discovery+action_doctrine_bundle→ (13)profile_resolution+doctrine_service_builderLAST (carries the US1-mutated activation-wrapper region + the profile caches).
Decision 8 — Load-bearing sequencing (US1 ∩ US3)
- Decision: US2 (#3082) and US1 (#3064) land first; the US3 byte-parity baseline is (re)captured only after both merge; US3 extraction lands last, and the
doctrine_service_builder/profile_resolutionseams (the_build_activation_aware_doctrine_serviceregioncontext.py:~1550-1566that US1 touches) are extracted last of all, against a frozen wrapper. - Rationale: US1 changes
build_charter_context's output for an empty-charter input; capturing the US3 parity golden before US1 merges would freeze stale bytes and collide. Confirmed the real collision is US1∩US3 (not US2∩US3 — US2's fix is in the already-extractedfetch_stanza.py, which US3 doesn't structurally move beyond consolidating the thin wrapper).
Decision 9 — Residual-LOC ceiling + WIRED completion test (post-plan tightening)
- Decision: Wire a dedicated completion test
tests/charter/test_context_decomposition_completion.py(owned by the last US3 WP) asserting both: (1)wc -l src/charter/context.py ≤ 600(the HARD gate), and (2) each named seam module in a manifest exists AND is imported by ≥1 non-contextcaller (imported from the seam, not only re-exported throughcontext.py). The seam-existence manifest is the primary, un-fakeable completion signal; the LOC gate is secondary. - Ceiling rationale (grounded): python-pedro measured the irreducible STAY weight — 3 orchestrators ≈ 372 LOC + module preamble/
CharterContextResult/BOOTSTRAP_ACTIONS≈ 35 + import block (grows with ~17 seam imports) ≈ 70 + FR-009 re-export shim ≈ 55 ⇒ realistic floor ≈ 500–540. A≤600HARD gate clears that floor with margin (still proves ~80% reduction from 3243) and avoids a self-inflicted red;≤500becomes the aspirational stretch, and the earlier400stretch is dropped as unrealistic. If the implementer cannot reach≤600, that is a BLOCKER requiring explicit operator re-sign-off — NOT an implementer-side ceiling adjustment (closes the "re-negotiable prose" fakeability the squad flagged). - Rationale: the gates already green on the monolith (
__all__, zerospecify_cliimports, byte-parity on a no-op) are regression guards, not completion signals; the LOC gate + seam-existence manifest is the non-fakeable completion signal.
Decision 10 — Sequencing enforced by task dependency + baseline provenance (post-plan)
- Decision: Because all three workstreams land in ONE mission (no inter-workstream merge), the "US3 baseline captured after US1+US2" rule is enforced as an explicit
/tasksWP dependency edge — the parity-baseline-capture WP depends on the US1 (empty-charter) and US2 (prose) WPs being approved — and the captured baseline must include the empty-charter input producing the generic-agent/no-doctrine-block output (proving it was generated post-US1). Theprofile_resolution/doctrine_service_builderseams (the US1-frozen_build_activation_aware_doctrine_serviceregion) extract in the last US3 WP.
Decision 11 — empty_charter_fallback payload default (post-plan, pedro)
- Decision:
InvocationPayload.__init__(**kwargs)only sets provided keys andto_dictdoesgetattr(self, s, None)— so always threadempty_charter_fallback=<bool>at the single construction site (executor.py:~336-348), and havedispatch.py::_render_rich_payloadread viagetattr(payload, "empty_charter_fallback", False). Otherwise existing payload-constructing tests emitnullin--jsonand the rich pathAttributeErrors.
Decision 12 — cycle-dissolution placement guardrail (post-plan, pedro)
- Decision:
fetch_stanza.pyreceives ONLY the pure stanza renderer (_render_fetch_stanza→ fold intofetch_stanza_lines)._budget_estimate+_PROFILE_INLINE_BODY_LIMIT_CHARSgo totoken_budget.py(which already importsfetch_stanza). Do NOT co-locate the budget gate intofetch_stanza.py— that would formfetch_stanza → token_budget → fetch_stanza._build_doctrine_serviceand_maybe_build_doctrine_serviceeach need their ownimport doctrine.service as _doctrine_service_modulein their new homes.
Tracker-hygiene carry-forwards (from squad, actioned at tasks)
- #2399 (P1) — "Structurally enforce agent-profile loading across all invocation contexts" — is adjacent: this mission adds a new dispatch-invocation resolution branch (generic-agent fallback). Note #2399 so its future enforcement does not treat the empty-charter generic-agent route as a violation. (No foldable dup; #371/#1049 are model-scoring routing, #1278 is doctrine content.)
- Enumerate the issue-matrix rows for #3082/#3064/#2532 at tasks; verify survival of
finalize-tasks. #2532declaresEpic: #2173in its body; planner-priti notes it is labelled tech-debt/tidy-up and behaviour-preserving (arguably#2519). Honour the issue author's#2173; note the tension, do not force-relink.#3082carries no priority/type label — add one at tasks for dashboard/matrix consistency.- Optional rename of
test_no_activation_key_admits_all_builtins_in_routingis unnecessary under the chosen seam (it stays semantically correct — the gate does still admit all built-ins; only auto-route dispatch overrides).