diff --git a/agent_baton/core/manager/context_bundles.py b/agent_baton/core/manager/context_bundles.py index e033add..cfdf5f2 100644 --- a/agent_baton/core/manager/context_bundles.py +++ b/agent_baton/core/manager/context_bundles.py @@ -476,8 +476,17 @@ def _build_knowledge_packs( if name in packs_by_name: packs.append(packs_by_name[name]) else: - reason = "required" if name in required else "step attachment" - packs.append(KnowledgePackReference(name=name, reason=reason)) + # bd-t8u: a pack name the knowledge plan never selected + # (canonical case: a role-required pack absent from the + # registry, e.g. review-rubric) must NOT attach as a phantom + # reference (path="", token_estimate=0) -- the knowledge + # plan already reports it under ``missing_packs``; surface + # it on the bundle as a warning naming the pack instead. + origin = "role-required" if name in required else "step-attached" + truncation_warnings.append( + f"Missing knowledge pack: {name} " + f"({origin}; not in knowledge plan selected_packs)" + ) return packs @staticmethod diff --git a/agent_baton/core/manager/knowledge_plan.py b/agent_baton/core/manager/knowledge_plan.py index be1cfbe..6c12109 100644 --- a/agent_baton/core/manager/knowledge_plan.py +++ b/agent_baton/core/manager/knowledge_plan.py @@ -106,7 +106,10 @@ class KnowledgePlanBuilder: ``missing_packs`` lists every ``default_packs``/``required_for_code_steps`` name absent from the registry (reason ``"config: default_packs"`` or - ``"config: required_for_code_steps"``). ``stale_packs`` lists every + ``"config: required_for_code_steps"``), plus every role-card + ``required_knowledge_packs`` name absent from the registry (reason + ``"role: "`` -- see :meth:`build`'s keyword-only + ``role_required_packs``, bd-t8u). ``stale_packs`` lists every *registered* pack (not just selected ones — staleness is a manager-wide signal, spec §4.4 "track source/confidence/staleness") whose ``last_reviewed`` + effective ``stale_after_days`` (pack-level value, @@ -118,7 +121,22 @@ def __init__(self, config: "ManagerConfig", registry: "KnowledgeRegistry") -> No self._config = config self._registry = registry - def build(self, plan: "MachinePlan", blueprint_roles: list[str]) -> KnowledgePlan: + def build( + self, + plan: "MachinePlan", + blueprint_roles: list[str], + *, + role_required_packs: dict[str, list[str]] | None = None, + ) -> KnowledgePlan: + """Build the plan-wide :class:`KnowledgePlan`. + + The ``(plan, blueprint_roles)`` positional signature is frozen -- + extras are keyword-only. *role_required_packs* maps each role to + its role card's ``required_knowledge_packs`` (bd-t8u): names the + registry has are selected (reason ``"role: "``); names it + does not have are reported in ``missing_packs`` with the same + reason instead of silently becoming phantom references downstream. + """ kp_config = self._config.knowledge_packs selected: dict[str, KnowledgePackReference] = {} @@ -188,6 +206,23 @@ def build(self, plan: "MachinePlan", blueprint_roles: list[str]) -> KnowledgePla pack.name, reason=f"role: {role}" ) + # Role-card hard requirements (bd-t8u): registry-present names are + # selected so downstream bundle building resolves them to real + # (path-bearing) references even when the pack's ``target_agents`` + # doesn't name the role; registry-absent names are reported missing + # (deduped against config-driven missing entries above -- first + # reason wins). + for role, names in (role_required_packs or {}).items(): + for name in names: + if self._registry.get_pack(name) is None: + if name not in seen_missing: + seen_missing.add(name) + missing.append( + MissingKnowledgePack(name=name, reason=f"role: {role}") + ) + elif name not in selected: + selected[name] = self._make_reference(name, reason=f"role: {role}") + return KnowledgePlan( task_id=plan.task_id, selected_packs=list(selected.values()), diff --git a/agent_baton/core/manager/phase_policy.py b/agent_baton/core/manager/phase_policy.py index 485e191..cf02416 100644 --- a/agent_baton/core/manager/phase_policy.py +++ b/agent_baton/core/manager/phase_policy.py @@ -6,8 +6,12 @@ ``IntelligentPlanner.create_plan()`` -- it injects adversarial-review steps per ``policies.phase_completion.adversarial_review`` / ``policies.project_completion.adversarial_review`` and (when the CLI did -not pin an explicit ``--gate-scope``) rescales existing phase gates to -``gates.gate_scope``. Everything else the PMO layer produces (charter, +not pin an explicit ``--gate-scope``) enforces ``gates.mode`` (bd-6dn): +``project_configured`` rescales existing phase gates to +``gates.gate_scope``, ``focused``/``full``/``smoke`` force that scope +directly (ignoring ``gates.gate_scope``), and ``off`` strips every phase +gate. ``gates.allow_smoke_fallback`` and ``gates.missing_gate_policy`` +remain record-only (ADR-25). Everything else the PMO layer produces (charter, scope map, blueprint, context bundles, ...) is a sidecar artifact that never touches the ``MachinePlan`` itself. @@ -70,6 +74,10 @@ class PolicyDecisions(BaseModel): injected_review_steps: list[str] = Field(default_factory=list) final_review_step: str | None = None gate_scope_applied: str | None = None + # Phase ids (stringified) whose gate was removed because + # ``gates.mode == "off"`` (bd-6dn). Empty for every other mode and + # whenever the CLI pinned an explicit ``--gate-scope``. + gates_stripped: list[str] = Field(default_factory=list) def _phase_review_step_id(phase_id: int) -> str: @@ -236,10 +244,31 @@ def apply(self, plan: MachinePlan, *, cli_gate_scope_explicit: bool) -> PolicyDe ) final_review_step = final_id + # gates.mode enforcement (bd-6dn). An explicit CLI --gate-scope + # always wins: when the operator pinned a scope on the command + # line, this applier never touches gates -- no rescope, no strip. gates_mode = config.gates.mode gate_scope_applied: str | None = None - if not cli_gate_scope_explicit and gates_mode == "project_configured": - gate_scope_applied = _apply_gate_scope(plan, config.gates.gate_scope) + gates_stripped: list[str] = [] + if not cli_gate_scope_explicit: + if gates_mode == "project_configured": + gate_scope_applied = _apply_gate_scope(plan, config.gates.gate_scope) + elif gates_mode in ("focused", "full", "smoke"): + # The mode names a scope directly: force it exactly as if + # it were the project-configured gate_scope. Same fidelity + # rules as above -- "focused" is a no-op inside + # _apply_gate_scope (planner gates win), "full"/"smoke" + # rescope via default_gate with detected_stack threaded. + gate_scope_applied = _apply_gate_scope(plan, gates_mode) + elif gates_mode == "off": + # Strip every phase gate (PlanPhase.gate is Optional, so + # the plan stays round-trip valid). Record which phases + # actually lost a gate so `baton team`/`baton report` can + # surface the decision. + for phase in plan.phases: + if phase.gate is not None: + phase.gate = None + gates_stripped.append(str(phase.phase_id)) return PolicyDecisions( handoff_required=config.policies.phase_completion.handoff_required, @@ -247,4 +276,5 @@ def apply(self, plan: MachinePlan, *, cli_gate_scope_explicit: bool) -> PolicyDe injected_review_steps=injected, final_review_step=final_review_step, gate_scope_applied=gate_scope_applied, + gates_stripped=gates_stripped, ) diff --git a/agent_baton/core/manager/planner.py b/agent_baton/core/manager/planner.py index a2cd43f..e82eb47 100644 --- a/agent_baton/core/manager/planner.py +++ b/agent_baton/core/manager/planner.py @@ -203,9 +203,18 @@ def _compose( # docstring): injected review steps do not exist yet, so they can # never pick up `required_for_code_steps` packs via per-step # attachment; they get knowledge exclusively through their role - # card's `required_knowledge_packs`. + # card's `required_knowledge_packs`. Those role-card requirements + # are threaded in as `role_required_packs` (bd-t8u) so a hard- + # required pack the registry lacks (e.g. review-rubric) is reported + # in `missing_packs` with a `role: ` reason instead of + # surfacing downstream as a phantom bundle reference. knowledge_plan = KnowledgePlanBuilder(config, self._registry()).build( - plan, [card.role for card in blueprint.roles] + plan, + [card.role for card in blueprint.roles], + role_required_packs={ + card.role: list(card.required_knowledge_packs) + for card in blueprint.roles + }, ) artifacts.knowledge_plan = knowledge_plan diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8c2cbd4..d040057 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -89,7 +89,7 @@ baton plan SUMMARY [options] - **`--manager-mode --dry-run`**: builds the same artifact set in memory only and prints it as a preview list (`Manager Mode artifacts (preview only -- nothing written)`) alongside the compact plan forecast; nothing touches disk. `--json --dry-run --manager-mode` includes the preview under a `manager_mode_artifacts` key. - **`--manager-mode` alone** (no `--save`, no `--dry-run`): prints markdown/JSON like a normal plan with `manager_mode: true` stamped on it, but builds no PMO artifacts. - **`--manager-mode --explain --save`**: appends a `## Manager Mode` section (workstreams + owners, team roles, and the effective adversarial-review/handoff/gate-scope policy) to `explanation.md`. -- **`--manager-mode` + `--gate-scope`**: an explicit `--gate-scope` always wins over the manager config's `gates.gate_scope`. Left unset, gates are rescoped to `gates.gate_scope` only when `gates.mode` (in `.claude/baton.yaml`) is `project_configured` (the default); other `gates.mode` values are recorded on the blueprint but do not change gate commands this increment. +- **`--manager-mode` + `--gate-scope`**: an explicit `--gate-scope` always wins over the manager config — no rescope, no strip. Left unset, `gates.mode` (in `.claude/baton.yaml`) is enforced: `project_configured` (the default) rescopes gates to `gates.gate_scope`; `focused`/`full`/`smoke` force that scope directly; `off` strips every phase gate (stripped phase ids are recorded on the blueprint). A malformed `.claude/baton.yaml` manager section is a hard error only when `--manager-mode` was explicitly passed; otherwise it downgrades to a warning and manager mode stays off. See [docs/internal/manager-mode-pmo-design.md](internal/manager-mode-pmo-design.md) for the full artifact schema. diff --git a/docs/design-decisions.md b/docs/design-decisions.md index dc5e609..7237dc3 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -1329,13 +1329,21 @@ work, in the same file the registry actually reads. **Accepted debt** (this increment): -- `ManagerConfig.gates.mode` accepts `project_configured | focused | +- ~~`ManagerConfig.gates.mode` accepts `project_configured | focused | full | smoke | off`, but only `project_configured` (the default) has - behavior: `PhasePolicyApplier` rescopes phase gates to - `gates.gate_scope` only when `mode == "project_configured"` and the - CLI did not pass an explicit `--gate-scope`. Other `mode` values are - recorded on `PolicyDecisions.gates_mode` (surfaced via `baton team`/ - `baton report`) but do not change gate commands yet. + behavior.~~ **Paid (bd-6dn, 2026-07-02)**: `PhasePolicyApplier` now + enforces every `gates.mode` value when the CLI did not pass an + explicit `--gate-scope` (an explicit `--gate-scope` still always + wins): `project_configured` rescopes phase gates to + `gates.gate_scope`; `focused`/`full`/`smoke` force that scope directly + (same fidelity rules — `focused` leaves planner gates untouched, + `full`/`smoke` rescope via `default_gate` with `detected_stack` + threaded); `off` strips every phase gate (`PlanPhase.gate` is + Optional, so the plan stays round-trip valid) and records the + stripped phase ids on `PolicyDecisions.gates_stripped`. Remaining + record-only debt: `gates.allow_smoke_fallback` and + `gates.missing_gate_policy` are validated and recorded but have no + runtime behavior yet. - Review steps injected by `PhasePolicyApplier` use the existing `step_type: "reviewing"` value from `planning/rules/step_types.py::AGENT_STEP_TYPE` — no new step type was diff --git a/tests/e2e/test_manager_mode_execution_dry_run.py b/tests/e2e/test_manager_mode_execution_dry_run.py index 9f4e311..8242435 100644 --- a/tests/e2e/test_manager_mode_execution_dry_run.py +++ b/tests/e2e/test_manager_mode_execution_dry_run.py @@ -102,7 +102,13 @@ def _routing_plan(task_id: str) -> MachinePlan: ) -def _build_manager_sidecars(plan: MachinePlan, project_root: Path, ctx_dir: Path) -> None: +def _build_manager_sidecars( + plan: MachinePlan, + project_root: Path, + ctx_dir: Path, + *, + knowledge_registry: KnowledgeRegistry | None = None, +) -> None: """Run the real ManagerModePlanner post-processor over *plan*, writing every PMO sidecar (including injecting review steps -- see PhasePolicyApplier) and mutating *plan* in place, mirroring @@ -111,11 +117,34 @@ def _build_manager_sidecars(plan: MachinePlan, project_root: Path, ctx_dir: Path ManagerConfig(), project_root=project_root, team_context_dir=ctx_dir, - knowledge_registry=KnowledgeRegistry(), + knowledge_registry=knowledge_registry or KnowledgeRegistry(), ) planner.build_and_write(plan, plan.task_summary) +def _registry_with_code_step_packs(root: Path) -> KnowledgeRegistry: + """A registry backing the default ``required_for_code_steps`` packs + (coding-conventions, testing-strategy) with real on-disk packs -- since + bd-t8u, registry-absent packs no longer attach to bundles as phantom + name-only refs, so tests that need pack refs in a bundle must supply a + real registry.""" + knowledge_root = root / ".claude" / "knowledge" + for pack_name in ("coding-conventions", "testing-strategy"): + pack_dir = knowledge_root / pack_name + pack_dir.mkdir(parents=True, exist_ok=True) + (pack_dir / "knowledge.yaml").write_text( + f"name: {pack_name}\ndescription: {pack_name} pack.\n", + encoding="utf-8", + ) + (pack_dir / "doc.md").write_text( + f"---\nname: {pack_name}-doc\ndescription: {pack_name} doc\n---\n\n# Doc\n", + encoding="utf-8", + ) + registry = KnowledgeRegistry() + registry.load_directory(knowledge_root) + return registry + + # --------------------------------------------------------------------------- # Engine construction (in-memory fake bead store -- avoids a real ``bd`` # subprocess call, which is unreliable across platforms/CI images; the @@ -270,14 +299,18 @@ def test_dispatch_prompt_context_bundle_has_pack_refs( ) -> None: """The role card for backend-engineer requires knowledge_packs.required_for_code_steps packs by default - (coding-conventions, testing-strategy) -- even with an empty - registry (no packs found on disk), the bundle still records pack - *names* as required/missing references, which is what the prompt - section renders (never full doc bodies).""" + (coding-conventions, testing-strategy) -- with a registry that has + them, the bundle records registry-backed pack references, which is + what the prompt section renders by name (never full doc bodies). + Since bd-t8u a registry-absent pack no longer produces a phantom + name-only ref, so this test supplies real packs on disk.""" task_id = "task-m9-dispatch-packs" plan = _single_phase_plan(task_id, manager_mode=True) ctx_dir = tmp_path / ".claude" / "team-context" - _build_manager_sidecars(plan, tmp_path, ctx_dir) + _build_manager_sidecars( + plan, tmp_path, ctx_dir, + knowledge_registry=_registry_with_code_step_packs(tmp_path), + ) paths = _paths(tmp_path, task_id) bundle_data = json.loads(paths.context_bundle("1.1").read_text(encoding="utf-8")) diff --git a/tests/e2e/test_manager_mode_planning.py b/tests/e2e/test_manager_mode_planning.py index ee3179c..efc92f7 100644 --- a/tests/e2e/test_manager_mode_planning.py +++ b/tests/e2e/test_manager_mode_planning.py @@ -170,6 +170,14 @@ def test_manager_mode_planning_produces_full_pmo_packet( assert "repo-architecture" in missing_names assert "testing-strategy" in missing_names assert "coding-conventions" not in missing_names + # bd-t8u: the review role cards hard-require review-rubric, which the + # fixture registry does not have -- reported with a role reason, not + # attached as a phantom (path="") bundle ref. + assert "review-rubric" in missing_names + rubric_missing = next( + p for p in artifacts.knowledge_plan.missing_packs if p.name == "review-rubric" + ) + assert rubric_missing.reason.startswith("role: ") # manager-brief.md is readable with workstreams/team/policies. brief_text = paths.manager_brief.read_text(encoding="utf-8") diff --git a/tests/manager/test_knowledge_packs.py b/tests/manager/test_knowledge_packs.py index b49f400..ed1ab64 100644 --- a/tests/manager/test_knowledge_packs.py +++ b/tests/manager/test_knowledge_packs.py @@ -363,6 +363,77 @@ def test_default_packs_selected_when_present(tmp_path: Path) -> None: assert selected_names.get("repo-architecture") == "config: default_packs" +def test_role_required_pack_absent_reported_missing(tmp_path: Path) -> None: + """bd-t8u: a role card's ``required_knowledge_packs`` entry absent from + the registry must land in ``missing_packs`` with a ``role: `` + reason -- never silently become a phantom selected ref.""" + registry = KnowledgeRegistry() # empty -- nothing on disk + config = _make_manager_config(tmp_path, "knowledge_packs:\n default_packs: []\n") + plan = _make_plan() + + result = KnowledgePlanBuilder(config, registry).build( + plan, + blueprint_roles=["code-reviewer"], + role_required_packs={"code-reviewer": ["review-rubric"]}, + ) + + missing_by_name = {m.name: m.reason for m in result.missing_packs} + assert missing_by_name.get("review-rubric") == "role: code-reviewer" + assert "review-rubric" not in {p.name for p in result.selected_packs} + + +def test_role_required_pack_missing_dedupes(tmp_path: Path) -> None: + """A name already reported missing (config reason, or an earlier role) + is never duplicated -- first reason wins.""" + registry = KnowledgeRegistry() + config = _make_manager_config( + tmp_path, + "knowledge_packs:\n" + " default_packs: []\n" + " required_for_code_steps:\n" + " - coding-conventions\n", + ) + plan = _make_plan() + + result = KnowledgePlanBuilder(config, registry).build( + plan, + blueprint_roles=["backend-engineer", "code-reviewer", "auditor"], + role_required_packs={ + "backend-engineer": ["coding-conventions"], + "code-reviewer": ["review-rubric"], + "auditor": ["review-rubric"], + }, + ) + + names = [m.name for m in result.missing_packs] + assert names.count("coding-conventions") == 1 + assert names.count("review-rubric") == 1 + missing_by_name = {m.name: m.reason for m in result.missing_packs} + assert missing_by_name["coding-conventions"] == "config: required_for_code_steps" + assert missing_by_name["review-rubric"] == "role: code-reviewer" + + +def test_role_required_pack_present_selected_with_real_path(tmp_path: Path) -> None: + """A role-required pack the registry DOES have is selected with real + registry metadata (non-empty path), not reported missing -- even when + its manifest's ``target_agents`` does not name the role.""" + registry = _make_registry(tmp_path, {"review-rubric": "name: review-rubric\n"}) + config = _make_manager_config(tmp_path, "knowledge_packs:\n default_packs: []\n") + plan = _make_plan() + + result = KnowledgePlanBuilder(config, registry).build( + plan, + blueprint_roles=["code-reviewer"], + role_required_packs={"code-reviewer": ["review-rubric"]}, + ) + + selected = {p.name: p for p in result.selected_packs} + assert "review-rubric" in selected + assert selected["review-rubric"].path + assert selected["review-rubric"].reason == "role: code-reviewer" + assert "review-rubric" not in {m.name for m in result.missing_packs} + + def test_knowledge_plan_round_trips_through_write_all(tmp_path: Path) -> None: """Sanity: the builder's output satisfies the frozen KnowledgePlan model (round-trips via to_dict/from_dict, as write_all will do).""" diff --git a/tests/manager/test_manager_mode_planner.py b/tests/manager/test_manager_mode_planner.py index 78d0931..a944f3d 100644 --- a/tests/manager/test_manager_mode_planner.py +++ b/tests/manager/test_manager_mode_planner.py @@ -198,7 +198,11 @@ def test_review_steps_use_review_role_card_not_phase_owner(tmp_path: Path) -> No assert contract.agent_name == review_agent bundle = artifacts.context_bundles[review_step_id] assert bundle.agent_name == review_agent - assert any(p.name == "review-rubric" for p in bundle.knowledge_packs) + # Registry is empty here: the role-required review-rubric pack is + # NOT attached as a phantom ref (bd-t8u) -- it surfaces as a + # truncation warning instead (see + # test_missing_role_required_pack_reported_not_phantom). + assert any("review-rubric" in w for w in bundle.truncation_warnings) # The implementation steps still resolve to their own workstream owner. assert artifacts.scope_contracts["1.1"].agent_name == "backend-engineer" @@ -299,6 +303,52 @@ def test_review_bundle_integration(tmp_path: Path) -> None: rubric_refs = [p for p in bundle.knowledge_packs if p.name == "review-rubric"] assert rubric_refs, f"{review_step_id} missing review-rubric pack ref" assert rubric_refs[0].path # registry-backed, not a bare placeholder + # ...and no missing-pack warning fires for it (bd-t8u complement). + assert not any("review-rubric" in w for w in bundle.truncation_warnings) + assert "review-rubric" not in { + m.name for m in artifacts.knowledge_plan.missing_packs + } + + +# --------------------------------------------------------------------------- +# test_missing_role_required_pack_reported_not_phantom (bd-t8u) +# --------------------------------------------------------------------------- + + +def test_missing_role_required_pack_reported_not_phantom(tmp_path: Path) -> None: + """Debt-ledger fix (bd-t8u): a role card's hard-required pack that the + registry does not have (canonical case: ``review-rubric`` required by + the review role cards, empty registry) must: + + 1. appear in ``knowledge_plan.missing_packs`` with a ``role: `` + reason, and + 2. NOT attach to review-step context bundles as a phantom ref + (``path=""``, ``token_estimate=0``) -- instead each affected bundle + carries a truncation-warnings note naming the pack. + """ + plan = _two_phase_plan() + config = ManagerConfig() + planner = _planner(tmp_path, config=config) # empty registry + + artifacts = planner.build(plan, plan.task_summary) + + missing_by_name = { + m.name: m.reason for m in artifacts.knowledge_plan.missing_packs + } + phase_review_agent = config.policies.review_agents.adversarial_review + assert missing_by_name.get("review-rubric") == f"role: {phase_review_agent}" + + for review_step_id in ("review-1", "review-2", "review-2-final"): + bundle = artifacts.context_bundles[review_step_id] + assert all(p.name != "review-rubric" for p in bundle.knowledge_packs), ( + f"{review_step_id} still carries a phantom review-rubric ref" + ) + assert all(p.path for p in bundle.knowledge_packs), ( + f"{review_step_id} carries an empty-path (phantom) pack ref" + ) + assert any("review-rubric" in w for w in bundle.truncation_warnings), ( + f"{review_step_id} bundle lacks a warning naming review-rubric" + ) # --------------------------------------------------------------------------- diff --git a/tests/manager/test_phase_policy.py b/tests/manager/test_phase_policy.py index 00f7902..1f9afec 100644 --- a/tests/manager/test_phase_policy.py +++ b/tests/manager/test_phase_policy.py @@ -8,6 +8,8 @@ import json +import pytest + from agent_baton.core.config.manager import ManagerConfig from agent_baton.core.manager.phase_policy import PhasePolicyApplier, PolicyDecisions from agent_baton.models.execution import MachinePlan, PlanGate, PlanPhase, PlanStep @@ -398,6 +400,164 @@ def test_gate_scope_full_threads_detected_stack_into_default_gate() -> None: _round_trip(plan) +def _plan_with_original_gate(*, detected_stack: str | None = "python") -> MachinePlan: + """Two phases: phase 1 carries a planner-built gate, phase 2 is gate-less.""" + return _make_plan( + detected_stack=detected_stack, + phases=[ + PlanPhase( + phase_id=1, + name="Implementation", + steps=[ + PlanStep( + step_id="1.1", + agent_name="backend-engineer", + task_description="Do the work.", + deliverables=["work"], + allowed_paths=["app/a.py"], + ), + ], + gate=PlanGate( + gate_type="build", + command="echo original-gate", + description="original description", + fail_on=["original failure"], + ), + ), + PlanPhase( + phase_id=2, + name="Docs", + steps=[ + PlanStep( + step_id="2.1", + agent_name="documentation-engineer", + task_description="Write the docs.", + deliverables=["docs"], + allowed_paths=["docs/a.md"], + depends_on=["1.1"], + ), + ], + ), + ], + ) + + +def test_mode_focused_is_noop_even_when_gate_scope_differs() -> None: + """bd-6dn: ``gates.mode == "focused"`` forces the focused scope, which + (per the Wave-2 F1 fidelity rule) means leaving the planner's gates + untouched -- even when ``gates.gate_scope`` says something else.""" + config = _no_review_config(gates={"mode": "focused", "gate_scope": "smoke"}) + plan = _plan_with_original_gate() + before = plan.to_dict() + + decisions = PhasePolicyApplier(config).apply(plan, cli_gate_scope_explicit=False) + + assert plan.to_dict() == before + assert decisions.gates_mode == "focused" + assert decisions.gate_scope_applied is None + assert decisions.gates_stripped == [] + + _round_trip(plan) + + +def test_mode_full_forces_full_scope_with_detected_stack() -> None: + """bd-6dn: ``gates.mode == "full"`` rescopes gates to full regardless of + ``gates.gate_scope``, threading ``plan.detected_stack`` into + ``default_gate`` (same fidelity rule as project_configured/full).""" + plan = _make_plan( + detected_stack="typescript", + phases=[ + PlanPhase( + phase_id=1, + name="Test", + steps=[ + PlanStep( + step_id="1.1", + agent_name="test-engineer", + task_description="Write tests for the widget.", + deliverables=["tests"], + allowed_paths=["src/widget.test.ts"], + ), + ], + gate=PlanGate( + gate_type="test", + command="pytest tests/test_widget.py", + description="original description", + fail_on=["original failure"], + ), + ), + ], + ) + config = _no_review_config(gates={"mode": "full", "gate_scope": "focused"}) + + decisions = PhasePolicyApplier(config).apply(plan, cli_gate_scope_explicit=False) + + gate = plan.phases[0].gate + assert gate.command == "npm test" + assert decisions.gates_mode == "full" + assert decisions.gate_scope_applied == "full" + assert decisions.gates_stripped == [] + + _round_trip(plan) + + +def test_mode_smoke_forces_smoke_scope() -> None: + """bd-6dn: ``gates.mode == "smoke"`` rescopes gates to smoke regardless + of ``gates.gate_scope``; ``gate_type`` and gate-less phases are left as + the planner decided them.""" + config = _no_review_config(gates={"mode": "smoke", "gate_scope": "full"}) + plan = _plan_with_original_gate() + + decisions = PhasePolicyApplier(config).apply(plan, cli_gate_scope_explicit=False) + + gate = plan.phases[0].gate + assert gate.command == 'python -c "import agent_baton; print(\'ok\')"' + assert gate.fail_on == ["import error"] + assert gate.gate_type == "build" + assert plan.phases[1].gate is None + assert decisions.gates_mode == "smoke" + assert decisions.gate_scope_applied == "smoke" + assert decisions.gates_stripped == [] + + _round_trip(plan) + + +def test_mode_off_strips_all_phase_gates() -> None: + """bd-6dn: ``gates.mode == "off"`` removes every phase gate, records the + stripped phase ids, and the mutated plan still round-trips.""" + config = _no_review_config(gates={"mode": "off"}) + plan = _plan_with_original_gate() + + decisions = PhasePolicyApplier(config).apply(plan, cli_gate_scope_explicit=False) + + assert all(phase.gate is None for phase in plan.phases) + assert decisions.gates_mode == "off" + assert decisions.gates_stripped == ["1"] + assert decisions.gate_scope_applied is None + + reloaded = _round_trip(plan) + assert all(phase.gate is None for phase in reloaded.phases) + + +@pytest.mark.parametrize( + "mode", ["project_configured", "focused", "full", "smoke", "off"] +) +def test_cli_explicit_gate_scope_wins_for_every_mode(mode: str) -> None: + """bd-6dn: an explicit CLI ``--gate-scope`` beats every ``gates.mode`` + value -- the applier must never touch gates (rescope OR strip) when + ``cli_gate_scope_explicit`` is set.""" + config = _no_review_config(gates={"mode": mode, "gate_scope": "smoke"}) + plan = _plan_with_original_gate() + before = plan.to_dict() + + decisions = PhasePolicyApplier(config).apply(plan, cli_gate_scope_explicit=True) + + assert plan.to_dict() == before + assert decisions.gates_mode == mode + assert decisions.gate_scope_applied is None + assert decisions.gates_stripped == [] + + def test_injected_review_steps_use_reviewing_step_type() -> None: """F1b: injected review steps (both phase reviews and the final project review) use ``step_type="reviewing"`` -- confirmed present in