Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions agent_baton/core/manager/context_bundles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions agent_baton/core/manager/knowledge_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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,
Expand All @@ -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: <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] = {}
Expand Down Expand Up @@ -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()),
Expand Down
38 changes: 34 additions & 4 deletions agent_baton/core/manager/phase_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -236,15 +244,37 @@ 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,
gates_mode=gates_mode,
injected_review_steps=injected,
final_review_step=final_review_step,
gate_scope_applied=gate_scope_applied,
gates_stripped=gates_stripped,
)
13 changes: 11 additions & 2 deletions agent_baton/core/manager/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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

Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 14 additions & 6 deletions docs/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 40 additions & 7 deletions tests/e2e/test_manager_mode_execution_dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
8 changes: 8 additions & 0 deletions tests/e2e/test_manager_mode_planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
71 changes: 71 additions & 0 deletions tests/manager/test_knowledge_packs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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)."""
Expand Down
Loading