From c61ffbb2e4f88c75855afe8e2a8c8a7442971ac8 Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 20:09:13 +0200 Subject: [PATCH 1/2] feat(context): define scenario taxonomy and request/response contract for adr_planning_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the API design layer for scenario-aware context retrieval — the schema foundation that retrieval and rendering logic will build on. Introduces ContextScenario (4 scenarios), ChangeMode, and DetailLevel enums; ScopeHint, TargetRef, ContextRequest, ConstraintSummary, InspectReference, PacketMetadata, and ScenarioContextPacket Pydantic v2 models in adr_kit/context/models.py. Exports all 10 new types from the context package. Extends PlanningContextRequest with 5 backward-compatible optional scenario fields so existing callers require no changes. Validated by 41 new unit tests across two test files. --- adr_kit/context/__init__.py | 28 ++- adr_kit/context/models.py | 197 ++++++++++++++++ adr_kit/mcp/models.py | 40 +++- tests/unit/test_context_scenario_models.py | 234 ++++++++++++++++++++ tests/unit/test_planning_context_request.py | 128 +++++++++++ 5 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_context_scenario_models.py create mode 100644 tests/unit/test_planning_context_request.py diff --git a/adr_kit/context/__init__.py b/adr_kit/context/__init__.py index 3370853..e47d33b 100644 --- a/adr_kit/context/__init__.py +++ b/adr_kit/context/__init__.py @@ -14,7 +14,22 @@ from .analyzer import TaskAnalyzer, TaskContext, TaskType from .guidance import ContextualPromptlet, GuidanceGenerator, GuidanceType -from .models import ContextPacket, ContextualADR, PlanningGuidance, TaskHint +from .models import ( + ChangeMode, + ConstraintSummary, + ContextPacket, + ContextRequest, + ContextScenario, + ContextualADR, + DetailLevel, + InspectReference, + PacketMetadata, + PlanningGuidance, + ScenarioContextPacket, + ScopeHint, + TargetRef, + TaskHint, +) from .planner import PlanningConfig, PlanningContext from .ranker import RankingStrategy, RelevanceRanker, RelevanceScore @@ -34,4 +49,15 @@ "GuidanceGenerator", "GuidanceType", "ContextualPromptlet", + # SCN: scenario taxonomy + "ContextScenario", + "ChangeMode", + "DetailLevel", + "ScopeHint", + "TargetRef", + "ContextRequest", + "ConstraintSummary", + "InspectReference", + "PacketMetadata", + "ScenarioContextPacket", ] diff --git a/adr_kit/context/models.py b/adr_kit/context/models.py index 1f0f212..b802d1d 100644 --- a/adr_kit/context/models.py +++ b/adr_kit/context/models.py @@ -1,12 +1,209 @@ """Data models for the Planning Context Service.""" from datetime import datetime, timezone +from enum import Enum from typing import Any from pydantic import BaseModel, Field from ..core.model import ADRStatus +# --------------------------------------------------------------------------- +# Scenario taxonomy (SCN) +# --------------------------------------------------------------------------- + + +class ContextScenario(str, Enum): + """Supported context retrieval scenarios for v1.""" + + STRATEGIC_PLANNING = "strategic_planning" + """Feature planning, spec writing, architectural exploration.""" + + FOCUSED_IMPLEMENTATION = "focused_implementation" + """Coding in a specific area or bounded task.""" + + PRE_DECISION = "pre_decision" + """Before committing to a direction — evaluate whether a new ADR is needed.""" + + SUPERSESSION_IMPACT = "supersession_impact" + """When an existing decision may change — assess downstream implications.""" + + +class ChangeMode(str, Enum): + """How the caller intends to change the codebase.""" + + NONE = "none" + """Reading or exploring — no changes planned.""" + + ADDITIVE = "additive" + """Adding new code or features without changing existing behaviour.""" + + MODIFYING = "modifying" + """Changing existing code or behaviour.""" + + REPLACING = "replacing" + """Superseding or replacing a decision or component.""" + + +class DetailLevel(str, Enum): + """How much detail to include in the response packet.""" + + MINIMAL = "minimal" + """Constraints only, no explanations.""" + + STANDARD = "standard" + """Constraints with brief rationale (default).""" + + DETAILED = "detailed" + """Full context including history and tradeoffs.""" + + +class ScopeHint(BaseModel): + """Typed scope signal so the kit doesn't have to guess what a string means.""" + + hint_type: str = Field( + ..., + description=("Signal type: 'file_path', 'module', 'domain', 'stack', or 'tag'"), + ) + value: str = Field( + ..., + description="The signal value, e.g. 'src/auth/', 'authentication', 'backend'", + ) + + +class TargetRef(BaseModel): + """Typed reference to a known ADR or clause the caller wants included.""" + + ref_type: str = Field( + ..., + description="Reference type: 'adr_id' or 'clause_id'", + ) + ref_id: str = Field( + ..., + description="The identifier, e.g. 'ADR-003' or a clause UUID", + ) + + +class ContextRequest(BaseModel): + """Structured request contract for adr_planning_context callers.""" + + scenario: ContextScenario = Field( + ContextScenario.STRATEGIC_PLANNING, + description="Which scenario this request falls under", + ) + task_summary: str = Field( + ..., + description="What the caller is trying to accomplish", + ) + scope_hints: list[ScopeHint] = Field( + default_factory=list, + description="Typed scope signals (file paths, domains, stack tags, etc.)", + ) + change_mode: ChangeMode = Field( + ChangeMode.NONE, + description="How the caller intends to modify the codebase", + ) + focus: str = Field( + "", + description="Specific area of concern (free text, feeds semantic retrieval)", + ) + known_targets: list[TargetRef] = Field( + default_factory=list, + description="Typed references to ADRs or clauses to include unconditionally", + ) + detail_level: DetailLevel = Field( + DetailLevel.STANDARD, + description="How much detail to include in the response", + ) + + +# --------------------------------------------------------------------------- +# Response-side models (SCN) +# --------------------------------------------------------------------------- + + +class ConstraintSummary(BaseModel): + """A single enforced constraint derived from an approved ADR.""" + + clause_id: str | None = Field( + None, + description="Clause identifier (None until clause IDs land in a future item)", + ) + source_adr: str = Field(..., description="ADR that produced this constraint") + summary: str = Field(..., description="One-line constraint description") + relevance_score: float = Field( + ..., + description="How relevant this constraint is to the request (0.0-1.0)", + ) + domain: str | None = Field( + None, + description="Architectural domain this constraint belongs to", + ) + + +class InspectReference(BaseModel): + """A reference returned so callers can inspect further detail on demand.""" + + ref_type: str = Field( + ..., + description="Reference type: 'adr', 'clause', or 'resource'", + ) + ref_id: str = Field( + ..., + description="Identifier: ADR-003, clause UUID, or resource URI", + ) + label: str = Field(..., description="Human-readable label for display") + + +class PacketMetadata(BaseModel): + """Metadata describing how a ScenarioContextPacket was assembled.""" + + token_estimate: int = Field( + ..., + description="Rough token count for the full packet", + ) + candidate_count: int = Field( + ..., + description="Number of candidates evaluated before ranking", + ) + ranking_strategy: str = Field( + "", + description="Strategy used to rank candidates (e.g. 'semantic', 'exact')", + ) + + +class ScenarioContextPacket(BaseModel): + """Scenario-aware response packet (v2). + + Coexists with the legacy ContextPacket. New tool paths return this shape; + the legacy ContextPacket remains in place for existing callers. + """ + + scenario: ContextScenario = Field( + ..., + description="Scenario this packet was assembled for", + ) + overview: str = Field( + ..., + description="1-3 sentence architectural orientation for the scenario", + ) + constraints: list[ConstraintSummary] = Field( + default_factory=list, + description="Relevant constraints, ranked by relevance", + ) + warnings: list[str] = Field( + default_factory=list, + description="AI warnings and tradeoff notes", + ) + inspect_deeper: list[InspectReference] = Field( + default_factory=list, + description="References to ADRs, clauses, or resources for further inspection", + ) + metadata: PacketMetadata | None = Field( + None, + description="Assembly metadata (token estimate, candidate count, etc.)", + ) + class TaskHint(BaseModel): """Hints about the task that help determine relevant context.""" diff --git a/adr_kit/mcp/models.py b/adr_kit/mcp/models.py index 27ef4b9..6d6172b 100644 --- a/adr_kit/mcp/models.py +++ b/adr_kit/mcp/models.py @@ -5,6 +5,14 @@ from pydantic import BaseModel, Field +from ..context.models import ( + ChangeMode, + ContextScenario, + DetailLevel, + ScopeHint, + TargetRef, +) + class MCPStatus(str, Enum): """Standard status codes for MCP responses.""" @@ -160,8 +168,14 @@ class SupersedeADRRequest(BaseModel): class PlanningContextRequest(BaseModel): - """Parameters for architectural context for agent tasks.""" + """Parameters for architectural context for agent tasks. + + Legacy fields (task_description, context_type, domain_hints, priority_level) are + preserved for backward compatibility. New callers should also supply the scenario + fields — if omitted, scenario defaults to STRATEGIC_PLANNING. + """ + # --- legacy fields (unchanged, all existing callers continue to work) --- task_description: str = Field( ..., description="Description of what the agent is trying to do" ) @@ -179,6 +193,30 @@ class PlanningContextRequest(BaseModel): ) adr_dir: str = Field("docs/adr", description="ADR directory path") + # --- scenario fields (all optional, backward-compat defaults) --- + scenario: ContextScenario = Field( + ContextScenario.STRATEGIC_PLANNING, + description=( + "Context scenario type; inferred as STRATEGIC_PLANNING when omitted" + ), + ) + change_mode: ChangeMode | None = Field( + None, + description="How the caller intends to change the codebase (optional)", + ) + detail_level: DetailLevel = Field( + DetailLevel.STANDARD, + description="How much detail to include in the response", + ) + scope: ScopeHint | None = Field( + None, + description="Typed scope signal for narrowing retrieval (optional)", + ) + target: TargetRef | None = Field( + None, + description="Typed reference to a specific ADR or clause to include (optional)", + ) + class DecisionGuidanceRequest(BaseModel): """Parameters for getting decision quality guidance.""" diff --git a/tests/unit/test_context_scenario_models.py b/tests/unit/test_context_scenario_models.py new file mode 100644 index 0000000..1a19f10 --- /dev/null +++ b/tests/unit/test_context_scenario_models.py @@ -0,0 +1,234 @@ +"""Unit tests for SCN scenario taxonomy models in adr_kit.context.models.""" + +import pytest + +from adr_kit.context.models import ( + ChangeMode, + ConstraintSummary, + ContextRequest, + ContextScenario, + DetailLevel, + InspectReference, + PacketMetadata, + ScenarioContextPacket, + ScopeHint, + TargetRef, +) + + +class TestContextScenario: + def test_all_values_are_strings(self): + for member in ContextScenario: + assert isinstance(member.value, str) + + def test_round_trip_via_value(self): + for member in ContextScenario: + assert ContextScenario(member.value) is member + + def test_four_scenarios_defined(self): + assert len(ContextScenario) == 4 + + def test_expected_values(self): + assert ContextScenario.STRATEGIC_PLANNING == "strategic_planning" + assert ContextScenario.FOCUSED_IMPLEMENTATION == "focused_implementation" + assert ContextScenario.PRE_DECISION == "pre_decision" + assert ContextScenario.SUPERSESSION_IMPACT == "supersession_impact" + + +class TestChangeMode: + def test_all_values_are_strings(self): + for member in ChangeMode: + assert isinstance(member.value, str) + + def test_expected_values(self): + assert ChangeMode.NONE == "none" + assert ChangeMode.ADDITIVE == "additive" + assert ChangeMode.MODIFYING == "modifying" + assert ChangeMode.REPLACING == "replacing" + + +class TestDetailLevel: + def test_all_values_are_strings(self): + for member in DetailLevel: + assert isinstance(member.value, str) + + def test_expected_values(self): + assert DetailLevel.MINIMAL == "minimal" + assert DetailLevel.STANDARD == "standard" + assert DetailLevel.DETAILED == "detailed" + + +class TestScopeHint: + def test_constructs_with_required_fields(self): + hint = ScopeHint(hint_type="file_path", value="src/auth/") + assert hint.hint_type == "file_path" + assert hint.value == "src/auth/" + + def test_serialises(self): + hint = ScopeHint(hint_type="domain", value="authentication") + d = hint.model_dump() + assert d == {"hint_type": "domain", "value": "authentication"} + + +class TestTargetRef: + def test_constructs_with_required_fields(self): + ref = TargetRef(ref_type="adr_id", ref_id="ADR-003") + assert ref.ref_type == "adr_id" + assert ref.ref_id == "ADR-003" + + def test_serialises(self): + ref = TargetRef(ref_type="clause_id", ref_id="a1b2c3d4") + d = ref.model_dump() + assert d == {"ref_type": "clause_id", "ref_id": "a1b2c3d4"} + + +class TestContextRequest: + def test_minimal_construction(self): + req = ContextRequest(task_summary="implement auth module") + assert req.task_summary == "implement auth module" + assert req.scenario == ContextScenario.STRATEGIC_PLANNING + assert req.change_mode == ChangeMode.NONE + assert req.detail_level == DetailLevel.STANDARD + assert req.scope_hints == [] + assert req.known_targets == [] + assert req.focus == "" + + def test_full_construction(self): + req = ContextRequest( + scenario=ContextScenario.SUPERSESSION_IMPACT, + task_summary="replace logging library", + scope_hints=[ScopeHint(hint_type="tag", value="logging")], + change_mode=ChangeMode.REPLACING, + focus="structured logging", + known_targets=[TargetRef(ref_type="adr_id", ref_id="ADR-005")], + detail_level=DetailLevel.DETAILED, + ) + assert req.scenario == ContextScenario.SUPERSESSION_IMPACT + assert req.change_mode == ChangeMode.REPLACING + assert req.detail_level == DetailLevel.DETAILED + assert len(req.scope_hints) == 1 + assert len(req.known_targets) == 1 + + def test_round_trip_serialisation(self): + req = ContextRequest( + scenario=ContextScenario.FOCUSED_IMPLEMENTATION, + task_summary="add rate limiting", + change_mode=ChangeMode.ADDITIVE, + ) + dumped = req.model_dump() + restored = ContextRequest.model_validate(dumped) + assert restored.scenario == req.scenario + assert restored.task_summary == req.task_summary + assert restored.change_mode == req.change_mode + + def test_scenario_serialised_as_string(self): + req = ContextRequest(task_summary="x") + d = req.model_dump() + assert isinstance(d["scenario"], str) + assert d["scenario"] == "strategic_planning" + + def test_task_summary_is_required(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ContextRequest() # type: ignore[call-arg] + + +class TestConstraintSummary: + def test_minimal_construction(self): + cs = ConstraintSummary( + source_adr="ADR-001", summary="no raw SQL", relevance_score=0.9 + ) + assert cs.source_adr == "ADR-001" + assert cs.summary == "no raw SQL" + assert cs.relevance_score == 0.9 + assert cs.clause_id is None + assert cs.domain is None + + def test_full_construction(self): + cs = ConstraintSummary( + clause_id="abc123", + source_adr="ADR-002", + summary="use ORM only", + relevance_score=0.75, + domain="persistence", + ) + assert cs.clause_id == "abc123" + assert cs.domain == "persistence" + + def test_serialises(self): + cs = ConstraintSummary( + source_adr="ADR-003", summary="rule", relevance_score=0.5 + ) + d = cs.model_dump() + assert "source_adr" in d + assert "relevance_score" in d + + +class TestInspectReference: + def test_constructs_with_required_fields(self): + ref = InspectReference( + ref_type="adr", ref_id="ADR-003", label="Authentication Decision" + ) + assert ref.ref_type == "adr" + assert ref.ref_id == "ADR-003" + assert ref.label == "Authentication Decision" + + def test_serialises(self): + ref = InspectReference(ref_type="clause", ref_id="xyz", label="rate-limit rule") + d = ref.model_dump() + assert d == {"ref_type": "clause", "ref_id": "xyz", "label": "rate-limit rule"} + + +class TestScenarioContextPacket: + def test_minimal_construction(self): + pkt = ScenarioContextPacket( + scenario=ContextScenario.STRATEGIC_PLANNING, + overview="System uses layered architecture.", + ) + assert pkt.scenario == ContextScenario.STRATEGIC_PLANNING + assert pkt.overview == "System uses layered architecture." + assert pkt.constraints == [] + assert pkt.warnings == [] + assert pkt.inspect_deeper == [] + assert pkt.metadata is None + + def test_full_construction(self): + pkt = ScenarioContextPacket( + scenario=ContextScenario.SUPERSESSION_IMPACT, + overview="This change affects auth.", + constraints=[ + ConstraintSummary( + source_adr="ADR-001", summary="rule", relevance_score=0.8 + ) + ], + warnings=["Breaking change to session tokens"], + inspect_deeper=[ + InspectReference( + ref_type="adr", ref_id="ADR-001", label="Auth Decision" + ) + ], + metadata=PacketMetadata( + token_estimate=500, candidate_count=12, ranking_strategy="semantic" + ), + ) + assert len(pkt.constraints) == 1 + assert len(pkt.warnings) == 1 + assert len(pkt.inspect_deeper) == 1 + assert pkt.metadata is not None + assert pkt.metadata.token_estimate == 500 + + def test_scenario_serialised_as_string(self): + pkt = ScenarioContextPacket( + scenario=ContextScenario.PRE_DECISION, + overview="Before committing.", + ) + d = pkt.model_dump() + assert isinstance(d["scenario"], str) + assert d["scenario"] == "pre_decision" + + def test_scenario_required(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ScenarioContextPacket(overview="x") # type: ignore[call-arg] diff --git a/tests/unit/test_planning_context_request.py b/tests/unit/test_planning_context_request.py new file mode 100644 index 0000000..e5b7c4a --- /dev/null +++ b/tests/unit/test_planning_context_request.py @@ -0,0 +1,128 @@ +"""Unit tests for the extended PlanningContextRequest model (SCN backward compat).""" + +import json + +from adr_kit.context.models import ( + ChangeMode, + ContextScenario, + DetailLevel, + ScopeHint, + TargetRef, +) +from adr_kit.mcp.models import PlanningContextRequest + + +class TestPlanningContextRequestBackwardCompat: + def test_legacy_call_succeeds(self): + req = PlanningContextRequest( + task_description="Implement user authentication", adr_dir="docs/adr" + ) + assert req.task_description == "Implement user authentication" + assert req.adr_dir == "docs/adr" + + def test_legacy_call_infers_strategic_planning(self): + req = PlanningContextRequest(task_description="x") + assert req.scenario == ContextScenario.STRATEGIC_PLANNING + + def test_legacy_context_type_preserved(self): + req = PlanningContextRequest( + task_description="refactor auth", context_type="refactoring" + ) + assert req.context_type == "refactoring" + + def test_legacy_domain_hints_preserved(self): + req = PlanningContextRequest( + task_description="x", domain_hints=["backend", "security"] + ) + assert req.domain_hints == ["backend", "security"] + + def test_legacy_priority_level_preserved(self): + req = PlanningContextRequest(task_description="x", priority_level="high") + assert req.priority_level == "high" + + def test_legacy_defaults(self): + req = PlanningContextRequest(task_description="x") + assert req.context_type == "implementation" + assert req.domain_hints == [] + assert req.priority_level == "normal" + assert req.adr_dir == "docs/adr" + + +class TestPlanningContextRequestNewFields: + def test_new_scenario_field_accepted(self): + req = PlanningContextRequest( + task_description="evaluate replacing auth library", + scenario=ContextScenario.SUPERSESSION_IMPACT, + ) + assert req.scenario == ContextScenario.SUPERSESSION_IMPACT + + def test_change_mode_accepted(self): + req = PlanningContextRequest( + task_description="x", + scenario=ContextScenario.FOCUSED_IMPLEMENTATION, + change_mode=ChangeMode.MODIFYING, + ) + assert req.change_mode == ChangeMode.MODIFYING + + def test_detail_level_accepted(self): + req = PlanningContextRequest( + task_description="x", detail_level=DetailLevel.DETAILED + ) + assert req.detail_level == DetailLevel.DETAILED + + def test_scope_hint_accepted(self): + req = PlanningContextRequest( + task_description="x", + scope=ScopeHint(hint_type="file_path", value="src/auth/"), + ) + assert req.scope is not None + assert req.scope.value == "src/auth/" + + def test_target_ref_accepted(self): + req = PlanningContextRequest( + task_description="x", + target=TargetRef(ref_type="adr_id", ref_id="ADR-003"), + ) + assert req.target is not None + assert req.target.ref_id == "ADR-003" + + def test_new_field_defaults(self): + req = PlanningContextRequest(task_description="x") + assert req.change_mode is None + assert req.detail_level == DetailLevel.STANDARD + assert req.scope is None + assert req.target is None + + +class TestPlanningContextRequestSerialisation: + def test_model_dump_includes_all_fields(self): + req = PlanningContextRequest(task_description="x") + d = req.model_dump() + legacy_fields = { + "task_description", + "context_type", + "domain_hints", + "priority_level", + "adr_dir", + } + new_fields = {"scenario", "change_mode", "detail_level", "scope", "target"} + assert legacy_fields.issubset(d.keys()) + assert new_fields.issubset(d.keys()) + + def test_model_dump_is_json_serialisable(self): + req = PlanningContextRequest( + task_description="plan auth refactor", + scenario=ContextScenario.PRE_DECISION, + change_mode=ChangeMode.REPLACING, + scope=ScopeHint(hint_type="domain", value="auth"), + ) + # Should not raise + json.dumps(req.model_dump()) + + def test_scenario_serialised_as_string(self): + req = PlanningContextRequest( + task_description="x", scenario=ContextScenario.FOCUSED_IMPLEMENTATION + ) + d = req.model_dump() + assert isinstance(d["scenario"], str) + assert d["scenario"] == "focused_implementation" From f67bc3478f7eba58ff9127dd3941665de943403b Mon Sep 17 00:00:00 2001 From: kschlt Date: Sat, 4 Apr 2026 17:23:23 +0200 Subject: [PATCH 2/2] chore: update CHANGELOG for context projection API (CID scenario taxonomy) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f53f21..152e0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Scenario taxonomy for `adr_planning_context` — four named scenarios (`strategic_planning`, `focused_implementation`, `pre_decision`, `supersession_impact`) replace the previous free-text `context_type` field; each scenario produces a different projection of the architecture contract +- `ContextRequest` model — structured request contract with typed `scope_hints`, `change_mode`, `detail_level`, and `known_targets`; existing callers passing only `task_description` continue to work unchanged (defaults to `strategic_planning`) +- `ScenarioContextPacket` response model — scenario-aware response shape with `overview`, ranked `constraints`, `warnings`, and `inspect_deeper` references to ADRs and clauses for progressive disclosure - `OutputMode` enum (`native_config`, `native_rules`, `generated_checker`, `policy_file`, `script_fallback`) and `EnforcementStage` enum (`commit`, `push`, `ci`) — enforcement adapters now declare what kind of artifact they emit and at which gate it is evaluated, making the enforcement plane's output taxonomy explicit and inspectable - `FallbackAdapter` — unroutable policy keys now flow through a standard adapter interface rather than a pipeline side path; fallback promptlets appear in `EnforcementResult.fragments_applied` with `output_mode="script_fallback"` alongside native-config fragments, making the full enforcement picture visible in one place - `output_mode` field on `EnforcementResult.fragments_applied` entries — each applied fragment now reports its output mode, enabling callers to distinguish native enforcement artifacts from agent-authored validation scripts