Skip to content

Index

strands_teams

strands_teams — a domain-agnostic agent-teams framework on strands-agents.

This package hosts the generic team-dispatch, findings, artifact, scoring, hook, display, and UseCase-contract machinery shared across use cases. Concrete implementations (e.g. codebase analysis) live in separate workspace packages.

NO_CURRENT_REVISION module-attribute

NO_CURRENT_REVISION = '∅'

Sentinel expected_current_revision meaning "this logical_name has no current revision yet".

A first promotion of a logical_name passes this sentinel as expected_current_revision (spec 005 AC-2-4). It is deliberately a value no real revision_id (a hex content hash) can collide with.

Disposition module-attribute

Disposition = Literal[
    "open",
    "acknowledged",
    "remediated",
    "accepted_risk",
    "rejected",
    "superseded",
]

Lifecycle states a finding moves through as it is triaged and resolved.

EpistemicStatus module-attribute

EpistemicStatus = Literal[
    "observed",
    "inferred",
    "hypothesized",
    "disputed",
    "accepted",
    "rejected",
    "superseded",
]

The epistemic standing of a TeamEvent's claim (AC-1-2).

  • observed — a directly recorded fact (e.g. a dispatch completed).
  • inferred — derived from other events without direct observation.
  • hypothesized — a tentative claim awaiting confirmation.
  • disputed — contradicted by another event/actor.
  • accepted / rejected — an adjudicated outcome.
  • superseded — replaced by a later event (retained in the log, never deleted).

EvidenceType module-attribute

EvidenceType = Literal[
    "file_line", "test", "command", "artifact", "url", "log"
]

Kinds of evidence a finding can cite (file:line, a test, a command exit, an artifact revision, a URL, or a log excerpt).

GateName module-attribute

GateName = Literal[
    "capability",
    "separation_of_duties",
    "required_approvals",
    "open_blocking_finding",
    "human_required",
]

The ordered gate stack the PolicyEngine runs; the name of the gate that failed is reported on a denying :class:PolicyVerdict (AC-3-1).

HumanDecisionStatus module-attribute

HumanDecisionStatus = Literal[
    "pending", "resolved", "rejected"
]

Lifecycle of a :class:HumanDecision gate.

  • pending — awaiting a human; the runner suspends the transition (AC-4-3).
  • resolved — approved by a human; the runner resumes and re-authorizes (AC-4-4).
  • rejected — declined by a human; the runner abandons the transition (AC-4-6).

Severity module-attribute

Severity = Literal[
    "info", "low", "medium", "high", "critical"
]

Ordered severity levels for a finding.

DEFAULT_NARRATIVE_RUBRIC module-attribute

DEFAULT_NARRATIVE_RUBRIC = NarrativeRubric(
    dimensions=(
        RubricDimension(
            name="specificity",
            pattern=FILE_LINE_PATTERN,
            thresholds=(
                (0, 1.0),
                (5, 2.0),
                (10, 3.0),
                (20, 4.0),
                (30, 5.0),
            ),
            suggestion="Add more file:line references (currently {count}).",
        ),
        RubricDimension(
            name="structure",
            pattern=HEADING_PATTERN,
            flags=re.MULTILINE,
            thresholds=(
                (0, 1.0),
                (3, 2.0),
                (5, 3.0),
                (8, 4.0),
                (11, 5.0),
            ),
            suggestion="Add more section headings for structure (currently {count}).",
        ),
        RubricDimension(
            name="diagrams",
            pattern=MERMAID_PATTERN,
            flags=re.IGNORECASE,
            thresholds=((0, 1.0), (1, 3.0), (2, 5.0)),
            suggestion="Add at least one mermaid diagram for call flow or architecture.",
            weak_score=1.0,
        ),
        RubricDimension(
            name="depth",
            counter=count_lines,
            thresholds=(
                (0, 1.0),
                (20, 2.0),
                (50, 3.0),
                (100, 4.0),
                (200, 5.0),
            ),
            suggestion="Expand content depth (currently {count} lines).",
        ),
        RubricDimension(
            name="cross_references",
            pattern=FILE_PATH_PATTERN,
            unique=True,
            thresholds=(
                (0, 1.0),
                (3, 2.0),
                (8, 3.0),
                (15, 4.0),
                (25, 5.0),
            ),
            suggestion="Cross-reference more files/modules (currently {count} unique paths).",
        ),
    )
)

The default five-dimension narrative rubric (revision threshold 3.5).

SpecialistDisposition module-attribute

SpecialistDisposition = Literal[
    "completed", "partial", "failed", "refused"
]

How a specialist harness run concluded.

  • completed — the harness finished its task successfully.
  • partial — the harness ran to a limit (e.g. max turns) with usable but incomplete work.
  • failed — the harness errored, timed out, or exceeded its budget without a usable result.
  • refused — the harness declined the task (e.g. a safety refusal).

TEAM_TOOLS module-attribute

TEAM_TOOLS = (
    dispatch_team,
    read_team_findings,
    write_artifact,
    score_narrative,
    enrich_artifact,
)

The five coordinator team tools, in canonical order (consumed by the factory).

AsyncioDurableEngine

AsyncioDurableEngine(event_log)

In-process asyncio :class:DurableStep engine (AC-4-2..AC-4-5).

Runs each step's callable in the current event loop, emitting a result :class:~strands_teams.models.TeamEvent to the bound :class:~strands_teams.events.EventLog before returning (AC-4-3). Before running, it scans the log for an existing result event whose payload names the same step_id; if found, it returns that recorded result without re-running the callable — idempotent replay (AC-4-4). It never calls asyncio.run and awaits (rather than blocks on) its callables, so it is safe inside an already-running strands runtime (AC-4-5; research-coding-runtimes.yaml line 18).

Bind the engine to the run's :class:~strands_teams.events.EventLog.

Source code in packages/strands-teams/src/strands_teams/durable.py
def __init__(self, event_log: EventLog) -> None:
    """Bind the engine to the run's :class:`~strands_teams.events.EventLog`."""
    self._event_log = event_log

run_step async

run_step(
    step_id,
    step_type,
    fn,
    *,
    goal_id="",
    task_id="",
    thread_id="",
    actor="lead",
    causal_parents=None,
)

Execute (or replay) one durable step (AC-4-3, AC-4-4, AC-4-5).

If a result event for step_id already exists in the log, returns it without re-running fn (idempotent replay, AC-4-4). Otherwise awaits fn (which may be sync or async — an async callable is awaited, a sync one is called directly; neither blocks the loop via asyncio.run, AC-4-5), emits a step.result :class:~strands_teams.models.TeamEvent recording the transition and result to the log (AC-4-3), and returns the fresh :class:StepResult.

Parameters:

Name Type Description Default
step_id str

Stable idempotency key for the step within the goal run.

required
step_type str

The lead-loop transition (e.g. decompose).

required
fn Callable[[], Awaitable[dict[str, Any]] | dict[str, Any]]

A zero-arg callable producing the step's result payload dict; may be sync or async.

required
goal_id str

The goal this step belongs to.

''
task_id str

The task this step belongs to.

''
thread_id str

The thread this step belongs to.

''
actor str

Identifier of the step's executor (defaults to "lead").

'lead'
causal_parents list[str] | None

event_ids this step's event causally follows.

None

Returns:

Name Type Description
A StepResult

class:StepResult — replayed=True when returned from a recorded

StepResult

event, else the freshly executed result.

Source code in packages/strands-teams/src/strands_teams/durable.py
async def run_step(
    self,
    step_id: str,
    step_type: str,
    fn: Callable[[], Awaitable[dict[str, Any]] | dict[str, Any]],
    *,
    goal_id: str = "",
    task_id: str = "",
    thread_id: str = "",
    actor: str = "lead",
    causal_parents: list[str] | None = None,
) -> StepResult:
    """Execute (or replay) one durable step (AC-4-3, AC-4-4, AC-4-5).

    If a result event for ``step_id`` already exists in the log, returns it
    without re-running ``fn`` (idempotent replay, AC-4-4). Otherwise awaits
    ``fn`` (which may be sync or async — an async callable is awaited, a sync
    one is called directly; neither blocks the loop via ``asyncio.run``,
    AC-4-5), emits a ``step.result`` :class:`~strands_teams.models.TeamEvent`
    recording the transition and result to the log (AC-4-3), and returns the
    fresh :class:`StepResult`.

    Args:
        step_id: Stable idempotency key for the step within the goal run.
        step_type: The lead-loop transition (e.g. ``decompose``).
        fn: A zero-arg callable producing the step's result payload dict; may be
            sync or async.
        goal_id: The goal this step belongs to.
        task_id: The task this step belongs to.
        thread_id: The thread this step belongs to.
        actor: Identifier of the step's executor (defaults to ``"lead"``).
        causal_parents: ``event_id``s this step's event causally follows.

    Returns:
        A :class:`StepResult` — ``replayed=True`` when returned from a recorded
        event, else the freshly executed result.
    """
    recorded = self._recorded_result(step_id)
    if recorded is not None:
        logger.debug(f"AsyncioDurableEngine: step {step_id!r} already recorded — replaying without re-running")
        return recorded

    outcome = fn()
    awaited = await outcome if inspect.isawaitable(outcome) else outcome
    payload = cast("dict[str, Any]", awaited)
    result_payload: dict[str, Any] = dict(payload) if payload else {}

    event = TeamEvent(
        event_type=STEP_RESULT_EVENT_PREFIX,
        actor=actor,
        goal_id=goal_id,
        task_id=task_id,
        thread_id=thread_id,
        causal_parents=list(causal_parents or []),
        epistemic_status="observed",
        payload={"step_id": step_id, "step_type": step_type, "result": result_payload},
    )
    self._event_log.append(event)
    return StepResult(step_id=step_id, step_type=step_type, payload=result_payload, replayed=False)

DurableStep

Bases: Protocol

Engine-agnostic contract for executing one lead-loop transition (AC-4-1).

An implementation drives one durable step to completion and records its outcome as a :class:~strands_teams.models.TeamEvent so the step is replayable. The in-process :class:AsyncioDurableEngine satisfies this today; a Temporal or Restate worker can implement the same method later without changing run_goal's call sites (plan §4 Phase 5; AC-4-1). A runtime_checkable Protocol checks method presence only.

run_step async

run_step(
    step_id,
    step_type,
    fn,
    *,
    goal_id="",
    task_id="",
    thread_id="",
    actor="lead",
    causal_parents=None,
)

Execute one step and record its transition result; return the outcome.

Source code in packages/strands-teams/src/strands_teams/durable.py
async def run_step(
    self,
    step_id: str,
    step_type: str,
    fn: Callable[[], Awaitable[dict[str, Any]] | dict[str, Any]],
    *,
    goal_id: str = "",
    task_id: str = "",
    thread_id: str = "",
    actor: str = "lead",
    causal_parents: list[str] | None = None,
) -> StepResult:
    """Execute one step and record its transition result; return the outcome."""
    ...

StepResult

Bases: FrozenModel

The recorded outcome of one durable step (AC-4-3, AC-4-4).

Attributes:

Name Type Description
step_id str

Stable idempotency key for the step within a goal run — a step re-executed during replay with a matching recorded result is not re-run (AC-4-4).

step_type str

The lead-loop transition this step performed (e.g. decompose).

payload dict[str, Any]

The structured result of the step (no transcript/CoT).

replayed bool

True when this result was returned from a prior recorded event rather than freshly executed (AC-4-4).

EventLog

EventLog(path)

An append-only, replayable log of :class:~strands_teams.models.TeamEvent.

Bound to a single file path (resolved from :meth:~strands_teams.layout.TeamsLayout.event_log_path), writing one JSON-encoded event per line (AC-2-1). The API is deliberately append + read only — there is no update or delete method — so an already-written event is immutable (AC-2-5). Supersession is expressed by appending a new event, never by editing an old line (AC-3-4).

Bind the log to path (created lazily on first :meth:append).

Source code in packages/strands-teams/src/strands_teams/events.py
def __init__(self, path: Path) -> None:
    """Bind the log to ``path`` (created lazily on first :meth:`append`)."""
    self._path = path

path property

path

The file this log appends to and reads from.

append

append(event)

Append exactly one JSON line for event; never raise (AC-2-2, AC-2-4).

Serializes event to a single line via :meth:~pydantic.BaseModel.model_dump_json and appends it, leaving every previously written line byte-for-byte unchanged. An OSError (e.g. a read-only filesystem) is logged and swallowed so a log-write failure never kills the coordinator or the lead loop (AC-2-4), matching the never-raises contract of the pre-split _write_debug_entry (teams.py).

Source code in packages/strands-teams/src/strands_teams/events.py
def append(self, event: TeamEvent) -> None:
    """Append exactly one JSON line for ``event``; never raise (AC-2-2, AC-2-4).

    Serializes ``event`` to a single line via
    :meth:`~pydantic.BaseModel.model_dump_json` and appends it, leaving every
    previously written line byte-for-byte unchanged. An ``OSError`` (e.g. a
    read-only filesystem) is logged and swallowed so a log-write failure never
    kills the coordinator or the lead loop (AC-2-4), matching the never-raises
    contract of the pre-split ``_write_debug_entry`` (``teams.py``).
    """
    try:
        self._path.parent.mkdir(parents=True, exist_ok=True)
        line = event.model_dump_json()
        with self._path.open("a", encoding="utf-8") as f:
            f.write(line + "\n")
    except OSError as e:  # AC-2-4: log and continue, never raise.
        logger.debug(f"EventLog.append failed for {self._path}: {e}")

read

read()

Parse and return every valid event, skipping malformed lines.

A malformed line is skipped; use :func:replay when you need the malformed-line warnings surfaced structurally (AC-3-5).

Source code in packages/strands-teams/src/strands_teams/events.py
def read(self) -> list[TeamEvent]:
    """Parse and return every valid event, skipping malformed lines.

    A malformed line is skipped; use :func:`replay` when you need the
    malformed-line warnings surfaced structurally (AC-3-5).
    """
    events, _ = parse_lines(self._read_raw_lines())
    return events

read_with_warnings

read_with_warnings()

Return (events, warnings) — one warning per skipped malformed line.

Source code in packages/strands-teams/src/strands_teams/events.py
def read_with_warnings(self) -> tuple[list[TeamEvent], list[str]]:
    """Return ``(events, warnings)`` — one warning per skipped malformed line."""
    return parse_lines(self._read_raw_lines())

ReplanDecision

Bases: FrozenModel

The structured outcome of :func:should_replan (AC-6-1).

Attributes:

Name Type Description
replan bool

Whether any replan trigger fired.

triggers list[str]

The distinct triggers that fired, in canonical order (empty when replan is False).

reasons list[str]

Human-readable reason per fired trigger.

ReplayState

Bases: FrozenModel

The deterministic reconstruction produced by :func:replay (AC-3-1, AC-3-2).

Immutable. :meth:to_canonical_json yields a byte-stable serialization so two replays of the same log compare byte-for-byte identical (AC-3-2, AC-7-1).

Attributes:

Name Type Description
applied list[str]

event_ids in causal-apply order (AC-3-3).

transitions list[str]

event_types in causal-apply order — the state transitions.

artifacts dict[str, str]

artifact_binding -> event_id of the latest applied, non-superseded event touching that binding.

findings dict[str, dict[str, Any]]

Finding payloads reconstructed from finding-bearing events, keyed by event_id.

decisions dict[str, dict[str, Any]]

Decision payloads reconstructed from decision-bearing events, keyed by event_id.

superseded list[str]

event_ids reflected as superseded (originals retained in the log — supersession never deletes, AC-3-4).

warnings list[str]

One entry per malformed log line skipped during replay (AC-3-5).

to_canonical_json

to_canonical_json()

Return a byte-stable JSON serialization for cross-replay equality (AC-3-2).

Source code in packages/strands-teams/src/strands_teams/events.py
def to_canonical_json(self) -> str:
    """Return a byte-stable JSON serialization for cross-replay equality (AC-3-2)."""
    return json.dumps(self.model_dump(mode="json"), sort_keys=True, ensure_ascii=False)

EvidenceGateResult

Bases: FrozenModel

Outcome of running the evidence gate over one or more findings.

Attributes:

Name Type Description
passed bool

True when no blocking finding lacks evidence.

flagged list[str]

Statements of the blocking findings that carry no evidence.

checked int

Total number of findings evaluated.

GoalResult

Bases: FrozenModel

The terminal outcome of a :func:run_goal run (AC-5-6).

Attributes:

Name Type Description
goal_id str

The goal this run drove.

status str

Terminal status — converged, max_iterations, or budget_exceeded.

iterations int

How many lead-loop iterations executed.

replans int

How many times the loop re-entered decompose via should_replan.

event_log_path str

The path the run's :class:EventLog was written to.

transitions list[str]

The ordered event_types recorded (for assertions/inspection).

TeamsLayout dataclass

TeamsLayout(
    output_root,
    artifact_prefix="BUNDLE",
    artifact_suffix=".md",
    artifacts_subdir="bundles",
    teams_subdir="teams",
    scratch_subdir="tmp",
    findings_name="findings.md",
    findings_json_name="findings.json",
    contract_name="contract.json",
    metadata_name="metadata.json",
    debug_log_name="team_debug.jsonl",
    event_log_name="events.jsonl",
    executive_summary_name="CONTEXT.md",
    planning_sentinel_name="heuristic_summary.json",
    revisions_subdir="revisions",
    alias_name="aliases.json",
    approvals_subdir="approvals",
    human_gates_subdir="human_gates",
    decision_log_name="decisions.jsonl",
)

Owns all artifact paths for a run, derived from one output root.

Every path-producing function in dispatch/read/write/quality code must call a method here rather than re-deriving a literal, so the naming scheme lives in exactly one place.

Attributes:

Name Type Description
output_root Path

The run's output-directory root (e.g., <repo>/.code-context).

artifact_prefix str

Prefix for per-area artifact files (BUNDLE -> BUNDLE.{area}.md).

artifact_suffix str

Extension for artifact files (default .md).

artifacts_subdir str

Subdirectory under the root holding per-area artifacts.

teams_subdir str

Subdirectory (under a scratch dir) holding per-team output.

scratch_subdir str

Scratch directory under the root for transient team data.

findings_name str

Per-team human findings filename.

findings_json_name str

Per-team typed-findings sidecar filename.

contract_name str

Per-team task-contract sidecar filename (authority, budget, acceptance criteria, input revisions).

metadata_name str

Per-team metadata filename.

debug_log_name str

Team-dispatch debug JSONL filename.

event_log_name str

Typed append-only event-plane JSONL filename (Phase 5 / spec 006), co-located with the team-debug log under the teams dir.

executive_summary_name str

Filename for the top-level executive summary.

planning_sentinel_name str

Filename for the planning-context sentinel produced by a deterministic pre-run step.

revisions_subdir str

Subdirectory under the root holding the content-addressed immutable revision store (per-revision_id records).

alias_name str

Filename of the mutable logical_name -> current revision_id alias pointer map (Phase 4 / spec 005), resolved under output_root the same way :meth:planning_sentinel_path is.

approvals_subdir str

Subdirectory (under the scratch dir) holding the per-token_id :class:~strands_teams.models.ApprovalToken store (Phase 6 / spec 007). The single source of every approval path — the PolicyEngine reads it, never a duplicated literal (AC-3-7).

human_gates_subdir str

Subdirectory (under the scratch dir) holding pending :class:~strands_teams.models.HumanDecision gate sentinels the runner suspends on and resumes from (Phase 6 / spec 007; AC-4-3, AC-4-5).

decision_log_name str

Filename of the append-only decision log — one JSON-encoded :class:~strands_teams.models.Decision per line — written on every :class:~strands_teams.policy.PolicyEngine verdict (AC-2-4).

teams_dir

teams_dir()

Directory holding every team's output subdirectory.

Source code in packages/strands-teams/src/strands_teams/layout.py
def teams_dir(self) -> Path:
    """Directory holding every team's output subdirectory."""
    return self.output_root / self.scratch_subdir / self.teams_subdir

team_dir

team_dir(team_id)

Directory for a single team's output.

Source code in packages/strands-teams/src/strands_teams/layout.py
def team_dir(self, team_id: str) -> Path:
    """Directory for a single team's output."""
    return self.teams_dir() / team_id

findings_path

findings_path(team_id)

Path to a team's findings.md.

Source code in packages/strands-teams/src/strands_teams/layout.py
def findings_path(self, team_id: str) -> Path:
    """Path to a team's ``findings.md``."""
    return self.team_dir(team_id) / self.findings_name

findings_json_path

findings_json_path(team_id)

Path to a team's typed findings.json sidecar.

Resolves under the same per-team directory as :meth:findings_path, so the human markdown and the machine-readable typed findings live side by side (AC-2-2).

Source code in packages/strands-teams/src/strands_teams/layout.py
def findings_json_path(self, team_id: str) -> Path:
    """Path to a team's typed ``findings.json`` sidecar.

    Resolves under the same per-team directory as :meth:`findings_path`, so
    the human markdown and the machine-readable typed findings live side by
    side (AC-2-2).
    """
    return self.team_dir(team_id) / self.findings_json_name

contract_path

contract_path(team_id)

Path to a team's task-contract sidecar (contract.json).

Resolves under the same per-team directory as :meth:findings_path, so a team's granted authority, budget, acceptance criteria, and input revisions are persisted next to its findings and metadata (AC-2-6). The :class:~strands_teams.hooks.ArtifactQualityHook reads every team's acceptance_criteria from these files to evaluate criteria coverage.

Source code in packages/strands-teams/src/strands_teams/layout.py
def contract_path(self, team_id: str) -> Path:
    """Path to a team's task-contract sidecar (``contract.json``).

    Resolves under the same per-team directory as :meth:`findings_path`, so a
    team's granted authority, budget, acceptance criteria, and input
    revisions are persisted next to its findings and metadata (AC-2-6). The
    :class:`~strands_teams.hooks.ArtifactQualityHook` reads every team's
    ``acceptance_criteria`` from these files to evaluate criteria coverage.
    """
    return self.team_dir(team_id) / self.contract_name

contract_glob

contract_glob()

Glob (relative to :meth:teams_dir) matching every per-team contract file.

Source code in packages/strands-teams/src/strands_teams/layout.py
def contract_glob(self) -> str:
    """Glob (relative to :meth:`teams_dir`) matching every per-team contract file."""
    return f"*/{self.contract_name}"

metadata_path

metadata_path(team_id)

Path to a team's metadata.json.

Source code in packages/strands-teams/src/strands_teams/layout.py
def metadata_path(self, team_id: str) -> Path:
    """Path to a team's ``metadata.json``."""
    return self.team_dir(team_id) / self.metadata_name

debug_log_path

debug_log_path()

Path to the append-only team-dispatch debug JSONL.

Source code in packages/strands-teams/src/strands_teams/layout.py
def debug_log_path(self) -> Path:
    """Path to the append-only team-dispatch debug JSONL."""
    return self.teams_dir() / self.debug_log_name

event_log_path

event_log_path()

Path to the typed append-only event-plane JSONL (Phase 5 / spec 006).

Co-located with :meth:debug_log_path under the teams dir so the typed event plane supersedes the untyped team_debug.jsonl without moving the run's scratch layout (AC-2-1). One JSON-encoded :class:~strands_teams.models.TeamEvent per line.

Source code in packages/strands-teams/src/strands_teams/layout.py
def event_log_path(self) -> Path:
    """Path to the typed append-only event-plane JSONL (Phase 5 / spec 006).

    Co-located with :meth:`debug_log_path` under the teams dir so the typed
    event plane supersedes the untyped ``team_debug.jsonl`` without moving the
    run's scratch layout (AC-2-1). One JSON-encoded
    :class:`~strands_teams.models.TeamEvent` per line.
    """
    return self.teams_dir() / self.event_log_name

artifacts_dir

artifacts_dir()

Directory holding per-area artifact files.

Source code in packages/strands-teams/src/strands_teams/layout.py
def artifacts_dir(self) -> Path:
    """Directory holding per-area artifact files."""
    return self.output_root / self.artifacts_subdir

artifact_path

artifact_path(area)

Path to a per-area artifact file (e.g., bundles/BUNDLE.auth.md).

Source code in packages/strands-teams/src/strands_teams/layout.py
def artifact_path(self, area: str) -> Path:
    """Path to a per-area artifact file (e.g., ``bundles/BUNDLE.auth.md``)."""
    return self.artifacts_dir() / f"{self.artifact_prefix}.{area}{self.artifact_suffix}"

artifact_glob

artifact_glob()

Glob pattern matching every per-area artifact file.

Source code in packages/strands-teams/src/strands_teams/layout.py
def artifact_glob(self) -> str:
    """Glob pattern matching every per-area artifact file."""
    return f"{self.artifact_prefix}.*{self.artifact_suffix}"

area_from_artifact

area_from_artifact(path)

Extract the area identifier from an artifact file path.

Inverse of :meth:artifact_path — strips the prefix/suffix so the quality hook can name a weak artifact by its area.

Source code in packages/strands-teams/src/strands_teams/layout.py
def area_from_artifact(self, path: Path) -> str:
    """Extract the area identifier from an artifact file path.

    Inverse of :meth:`artifact_path` — strips the prefix/suffix so the
    quality hook can name a weak artifact by its area.
    """
    return path.name.removeprefix(f"{self.artifact_prefix}.").removesuffix(self.artifact_suffix)

executive_summary_path

executive_summary_path()

Path to the top-level executive summary file.

Source code in packages/strands-teams/src/strands_teams/layout.py
def executive_summary_path(self) -> Path:
    """Path to the top-level executive summary file."""
    return self.output_root / self.executive_summary_name

planning_sentinel_path

planning_sentinel_path()

Path to the planning-context sentinel produced by the pre-run step.

Source code in packages/strands-teams/src/strands_teams/layout.py
def planning_sentinel_path(self) -> Path:
    """Path to the planning-context sentinel produced by the pre-run step."""
    return self.output_root / self.planning_sentinel_name

revisions_dir

revisions_dir()

Directory holding the content-addressed immutable revision store (AC-1-3).

Source code in packages/strands-teams/src/strands_teams/layout.py
def revisions_dir(self) -> Path:
    """Directory holding the content-addressed immutable revision store (AC-1-3)."""
    return self.output_root / self.revisions_subdir

revision_path

revision_path(logical_name, revision_id)

Resolve the on-disk path for one (logical_name, revision_id) revision (AC-1-3).

Every revision of a logical artifact is grouped under a per-artifact subdirectory of :meth:revisions_dir, keyed by revision_id (which is content-addressed). This is the ONLY place a revision path is derived, so dispatch/read/write/promotion code never duplicates the literal (AC-1-3).

Source code in packages/strands-teams/src/strands_teams/layout.py
def revision_path(self, logical_name: str, revision_id: str) -> Path:
    """Resolve the on-disk path for one ``(logical_name, revision_id)`` revision (AC-1-3).

    Every revision of a logical artifact is grouped under a per-artifact
    subdirectory of :meth:`revisions_dir`, keyed by ``revision_id`` (which is
    content-addressed). This is the ONLY place a revision path is derived, so
    dispatch/read/write/promotion code never duplicates the literal (AC-1-3).
    """
    return self.revisions_dir() / _slug(logical_name) / f"{_slug(revision_id)}.json"

alias_path

alias_path()

Path to the mutable logical_name -> current revision_id alias map (AC-2-1).

Resolved directly under output_root in the same manner as :meth:planning_sentinel_path — a single durable JSON file that is the only mutable pointer into the content-addressed revision store.

Source code in packages/strands-teams/src/strands_teams/layout.py
def alias_path(self) -> Path:
    """Path to the mutable ``logical_name -> current revision_id`` alias map (AC-2-1).

    Resolved directly under ``output_root`` in the same manner as
    :meth:`planning_sentinel_path` — a single durable JSON file that is the
    only mutable pointer into the content-addressed revision store.
    """
    return self.output_root / self.alias_name

persist_revision

persist_revision(revision)

Persist a revision to its content-addressed path and return it (AC-1-4, AC-1-5).

Writes the revision keyed by revision_id under :meth:revision_path so it is addressable by later reads (AC-1-4). Idempotent for an identical re-write (same revision_id and content_hash). If the same revision_id was already persisted with a DIFFERENT content_hash, the write is rejected with :class:RevisionConflictError and the stored revision is left unchanged (AC-1-5) — the store is append-only and content-addressed.

Returns:

Type Description
ArtifactRevision

The stored :class:ArtifactRevision (the argument on a fresh write, or

ArtifactRevision

the already-stored record on an idempotent re-write).

Raises:

Type Description
RevisionConflictError

If revision.revision_id is already stored with content differing from revision.content_hash (AC-1-5).

Source code in packages/strands-teams/src/strands_teams/layout.py
def persist_revision(self, revision: ArtifactRevision) -> ArtifactRevision:
    """Persist a revision to its content-addressed path and return it (AC-1-4, AC-1-5).

    Writes the revision keyed by ``revision_id`` under :meth:`revision_path` so
    it is addressable by later reads (AC-1-4). Idempotent for an identical
    re-write (same ``revision_id`` and ``content_hash``). If the same
    ``revision_id`` was already persisted with a DIFFERENT ``content_hash``,
    the write is rejected with :class:`RevisionConflictError` and the stored
    revision is left unchanged (AC-1-5) — the store is append-only and
    content-addressed.

    Returns:
        The stored :class:`ArtifactRevision` (the argument on a fresh write, or
        the already-stored record on an idempotent re-write).

    Raises:
        RevisionConflictError: If ``revision.revision_id`` is already stored
            with content differing from ``revision.content_hash`` (AC-1-5).
    """
    path = self.revision_path(revision.logical_name, revision.revision_id)
    if path.exists():
        stored = ArtifactRevision.model_validate_json(path.read_text(encoding="utf-8"))
        if stored.content_hash != revision.content_hash:
            raise RevisionConflictError(revision.revision_id, stored.content_hash, revision.content_hash)
        return stored
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(revision.model_dump_json(indent=2), encoding="utf-8")
    return revision

load_revision

load_revision(logical_name, revision_id)

Load a persisted revision, or None when it is not in the store.

Source code in packages/strands-teams/src/strands_teams/layout.py
def load_revision(self, logical_name: str, revision_id: str) -> ArtifactRevision | None:
    """Load a persisted revision, or ``None`` when it is not in the store."""
    path = self.revision_path(logical_name, revision_id)
    if not path.exists():
        return None
    return ArtifactRevision.model_validate_json(path.read_text(encoding="utf-8"))

current_revision_id

current_revision_id(logical_name)

Return the revision_id the alias currently points logical_name at.

Returns the :data:~strands_teams.models.NO_CURRENT_REVISION sentinel when logical_name has no current revision (AC-2-4).

Source code in packages/strands-teams/src/strands_teams/layout.py
def current_revision_id(self, logical_name: str) -> str:
    """Return the ``revision_id`` the alias currently points ``logical_name`` at.

    Returns the :data:`~strands_teams.models.NO_CURRENT_REVISION` sentinel when
    ``logical_name`` has no current revision (AC-2-4).
    """
    with self._locked_aliases() as aliases:
        return aliases.get(logical_name, NO_CURRENT_REVISION)

promote_revision

promote_revision(
    logical_name,
    expected_current_revision,
    proposed_revision,
)

Durably compare-and-set the alias for logical_name (AC-2-2..AC-2-5).

The compare-and-set is serialized by an OS advisory lock on the alias file, so two concurrent promotions of the same logical_name with the same expected_current_revision cannot both win: whichever acquires the lock first flips the alias, and the other observes the new current and is rejected with :class:StaleArtifactError (AC-2-5). Because the CAS is a durable operation against this on-disk store — not a mutation of the by-reference invocation_state dict — it is correct even when the callers share that dict read-only (research-coding-runtimes.yaml lines 22, 30).

On a matching CAS the proposed revision is persisted (content-addressed) with the prior current revision recorded in its supersedes lineage, and the alias is atomically flipped to it (AC-2-2). A first promotion passes the :data:~strands_teams.models.NO_CURRENT_REVISION sentinel as expected_current_revision and sets the alias as the first revision (AC-2-4).

Parameters:

Name Type Description Default
logical_name str

The logical artifact to promote.

required
expected_current_revision str

The revision the caller believes is current (the sentinel for a first promotion).

required
proposed_revision ArtifactRevision

The revision to make current. Its supersedes is augmented with the prior current revision before it is persisted.

required

Returns:

Type Description
ArtifactRevision

The persisted, now-current :class:ArtifactRevision (with the prior

ArtifactRevision

revision recorded in supersedes).

Raises:

Type Description
StaleArtifactError

If expected_current_revision does not match the alias's stored current revision — the alias is left unchanged (AC-2-3).

RevisionConflictError

If persisting the proposed revision conflicts with an already-stored revision of the same id (AC-1-5).

Source code in packages/strands-teams/src/strands_teams/layout.py
def promote_revision(
    self,
    logical_name: str,
    expected_current_revision: str,
    proposed_revision: ArtifactRevision,
) -> ArtifactRevision:
    """Durably compare-and-set the alias for ``logical_name`` (AC-2-2..AC-2-5).

    The compare-and-set is serialized by an OS advisory lock on the alias file,
    so two concurrent promotions of the same ``logical_name`` with the same
    ``expected_current_revision`` cannot both win: whichever acquires the lock
    first flips the alias, and the other observes the new current and is
    rejected with :class:`StaleArtifactError` (AC-2-5). Because the CAS is a
    durable operation against this on-disk store — not a mutation of the
    by-reference ``invocation_state`` dict — it is correct even when the callers
    share that dict read-only (research-coding-runtimes.yaml lines 22, 30).

    On a matching CAS the proposed revision is persisted (content-addressed)
    with the prior current revision recorded in its ``supersedes`` lineage, and
    the alias is atomically flipped to it (AC-2-2). A first promotion passes the
    :data:`~strands_teams.models.NO_CURRENT_REVISION` sentinel as
    ``expected_current_revision`` and sets the alias as the first revision
    (AC-2-4).

    Args:
        logical_name: The logical artifact to promote.
        expected_current_revision: The revision the caller believes is current
            (the sentinel for a first promotion).
        proposed_revision: The revision to make current. Its ``supersedes`` is
            augmented with the prior current revision before it is persisted.

    Returns:
        The persisted, now-current :class:`ArtifactRevision` (with the prior
        revision recorded in ``supersedes``).

    Raises:
        StaleArtifactError: If ``expected_current_revision`` does not match the
            alias's stored current revision — the alias is left unchanged
            (AC-2-3).
        RevisionConflictError: If persisting the proposed revision conflicts
            with an already-stored revision of the same id (AC-1-5).
    """
    with self._locked_aliases() as aliases:
        actual = aliases.get(logical_name, NO_CURRENT_REVISION)
        if actual != expected_current_revision:
            raise StaleArtifactError(logical_name, expected_current_revision, actual)

        superseded = [] if actual == NO_CURRENT_REVISION else [actual]
        merged_supersedes = list(dict.fromkeys([*proposed_revision.supersedes, *superseded]))
        to_store = proposed_revision.model_copy(update={"supersedes": merged_supersedes})

        stored = self.persist_revision(to_store)
        aliases[logical_name] = stored.revision_id
        self._write_aliases(aliases)
        return stored

approvals_dir

approvals_dir()

Directory holding the per-token_id :class:ApprovalToken store (AC-3-7).

Source code in packages/strands-teams/src/strands_teams/layout.py
def approvals_dir(self) -> Path:
    """Directory holding the per-``token_id`` :class:`ApprovalToken` store (AC-3-7)."""
    return self.output_root / self.scratch_subdir / self.approvals_subdir

approval_path

approval_path(token_id)

Resolve the on-disk path for one approval token, keyed by token_id.

Source code in packages/strands-teams/src/strands_teams/layout.py
def approval_path(self, token_id: str) -> Path:
    """Resolve the on-disk path for one approval token, keyed by ``token_id``."""
    return self.approvals_dir() / f"{_slug(token_id)}.json"

persist_approval

persist_approval(token)

Persist an :class:ApprovalToken to the approvals store and return it (AC-1-1).

Source code in packages/strands-teams/src/strands_teams/layout.py
def persist_approval(self, token: ApprovalToken) -> ApprovalToken:
    """Persist an :class:`ApprovalToken` to the approvals store and return it (AC-1-1)."""
    path = self.approval_path(token.token_id)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(token.model_dump_json(indent=2), encoding="utf-8")
    return token

load_approval

load_approval(token_id)

Load one persisted approval token, or None when absent/unreadable.

Source code in packages/strands-teams/src/strands_teams/layout.py
def load_approval(self, token_id: str) -> ApprovalToken | None:
    """Load one persisted approval token, or ``None`` when absent/unreadable."""
    path = self.approval_path(token_id)
    if not path.exists():
        return None
    try:
        return ApprovalToken.model_validate_json(path.read_text(encoding="utf-8"))
    except (ValueError, OSError):
        return None

load_approvals

load_approvals()

Load every persisted approval token (skipping any that fail to parse — AC-3-7).

Source code in packages/strands-teams/src/strands_teams/layout.py
def load_approvals(self) -> list[ApprovalToken]:
    """Load every persisted approval token (skipping any that fail to parse — AC-3-7)."""
    approvals_dir = self.approvals_dir()
    if not approvals_dir.exists():
        return []
    tokens: list[ApprovalToken] = []
    for path in sorted(approvals_dir.glob("*.json")):
        try:
            tokens.append(ApprovalToken.model_validate_json(path.read_text(encoding="utf-8")))
        except (ValueError, OSError):
            continue
    return tokens

decision_log_path

decision_log_path()

Path to the append-only decision log — one :class:Decision per line (AC-2-4).

Source code in packages/strands-teams/src/strands_teams/layout.py
def decision_log_path(self) -> Path:
    """Path to the append-only decision log — one :class:`Decision` per line (AC-2-4)."""
    return self.output_root / self.scratch_subdir / self.decision_log_name

append_decision

append_decision(decision)

Append one :class:Decision to the decision log (AC-2-4).

Raises on I/O failure rather than swallowing it, so a transition can never proceed with its decision unrecorded (AC-2-5). The caller decides how to react; unlike the debug log, this path is auth-critical.

Raises:

Type Description
OSError

If the decision line cannot be written.

Source code in packages/strands-teams/src/strands_teams/layout.py
def append_decision(self, decision: Decision) -> None:
    """Append one :class:`Decision` to the decision log (AC-2-4).

    Raises on I/O failure rather than swallowing it, so a transition can never
    proceed with its decision unrecorded (AC-2-5). The caller decides how to
    react; unlike the debug log, this path is auth-critical.

    Raises:
        OSError: If the decision line cannot be written.
    """
    path = self.decision_log_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as f:
        f.write(decision.model_dump_json() + "\n")

human_gates_dir

human_gates_dir()

Directory holding pending :class:HumanDecision gate sentinels (AC-4-3, AC-4-5).

Source code in packages/strands-teams/src/strands_teams/layout.py
def human_gates_dir(self) -> Path:
    """Directory holding pending :class:`HumanDecision` gate sentinels (AC-4-3, AC-4-5)."""
    return self.output_root / self.scratch_subdir / self.human_gates_subdir

human_gate_path

human_gate_path(decision_id)

Resolve the on-disk path for one human-gate sentinel, keyed by decision_id.

Source code in packages/strands-teams/src/strands_teams/layout.py
def human_gate_path(self, decision_id: str) -> Path:
    """Resolve the on-disk path for one human-gate sentinel, keyed by ``decision_id``."""
    return self.human_gates_dir() / f"{_slug(decision_id)}.json"

persist_human_gate

persist_human_gate(gate)

Persist a :class:HumanDecision gate to disk and return it (AC-4-5).

A pending gate persisted here is what lets a swarm node return control rather than block past its node_timeout (AC-4-5): the gate survives on disk and the runner resumes from it later.

Source code in packages/strands-teams/src/strands_teams/layout.py
def persist_human_gate(self, gate: HumanDecision) -> HumanDecision:
    """Persist a :class:`HumanDecision` gate to disk and return it (AC-4-5).

    A pending gate persisted here is what lets a swarm node return control
    rather than block past its ``node_timeout`` (AC-4-5): the gate survives on
    disk and the runner resumes from it later.
    """
    path = self.human_gate_path(gate.decision_id)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(gate.model_dump_json(indent=2), encoding="utf-8")
    return gate

load_human_gate

load_human_gate(decision_id)

Load one persisted human-gate sentinel, or None when absent/unreadable.

Source code in packages/strands-teams/src/strands_teams/layout.py
def load_human_gate(self, decision_id: str) -> HumanDecision | None:
    """Load one persisted human-gate sentinel, or ``None`` when absent/unreadable."""
    path = self.human_gate_path(decision_id)
    if not path.exists():
        return None
    try:
        return HumanDecision.model_validate_json(path.read_text(encoding="utf-8"))
    except (ValueError, OSError):
        return None

load_pending_human_gates

load_pending_human_gates()

Load every human gate still pending (the runner suspends on these — AC-4-3).

Source code in packages/strands-teams/src/strands_teams/layout.py
def load_pending_human_gates(self) -> list[HumanDecision]:
    """Load every human gate still ``pending`` (the runner suspends on these — AC-4-3)."""
    gates_dir = self.human_gates_dir()
    if not gates_dir.exists():
        return []
    pending: list[HumanDecision] = []
    for path in sorted(gates_dir.glob("*.json")):
        try:
            gate = HumanDecision.model_validate_json(path.read_text(encoding="utf-8"))
        except (ValueError, OSError):
            continue
        if gate.status == "pending":
            pending.append(gate)
    return pending

ApprovalToken

Bases: FrozenModel

A revision-bound, signed, expiring approval of a consequential transition (ASTR §14.7).

An approval names the exact :class:ArtifactRevision it approves — by both artifact_revision_id and the revision's immutable revision_content_hash captured at grant time (AC-1-2) — so it goes stale the moment the artifact changes (AC-5-1). Its signature is an HMAC over the canonical serialization of its identity fields (:func:approval_signing_payload), so a forged or tampered token fails :meth:signature_valid and the PolicyEngine treats it as absent (AC-1-4). Past valid_until the engine rejects it as expired (AC-1-5). invalidation_rules names the artifact facets whose change sunsets the approval (AC-5-2, AC-5-3).

Build one with :meth:mint so the signature is computed with the configured signing key; constructing directly is supported for round-tripping a persisted token.

Attributes:

Name Type Description
token_id str

Unique identity of this approval (auto UUID hex when minted).

approved_transition_kind str

The transition kind this token approves (e.g. "release"). An approval for one kind never authorizes another.

artifact_revision_id str

The revision_id of the exact revision approved.

revision_content_hash str

The approved revision's content_hash at grant time — the staleness anchor (AC-5-1).

approver str

Principal who granted the approval (the separation-of-duties and required-approvals gates read this — AC-3-3, AC-3-4).

granted_at datetime

UTC timestamp the approval was minted.

valid_until datetime

UTC expiry; at or after it the token is expired (AC-1-5).

signature str

HMAC-SHA256 hex over :func:approval_signing_payload (AC-1-3).

invalidation_rules list[str]

Facet names whose change sunsets the approval (AC-5-2); empty means any change to the bound artifact invalidates it.

mint classmethod

mint(
    *,
    approved_transition_kind,
    artifact_revision_id,
    revision_content_hash,
    approver,
    signing_key,
    valid_until=None,
    valid_for=None,
    invalidation_rules=None,
    token_id=None,
    granted_at=None,
)

Mint a signed :class:ApprovalToken over its canonical payload (AC-1-3).

Computes the HMAC signature with signing_key so the token verifies against the same key later (AC-1-4). Supply the expiry either as an absolute valid_until or a relative valid_for (added to granted_at); when neither is given the token is valid for 24 hours (AC-1-5).

Parameters:

Name Type Description Default
approved_transition_kind str

The transition kind approved.

required
artifact_revision_id str

The approved revision's id.

required
revision_content_hash str

The approved revision's content hash (AC-1-2).

required
approver str

The approving principal.

required
signing_key str

The configured signing key (from TeamsSettings).

required
valid_until datetime | None

Absolute UTC expiry (wins over valid_for).

None
valid_for timedelta | None

Relative validity window from granted_at.

None
invalidation_rules list[str] | None

Facet names whose change sunsets the approval.

None
token_id str | None

Explicit id; defaults to a fresh UUID hex.

None
granted_at datetime | None

Explicit grant time; defaults to now.

None

Returns:

Type Description
ApprovalToken

A signed, frozen :class:ApprovalToken.

Source code in packages/strands-teams/src/strands_teams/models.py
@classmethod
def mint(
    cls,
    *,
    approved_transition_kind: str,
    artifact_revision_id: str,
    revision_content_hash: str,
    approver: str,
    signing_key: str,
    valid_until: datetime | None = None,
    valid_for: timedelta | None = None,
    invalidation_rules: list[str] | None = None,
    token_id: str | None = None,
    granted_at: datetime | None = None,
) -> ApprovalToken:
    """Mint a signed :class:`ApprovalToken` over its canonical payload (AC-1-3).

    Computes the HMAC ``signature`` with ``signing_key`` so the token verifies
    against the same key later (AC-1-4). Supply the expiry either as an absolute
    ``valid_until`` or a relative ``valid_for`` (added to ``granted_at``); when
    neither is given the token is valid for 24 hours (AC-1-5).

    Args:
        approved_transition_kind: The transition kind approved.
        artifact_revision_id: The approved revision's id.
        revision_content_hash: The approved revision's content hash (AC-1-2).
        approver: The approving principal.
        signing_key: The configured signing key (from ``TeamsSettings``).
        valid_until: Absolute UTC expiry (wins over ``valid_for``).
        valid_for: Relative validity window from ``granted_at``.
        invalidation_rules: Facet names whose change sunsets the approval.
        token_id: Explicit id; defaults to a fresh UUID hex.
        granted_at: Explicit grant time; defaults to now.

    Returns:
        A signed, frozen :class:`ApprovalToken`.
    """
    resolved_id = token_id or uuid4().hex
    resolved_granted = granted_at or _utcnow()
    if valid_until is None:
        valid_until = resolved_granted + (valid_for if valid_for is not None else timedelta(hours=24))
    payload = approval_signing_payload(
        token_id=resolved_id,
        approved_transition_kind=approved_transition_kind,
        artifact_revision_id=artifact_revision_id,
        revision_content_hash=revision_content_hash,
        approver=approver,
        valid_until=valid_until,
    )
    return cls(
        token_id=resolved_id,
        approved_transition_kind=approved_transition_kind,
        artifact_revision_id=artifact_revision_id,
        revision_content_hash=revision_content_hash,
        approver=approver,
        granted_at=resolved_granted,
        valid_until=valid_until,
        signature=_sign(payload, signing_key),
        invalidation_rules=list(invalidation_rules or []),
    )

signing_payload

signing_payload()

Return this token's canonical signing payload (AC-1-3).

Source code in packages/strands-teams/src/strands_teams/models.py
def signing_payload(self) -> str:
    """Return this token's canonical signing payload (AC-1-3)."""
    return approval_signing_payload(
        token_id=self.token_id,
        approved_transition_kind=self.approved_transition_kind,
        artifact_revision_id=self.artifact_revision_id,
        revision_content_hash=self.revision_content_hash,
        approver=self.approver,
        valid_until=self.valid_until,
    )

signature_valid

signature_valid(signing_key)

Return whether signature verifies against signing_key (AC-1-4).

Uses :func:hmac.compare_digest for a constant-time comparison so a signature check cannot be timing-attacked. A token whose signature does not verify is treated as absent by the PolicyEngine.

Source code in packages/strands-teams/src/strands_teams/models.py
def signature_valid(self, signing_key: str) -> bool:
    """Return whether ``signature`` verifies against ``signing_key`` (AC-1-4).

    Uses :func:`hmac.compare_digest` for a constant-time comparison so a
    signature check cannot be timing-attacked. A token whose signature does not
    verify is treated as absent by the ``PolicyEngine``.
    """
    expected = _sign(self.signing_payload(), signing_key)
    return hmac.compare_digest(expected, self.signature)

is_expired

is_expired(now=None)

Return whether the token is at or past its valid_until (AC-1-5).

Source code in packages/strands-teams/src/strands_teams/models.py
def is_expired(self, now: datetime | None = None) -> bool:
    """Return whether the token is at or past its ``valid_until`` (AC-1-5)."""
    return (now or _utcnow()) >= self.valid_until

ArtifactRevision

Bases: FrozenModel

An immutable, content-addressed revision of one logical artifact (ASTR §4.8, §6.4).

A lightweight metadata record — not the artifact blob itself, which lives at the layout-resolved path. Immutable and extra="forbid" (inherited from :class:FrozenModel): any attempt to assign a field after construction raises a pydantic.ValidationError (AC-1-1). Build one with :meth:from_content so content_hash and per-facet hashes are computed deterministically from the content (AC-1-2); constructing directly is supported for round-tripping a persisted revision.

Attributes:

Name Type Description
artifact_id str

Stable identity of the logical artifact across all its revisions (defaults to logical_name in :meth:from_content).

revision_id str

Identity of this specific revision. Content-addressed: defaults to content_hash in :meth:from_content. The revision store is keyed by this (AC-1-4).

content_hash str

Deterministic hash of the revision's content alone (AC-1-2).

artifact_type str

The kind of artifact (e.g. "bundle", "finding_set", "patch") — opaque to the framework.

logical_name str

The name the alias pointer maps to a current revision.

produced_by str

Identifier of the team/agent/runtime that produced it.

parents list[str]

revision_ids this revision was derived from (inputs).

supersedes list[str]

revision_ids this revision replaces (lineage; a promotion records the prior current revision here — AC-2-2).

classification str

Handling classification (e.g. "internal").

facets dict[str, str]

Per-facet content hashes ({facet_name: sha256}) enabling the facet-level :func:~strands_teams.revisions.semantic_diff (AC-3-2).

created_at datetime

UTC creation timestamp (not part of content_hash).

from_content classmethod

from_content(
    *,
    logical_name,
    facets=None,
    body=None,
    artifact_type="artifact",
    produced_by="",
    artifact_id=None,
    revision_id=None,
    parents=None,
    supersedes=None,
    classification="internal",
    created_at=None,
)

Build a revision from content, computing content_hash deterministically (AC-1-2).

Supply the content either as facets (a {facet_name: content} mapping, enabling facet-level semantic_diff) or as a single body string (wrapped as one "body" facet). content_hash and revision_id (which defaults to content_hash — content addressing) are derived from the content alone; artifact_id defaults to logical_name.

Parameters:

Name Type Description Default
logical_name str

The logical artifact's name.

required
facets Mapping[str, str] | None

Facet-name to facet-content mapping. Mutually exclusive with body; when both are omitted the revision has empty content.

None
body str | None

A single-blob content string (wrapped as facet "body").

None
artifact_type str

Kind of artifact.

'artifact'
produced_by str

Producer identifier.

''
artifact_id str | None

Stable logical identity; defaults to logical_name.

None
revision_id str | None

Explicit revision id; defaults to content_hash.

None
parents list[str] | None

Input revision ids.

None
supersedes list[str] | None

Superseded revision ids.

None
classification str

Handling classification.

'internal'
created_at datetime | None

Explicit timestamp; defaults to now.

None

Returns:

Type Description
ArtifactRevision

A frozen :class:ArtifactRevision.

Source code in packages/strands-teams/src/strands_teams/models.py
@classmethod
def from_content(
    cls,
    *,
    logical_name: str,
    facets: Mapping[str, str] | None = None,
    body: str | None = None,
    artifact_type: str = "artifact",
    produced_by: str = "",
    artifact_id: str | None = None,
    revision_id: str | None = None,
    parents: list[str] | None = None,
    supersedes: list[str] | None = None,
    classification: str = "internal",
    created_at: datetime | None = None,
) -> ArtifactRevision:
    """Build a revision from content, computing ``content_hash`` deterministically (AC-1-2).

    Supply the content either as ``facets`` (a ``{facet_name: content}``
    mapping, enabling facet-level ``semantic_diff``) or as a single ``body``
    string (wrapped as one ``"body"`` facet). ``content_hash`` and
    ``revision_id`` (which defaults to ``content_hash`` — content addressing)
    are derived from the content alone; ``artifact_id`` defaults to
    ``logical_name``.

    Args:
        logical_name: The logical artifact's name.
        facets: Facet-name to facet-content mapping. Mutually exclusive with
            ``body``; when both are omitted the revision has empty content.
        body: A single-blob content string (wrapped as facet ``"body"``).
        artifact_type: Kind of artifact.
        produced_by: Producer identifier.
        artifact_id: Stable logical identity; defaults to ``logical_name``.
        revision_id: Explicit revision id; defaults to ``content_hash``.
        parents: Input revision ids.
        supersedes: Superseded revision ids.
        classification: Handling classification.
        created_at: Explicit timestamp; defaults to now.

    Returns:
        A frozen :class:`ArtifactRevision`.
    """
    resolved_facets: dict[str, str] = dict(facets) if facets is not None else {}
    if body is not None:
        resolved_facets.setdefault("body", body)
    content_hash = compute_content_hash(resolved_facets)
    facet_hashes = _facet_hashes(resolved_facets)
    return cls(
        artifact_id=artifact_id or logical_name,
        revision_id=revision_id or content_hash,
        content_hash=content_hash,
        artifact_type=artifact_type,
        logical_name=logical_name,
        produced_by=produced_by,
        parents=list(parents or []),
        supersedes=list(supersedes or []),
        classification=classification,
        facets=facet_hashes,
        created_at=created_at or _utcnow(),
    )

AuthorityGrant

Bases: FrozenModel

What a dispatched team is permitted to do (ASTR §4.7).

A capability grant attached to a team's task contract. Every capability defaults to False (deny by default): a caller must explicitly widen a team's authority. The framework enforces may_create_blocking_finding in dispatch_team code — a team whose grant forbids blocking findings cannot persist one, regardless of what its agents' prompts say.

Attributes:

Name Type Description
may_create_blocking_finding bool

Whether this team may persist a Finding with blocking=True. Enforced structurally by dispatch_team (a forbidden blocking finding is stripped from the persisted typed-findings artifact and the dispatch returns an error).

may_write bool

Whether a later-phase specialist coding runtime granted to this team may modify files. A Codex amazon-bedrock runtime gates file writes on this flag (research-coding-runtimes.yaml codex_gpt56_bedrock; consumed in Phase 3 / spec 004-coding-runtime).

may_approve bool

Whether this team may approve/accept work (e.g. mark a finding remediated or sign off an artifact revision).

Budget

Bases: FrozenModel

How much a dispatched team may spend (ASTR §6.2).

The resource envelope attached to a team's task contract. All three limits are non-negative; a negative value is rejected at construction (FrozenModel validates assignments). A value of 0 means "no explicit limit supplied" — dispatch_team only overrides the run-default swarm execution_timeout when max_wall_clock_minutes is strictly positive.

Attributes:

Name Type Description
max_wall_clock_minutes float

Wall-clock ceiling for the team, in minutes. dispatch_team uses max_wall_clock_minutes * 60 as the swarm's execution_timeout when positive.

max_cost_usd float

Dollar ceiling for the team. This is the framework-contract value that a Bedrock ClaudeAgentRuntime maps to the Claude Agent SDK's max_budget_usd option (note the field-name difference: max_cost_usd here vs. max_budget_usd in the SDK — research-coding-runtimes.yaml claude_agent_sdk_bedrock; consumed in Phase 3 / spec 004-coding-runtime).

max_retries int

Retry ceiling. Bounds a Codex harness's retry count when a specialist coding runtime is granted (research-coding-runtimes.yaml codex_gpt56_bedrock; consumed in Phase 3 / spec 004-coding-runtime).

Bundle

Bases: FrozenModel

A narrative bundle about a specific area of the domain.

CalibratedJudgment

Bases: FrozenModel

A judgment with attached confidence and auditable reasoning.

Decision

Bases: FrozenModel

An auditable record of a consequential choice (ASTR §11).

Captures the full deliberation: the question, the options considered, what was selected, the rationale, the evidence (reusing the P1 :class:Evidence vocabulary — file:line / test / command exit / artifact / url / log, AC-2-2), any dissent, and the prior decision_id this record supersedes when it reverses or replaces an earlier decision (AC-2-3). The PolicyEngine appends one on every allow/deny verdict (AC-2-4).

Attributes:

Name Type Description
decision_id str

Unique identity of this decision (auto UUID hex if omitted).

question str

The question the decision answers.

options list[str]

The options that were considered.

selected str

The option that was chosen.

rationale str

Why the selection was made.

evidence list[Evidence]

Evidence refs backing the decision (reuses the P1 type — AC-2-2).

dissent list[str]

Recorded dissenting views (empty when unanimous).

supersedes list[str]

Prior decision_ids this decision reverses/replaces (AC-2-3).

decided_at datetime

UTC timestamp the decision was recorded.

Evidence

Bases: FrozenModel

A single evidence reference backing a finding.

Attributes:

Name Type Description
type EvidenceType

The kind of evidence (file_line, test, command, artifact, url, log).

uri str

A locator for the evidence — e.g. src/auth.py:42 for a file_line ref, a test node id, a command string, an artifact revision id, or a URL. Required and non-empty.

detail str | None

Optional human-readable note about what this evidence shows.

Finding

Bases: FrozenModel

A typed, evidence-bearing finding produced by a team (ASTR §6.6, §8.3).

Immutable and extra="forbid" (inherited from :class:FrozenModel), so a finding is safe to pass by reference through invocation_state and any on-disk findings.json that carries unknown keys is rejected at the parse boundary rather than silently accepted.

Attributes:

Name Type Description
statement str

The finding itself, stated in one or two sentences (non-empty).

severity Severity

One of info/low/medium/high/critical.

blocking bool

Whether this finding must be resolved before the work is accepted. A blocking finding is subject to the evidence gate (must carry ≥1 evidence ref).

disposition Disposition

Lifecycle state (defaults to open).

affected_artifacts list[str]

Paths/ids of artifacts this finding is about.

evidence list[Evidence]

Evidence refs backing the finding (file:line / test / command exit / artifact / url / log).

confidence float

Calibrated confidence in [0.0, 1.0].

created_by str

Identifier of the agent/team that authored the finding.

created_at datetime

UTC creation timestamp.

updated_at datetime

UTC timestamp of the last lifecycle update.

FrozenModel

Bases: BaseModel

Immutable model (like a frozen dataclass).

This model: - Prevents mutation (frozen=True) - Validates assignments - Forbids extra fields

Use for result objects and immutable data transfer.

GeneratedFile

Bases: FrozenModel

Record of a file generated during a run.

HumanDecision

Bases: FrozenModel

A first-class human-gate protocol state (ASTR §14, §5.2).

Where a transition kind is human-gated, the PolicyEngine emits one of these as pending rather than letting the model self-approve (AC-4-1). The runner persists it to disk and suspends the outer loop for that transition until a human resolves it (AC-4-3, AC-4-5); an approving resolution resumes and re-authorizes the transition (AC-4-4), a rejecting one abandons it (AC-4-6).

Attributes:

Name Type Description
decision_id str

Unique identity of this gate (auto UUID hex if omitted).

transition TransitionRequest

The :class:TransitionRequest this gate governs.

prompt str

The human-readable question posed to the human.

options list[str]

The choices offered to the human.

status HumanDecisionStatus

pending | resolved | rejected (AC-4-2).

resolved_by str

Principal who resolved the gate (empty while pending).

resolved_at datetime | None

UTC timestamp of resolution (None while pending).

created_at datetime

UTC timestamp the gate was emitted.

resolve

resolve(*, approved, resolved_by, resolved_at=None)

Return a resolved copy of this gate (frozen — never mutates in place).

Parameters:

Name Type Description Default
approved bool

True -> resolved (approve); False -> rejected.

required
resolved_by str

The principal resolving the gate.

required
resolved_at datetime | None

Resolution timestamp; defaults to now.

None

Returns:

Type Description
HumanDecision

A new :class:HumanDecision with the updated status and resolver.

Source code in packages/strands-teams/src/strands_teams/models.py
def resolve(self, *, approved: bool, resolved_by: str, resolved_at: datetime | None = None) -> HumanDecision:
    """Return a resolved copy of this gate (frozen — never mutates in place).

    Args:
        approved: ``True`` -> ``resolved`` (approve); ``False`` -> ``rejected``.
        resolved_by: The principal resolving the gate.
        resolved_at: Resolution timestamp; defaults to now.

    Returns:
        A new :class:`HumanDecision` with the updated status and resolver.
    """
    return self.model_copy(
        update={
            "status": "resolved" if approved else "rejected",
            "resolved_by": resolved_by,
            "resolved_at": resolved_at or _utcnow(),
        },
    )

PhaseTiming

Bases: FrozenModel

Timing data for a single run phase.

PolicyVerdict

Bases: FrozenModel

The PolicyEngine's final allow/deny on a transition (ASTR §14.7, AC-3-1).

The model never decides — this verdict, produced by PolicyEngine.authorize_transition, is the authority (AC-3-1). On a deny, failed_gate names which gate in the ordered stack refused and reason is human-readable. On an allow, failed_gate is None (AC-3-6). When the denying gate is human_required, pending_human_decision carries the gate the runner must suspend on (AC-4-1).

Attributes:

Name Type Description
allowed bool

Whether the transition is authorized.

failed_gate GateName | None

The gate that refused (None when allowed).

reason str

Human-readable explanation of the verdict.

pending_human_decision HumanDecision | None

The pending gate to suspend on, set only when failed_gate == "human_required" (AC-4-1).

PromotionResult

Bases: FrozenModel

The structured outcome of a successful promote_artifact compare-and-set (AC-2-6).

Returned (as model_dump_json()) by the promote_artifact tool so the calling agent parses JSON per the strands tool norm (research-coding-runtimes line 26).

Attributes:

Name Type Description
logical_name str

The promoted artifact's logical name.

current_revision str

The new current revision_id after promotion.

superseded_revision str

The prior current revision_id (the :data:NO_CURRENT_REVISION sentinel when this was the first revision).

RevisionConflictError

RevisionConflictError(
    revision_id, stored_hash, incoming_hash
)

Bases: Exception

Raised when a revision_id is re-persisted with content differing from the stored one (AC-1-5).

The revision store is content-addressed and append-only: a given revision_id binds to exactly one content_hash. Re-persisting the same revision_id with identical content is idempotent; re-persisting it with differing content is a conflict that leaves the stored revision unchanged.

Attributes:

Name Type Description
revision_id

The revision id that was re-persisted with differing content.

stored_hash

The content hash already on disk for that revision id.

incoming_hash

The content hash of the rejected write.

Record the conflicting revision id and the stored vs. incoming content hashes.

Source code in packages/strands-teams/src/strands_teams/models.py
def __init__(self, revision_id: str, stored_hash: str, incoming_hash: str) -> None:
    """Record the conflicting revision id and the stored vs. incoming content hashes."""
    self.revision_id = revision_id
    self.stored_hash = stored_hash
    self.incoming_hash = incoming_hash
    super().__init__(
        f"revision {revision_id!r} already persisted with content_hash {stored_hash!r}; "
        f"refusing to overwrite with differing content {incoming_hash!r}",
    )

StaleArtifactError

StaleArtifactError(logical_name, expected, actual)

Bases: Exception

Raised by promote_artifact when the optimistic compare-and-set fails (AC-2-3).

Signals that expected_current_revision did not match the alias's stored current revision for logical_name — another promotion won the race, or the caller's view of "current" is stale. The alias pointer is left unchanged.

Attributes:

Name Type Description
logical_name

The artifact whose promotion was rejected.

expected

The revision the caller believed was current.

actual

The revision actually current in the alias store.

Record the artifact, the expected revision, and the actually-current one.

Source code in packages/strands-teams/src/strands_teams/models.py
def __init__(self, logical_name: str, expected: str, actual: str) -> None:
    """Record the artifact, the expected revision, and the actually-current one."""
    self.logical_name = logical_name
    self.expected = expected
    self.actual = actual
    super().__init__(
        f"stale promotion of {logical_name!r}: expected current revision {expected!r} but alias holds {actual!r}",
    )

StrictModel

Bases: BaseModel

Base model with strict validation.

This model: - Allows mutation (frozen=False) - Validates assignments - Forbids extra fields - Strips whitespace from strings

Use for internal mutable state.

TeamConfidence

Bases: FrozenModel

Confidence metadata attached to any team judgment.

Renamed from AnalysisConfidence to keep the framework layer domain-agnostic.

TeamEvent

Bases: FrozenModel

A typed, causal, replayable event on the team event plane (ASTR §6.5, §7).

Immutable and extra="forbid" (inherited from :class:FrozenModel): an unexpected field — notably a raw-transcript or chain-of-thought field — is rejected at construction rather than silently accepted (AC-1-4). Serializing one event to a single JSON line and re-parsing it yields an equal object, so the event log is a lossless round-trip substrate for replay (AC-1-5).

event_id, created_at, and correlation_id are auto-assigned when omitted: a fresh UUID event id, a UTC creation timestamp, and a correlation id defaulting to goal_id when present and otherwise to the assigned event_id (AC-1-3).

Attributes:

Name Type Description
event_id str

Unique identity of this event (auto-assigned UUID hex if omitted).

event_type str

The kind of transition/claim (e.g. "team.dispatched", "critical_finding.created", "goal.decompose") — opaque to the framework except for the should_replan trigger vocabulary.

actor str

Identifier of the agent/team/runtime that produced the event.

recipients list[str]

Identifiers this event is addressed to (empty = broadcast).

thread_id str

The conversation/thread this event belongs to.

goal_id str

The goal (durable lead loop) this event belongs to.

task_id str

The task within the goal this event belongs to.

artifact_bindings list[str]

revision_id/artifact ids this event is about.

causal_parents list[str]

event_ids this event causally follows (replay applies an event only after all its causal parents — AC-3-3).

correlation_id str

Groups related events; defaults to goal_id or event_id (AC-1-3).

epistemic_status EpistemicStatus

The claim's standing (AC-1-2).

payload dict[str, Any]

The structured outcome of the transition — NO transcript/CoT.

evidence list[Evidence]

Evidence refs backing the event (reuses the P1 type).

created_at datetime

UTC creation timestamp (auto-assigned if omitted).

TransitionRequest

Bases: FrozenModel

A proposed consequential state transition the PolicyEngine judges (ASTR §14.7).

The model constructs one of these and hands it to PolicyEngine.authorize_transition; the engine — never the model — issues the final verdict (AC-6-1). A consequential transition (e.g. a release or a merge) is refused unless a valid revision-bound approval is present and no open blocking finding remains (AC-6-2, AC-6-3).

Attributes:

Name Type Description
actor str

The principal requesting the transition (the capability and separation-of-duties gates read this — AC-3-2, AC-3-3).

kind str

The transition kind (e.g. "release", "merge") — matched against approvals' approved_transition_kind and the human-gated set.

target_revision_id str

The revision_id the transition targets — the revision approvals must bind (AC-3-4) and findings are matched against.

consequential bool

Whether this transition is consequential (release path) and thus subject to the structural approval + blocking-finding refusal (AC-6-2, AC-6-3).

presented_tokens list[str]

The token_ids of the approvals presented for this transition (the engine resolves them against the approval store).

required_capability Literal['may_write', 'may_approve', 'may_create_blocking_finding']

The :class:AuthorityGrant capability this transition requires (defaults to may_approve); the capability gate denies when the actor's grant lacks it (AC-3-2).

target_artifact str

Optional logical artifact name the transition targets, used to match findings that reference the artifact by name as well as by revision id (AC-3-5).

PhaseSpec dataclass

PhaseSpec(
    names,
    descriptions=dict(),
    tool_phase_map=dict(),
    prefix_rules=dict(),
)

Describes the ordered phases of a run for progress display and tagging.

Attributes:

Name Type Description
names Mapping[int, str]

Map of phase index (1-based) to human-readable phase name.

descriptions Mapping[int, str]

Map of phase index to a one-line description.

tool_phase_map Mapping[str, int]

Map of exact tool name to the phase index it belongs to.

prefix_rules Mapping[str, int]

Map of tool-name prefix to a phase index, applied when no exact match is found in tool_phase_map (e.g., "gitnexus_").

count property

count

Total number of phases (the N in a [i/N] indicator).

name

name(phase)

Return the display name for a phase index, or a fallback label.

Source code in packages/strands-teams/src/strands_teams/phases.py
def name(self, phase: int) -> str:
    """Return the display name for a phase index, or a fallback label."""
    return self.names.get(phase, f"Phase {phase}")

description

description(phase)

Return the description for a phase index, or an empty string.

Source code in packages/strands-teams/src/strands_teams/phases.py
def description(self, phase: int) -> str:
    """Return the description for a phase index, or an empty string."""
    return self.descriptions.get(phase, "")

resolve

resolve(tool_name)

Resolve a tool name to its phase index.

Checks the exact tool_phase_map first, then prefix_rules.

Parameters:

Name Type Description Default
tool_name str

The invoked tool's name.

required

Returns:

Type Description
int | None

The phase index, or None if the tool maps to no phase.

Source code in packages/strands-teams/src/strands_teams/phases.py
def resolve(self, tool_name: str) -> int | None:
    """Resolve a tool name to its phase index.

    Checks the exact ``tool_phase_map`` first, then ``prefix_rules``.

    Args:
        tool_name: The invoked tool's name.

    Returns:
        The phase index, or ``None`` if the tool maps to no phase.
    """
    phase = self.tool_phase_map.get(tool_name)
    if phase is not None:
        return phase
    for prefix, mapped in self.prefix_rules.items():
        if tool_name.startswith(prefix):
            return mapped
    return None

PolicyEngine

PolicyEngine(
    *,
    signing_key,
    required_approvals=1,
    human_gated_kinds=(),
)

Runs the ordered authority-gate stack over a transition (AC-3-1).

Construct one with the run's signing_key, required_approvals threshold, and human_gated_kinds (all from :class:~strands_teams.TeamsSettings), then call :meth:authorize_transition with the transition and the invocation_state dict. The engine reads grants, the approval store, findings, and current revision hashes from that state by reference; it never writes to it (AC-3-7).

Attributes:

Name Type Description
signing_key

Key that approval signatures must verify against; a token that fails verification is treated as absent (AC-1-4).

required_approvals

Valid revision-bound approvals a consequential transition needs before the required-approvals gate passes (AC-3-4).

human_gated_kinds

Transition kinds that are human-gated (AC-4-1).

Initialize the engine with the run's policy configuration.

Parameters:

Name Type Description Default
signing_key str

The approval signing/verification key.

required
required_approvals int

Required valid approvals for a consequential transition.

1
human_gated_kinds Sequence[str]

Transition kinds that must go through a human gate.

()
Source code in packages/strands-teams/src/strands_teams/policy.py
def __init__(
    self,
    *,
    signing_key: str,
    required_approvals: int = 1,
    human_gated_kinds: Sequence[str] = (),
) -> None:
    """Initialize the engine with the run's policy configuration.

    Args:
        signing_key: The approval signing/verification key.
        required_approvals: Required valid approvals for a consequential transition.
        human_gated_kinds: Transition kinds that must go through a human gate.
    """
    self.signing_key = signing_key
    self.required_approvals = required_approvals
    self.human_gated_kinds = frozenset(human_gated_kinds)

authorize_transition

authorize_transition(
    transition, invocation_state, *, now=None
)

Return the final allow/deny verdict for transition (AC-3-1, AC-3-6).

Runs the gate stack in order and short-circuits on the first failure. When every gate passes for a non-human-gated transition, returns allowed=True (AC-3-6). Reads all state from invocation_state by reference and does not mutate it (AC-3-7).

Parameters:

Name Type Description Default
transition TransitionRequest

The proposed transition to judge.

required
invocation_state Mapping[str, Any]

The run state (grants/approvals/findings/revision hashes) read by reference.

required
now datetime | None

Clock override for expiry checks (defaults to real UTC now).

None

Returns:

Name Type Description
A PolicyVerdict

class:PolicyVerdict; on a human_required deny it also carries

PolicyVerdict

the pending :class:HumanDecision to suspend on (AC-4-1).

Source code in packages/strands-teams/src/strands_teams/policy.py
def authorize_transition(
    self,
    transition: TransitionRequest,
    invocation_state: Mapping[str, Any],
    *,
    now: datetime | None = None,
) -> PolicyVerdict:
    """Return the final allow/deny verdict for ``transition`` (AC-3-1, AC-3-6).

    Runs the gate stack in order and short-circuits on the first failure. When
    every gate passes for a non-human-gated transition, returns
    ``allowed=True`` (AC-3-6). Reads all state from ``invocation_state`` by
    reference and does not mutate it (AC-3-7).

    Args:
        transition: The proposed transition to judge.
        invocation_state: The run state (grants/approvals/findings/revision
            hashes) read by reference.
        now: Clock override for expiry checks (defaults to real UTC now).

    Returns:
        A :class:`PolicyVerdict`; on a ``human_required`` deny it also carries
        the pending :class:`HumanDecision` to suspend on (AC-4-1).
    """
    grants: Mapping[str, AuthorityGrant] = invocation_state.get(STATE_GRANTS, {})
    approvals: Mapping[str, ApprovalToken] = invocation_state.get(STATE_APPROVALS, {})
    findings: Sequence[Finding] = invocation_state.get(STATE_FINDINGS, [])
    revision_hashes: Mapping[str, str] = invocation_state.get(STATE_REVISION_HASHES, {})

    # Gate 1: capability (AC-3-2)
    grant = grants.get(transition.actor)
    if grant is None or not getattr(grant, transition.required_capability, False):
        return PolicyVerdict(
            allowed=False,
            failed_gate="capability",
            reason=(
                f"Actor '{transition.actor}' lacks required capability "
                f"'{transition.required_capability}' for transition '{transition.kind}'."
            ),
        )

    presented = [approvals[t] for t in transition.presented_tokens if t in approvals]

    # Gate 2: separation of duties (AC-3-3) — actor cannot be sole approver.
    if presented and all(tok.approver == transition.actor for tok in presented):
        return PolicyVerdict(
            allowed=False,
            failed_gate="separation_of_duties",
            reason=(
                f"Actor '{transition.actor}' is the sole approver of the transition they requested; "
                "separation of duties requires an independent approver."
            ),
        )

    # Gate 3: human-required (AC-4-1) — emit a pending gate, do not self-approve.
    if transition.kind in self.human_gated_kinds:
        pending = HumanDecision(
            transition=transition,
            prompt=(
                f"Human approval required for '{transition.kind}' transition "
                f"on revision '{transition.target_revision_id}' requested by '{transition.actor}'."
            ),
            options=["approve", "reject"],
        )
        return PolicyVerdict(
            allowed=False,
            failed_gate="human_required",
            reason=f"Transition kind '{transition.kind}' is human-gated; a human must resolve the decision.",
            pending_human_decision=pending,
        )

    # Gate 4: required approvals (AC-3-4, AC-5-1, AC-5-4) — count only VALID tokens.
    if transition.consequential or self.required_approvals > 0:
        valid = [tok for tok in presented if self._approval_is_valid(tok, transition, revision_hashes, now)]
        if len(valid) < max(self.required_approvals, 1 if transition.consequential else 0):
            return PolicyVerdict(
                allowed=False,
                failed_gate="required_approvals",
                reason=(
                    f"Transition '{transition.kind}' has {len(valid)} valid revision-bound approval(s); "
                    f"{self.required_approvals} required (stale/expired/forged tokens do not count)."
                ),
            )

    # Gate 5: open blocking finding (AC-3-5, AC-6-3).
    if self._has_open_blocking_finding(findings, transition):
        return PolicyVerdict(
            allowed=False,
            failed_gate="open_blocking_finding",
            reason=(
                f"An open blocking finding affects the target of transition '{transition.kind}'; "
                "it must be resolved before the transition is authorized."
            ),
        )

    return PolicyVerdict(
        allowed=True,
        reason=f"All gates passed for transition '{transition.kind}'.",
    )

Review

Bases: FrozenModel

A lightweight revision-bound review (AC-3-1).

A minimal record for callers that want to bind a review to an artifact revision without producing a full :class:~strands_teams.models.Finding. Immutable; reviewed_revision is the revision_id the review was produced against and reviewed_facets are the facets it covers (empty = the whole artifact).

Attributes:

Name Type Description
review_id str

Identifier of the review.

reviewed_revision str

The revision_id this review was produced against.

reviewed_facets list[str]

The artifact facets this review covers (empty = whole artifact — any changed facet invalidates it).

summary str

Optional human-readable summary of the review.

ReviewReconciliation

Bases: FrozenModel

Result of reconciling a set of bound reviews against a promoted revision (AC-3-6).

Attributes:

Name Type Description
carried_forward list[ReviewVerdict]

Reviews :func:invalidates_review reports as still valid — reused rather than recomputed.

reopened list[ReviewVerdict]

Reviews reported as invalidated — must be recomputed against the new revision.

errored list[ReviewVerdict]

Reviews whose bound revision is not in the new revision's lineage (AC-3-5) — surfaced, never silently dropped.

ReviewVerdict

Bases: FrozenModel

The structured outcome of :func:invalidates_review (AC-3-3, AC-3-4, AC-3-5).

Attributes:

Name Type Description
status ReviewStatus

valid (reuse), invalidated (re-open), or error (lineage mismatch).

reason str

Human-readable explanation of the verdict.

changed_facets list[str]

Facets semantic_diff reported as changed between the bound revision and the new revision.

intersecting_facets list[str]

The changed facets that the review covers (the invalidation trigger; empty when valid).

DimensionScore dataclass

DimensionScore(name, count, score)

The scored result for one dimension.

NarrativeRubric dataclass

NarrativeRubric(
    dimensions,
    revision_threshold=DEFAULT_REVISION_THRESHOLD,
)

The single rubric object: dimensions + revision threshold.

Attributes:

Name Type Description
dimensions tuple[RubricDimension, ...]

Ordered scoring dimensions.

revision_threshold float

Average (and per-artifact) score below which the artifact needs a revision pass (default 3.5).

score

score(text)

Score text across every dimension.

Parameters:

Name Type Description Default
text str

The narrative artifact content to score.

required

Returns:

Name Type Description
A ScoreReport

class:ScoreReport with per-dimension scores, the rounded total

ScoreReport

average, the needs_revision flag (average below the revision

ScoreReport

threshold), and improvement suggestions for weak dimensions.

Source code in packages/strands-teams/src/strands_teams/rubric.py
def score(self, text: str) -> ScoreReport:
    """Score ``text`` across every dimension.

    Args:
        text: The narrative artifact content to score.

    Returns:
        A :class:`ScoreReport` with per-dimension scores, the rounded total
        average, the ``needs_revision`` flag (average below the revision
        threshold), and improvement suggestions for weak dimensions.
    """
    scores: dict[str, float] = {}
    counts: dict[str, int] = {}
    suggestions: list[str] = []
    for dim in self.dimensions:
        count = dim.measure(text)
        value = dim.score_count(count)
        scores[dim.name] = value
        counts[dim.name] = count
        if value <= dim.weak_score:
            suggestions.append(dim.suggestion.format(count=count))
    total = round(sum(scores.values()) / len(scores), 2) if scores else 0.0
    return ScoreReport(
        scores=scores,
        total=total,
        needs_revision=total < self.revision_threshold,
        suggestions=suggestions,
        counts=counts,
    )

RubricDimension dataclass

RubricDimension(
    name,
    thresholds,
    suggestion,
    pattern=None,
    flags=0,
    unique=False,
    counter=None,
    weak_score=2.0,
)

One scored dimension of a narrative artifact.

A dimension measures a count from the text (via a regex pattern or a counter callable), then maps that count to a 0-5 score using an ascending (min_count, score) threshold table: the score is the highest table entry whose min_count does not exceed the measured count.

Attributes:

Name Type Description
name str

Dimension key (e.g., "specificity").

thresholds tuple[tuple[int, float], ...]

Ascending (min_count, score) pairs. The first pair should use min_count=0 to define the floor score.

suggestion str

Improvement hint; may contain a {count} placeholder.

pattern str | None

Regex whose match count is the measure. Ignored if counter is set.

flags int

Regex flags applied to pattern (e.g., re.MULTILINE).

unique bool

If True, count distinct matches rather than all matches.

counter Callable[[str], int] | None

Callable measuring the count directly (overrides pattern).

weak_score float

Emit suggestion when the dimension scores at or below this.

measure

measure(text)

Return the raw count this dimension measures from text.

Source code in packages/strands-teams/src/strands_teams/rubric.py
def measure(self, text: str) -> int:
    """Return the raw count this dimension measures from ``text``."""
    if self.counter is not None:
        return self.counter(text)
    if self.pattern is None:
        msg = f"RubricDimension {self.name!r} has neither pattern nor counter"
        raise ValueError(msg)
    matches = re.findall(self.pattern, text, self.flags)
    return len(set(matches)) if self.unique else len(matches)

score_count

score_count(count)

Map a measured count to its 0-5 score via the threshold table.

Source code in packages/strands-teams/src/strands_teams/rubric.py
def score_count(self, count: int) -> float:
    """Map a measured ``count`` to its 0-5 score via the threshold table."""
    score = 0.0
    for min_count, value in self.thresholds:
        if count >= min_count:
            score = value
    return score

ScoreReport dataclass

ScoreReport(
    scores,
    total,
    needs_revision,
    suggestions,
    counts=dict(),
)

Result of scoring a text against a :class:NarrativeRubric.

StreamResult

Bases: BaseModel

Result of a coordinator run.

ResourceUsage

Bases: FrozenModel

Cost and token accounting reported by a specialist harness run (ASTR §10.4).

Immutable and extra="forbid" (inherited from :class:FrozenModel). Every field defaults to a zero-ish value so an adapter can report a partial accounting (e.g. a Codex run that surfaces token counts but no dollar cost) without constructing an invalid object.

Attributes:

Name Type Description
cost_usd float

Dollar cost of the run (Claude ResultMessage.total_cost_usd; 0.0 when the provider reports none).

input_tokens int

Total input tokens consumed.

output_tokens int

Total output tokens generated.

num_turns int

Number of agent turns (tool-use round trips) the harness took.

duration_seconds float

Wall-clock duration of the run, in seconds.

SpecialistResult

Bases: FrozenModel

The structured outcome of one specialist harness run (ASTR §10.4).

This is the ONLY thing that crosses the harness boundary back into the swarm's context. It carries a disposition, the artifacts the harness changed, typed findings, evidence refs, a residual-risk note, and resource usage — but never the raw transcript, private chain-of-thought, or full terminal command log (ASTR §10.4; plan §5 "Boundary discipline"). Immutable and extra="forbid" (inherited from :class:FrozenModel), so an out-of-range disposition or an unexpected extra field is rejected at construction rather than silently coerced (AC-2-7).

Attributes:

Name Type Description
disposition SpecialistDisposition

How the run concluded — one of completed / partial / failed / refused.

artifacts list[str]

Worktree-relative path strings the harness created or modified (ground truth derivable from git status --porcelain). Typed as plain strings in Phase 3; a later phase MAY enrich these to ArtifactRevision objects (spec cross-phase note; P4 not present here).

findings list[Finding]

Typed :class:~strands_teams.models.Finding objects the harness produced (reuses the P1 type — never redefined here).

evidence list[Evidence]

Standalone :class:~strands_teams.models.Evidence refs backing the result (reuses the P1 evidence-reference type).

residual_risk str

A short prose note on risk the harness could not eliminate (empty string when none).

resource_usage ResourceUsage

Cost/token/turn/duration accounting for the run.

SpecialistRuntime

Bases: Protocol

Structural interface for an autonomous external coding harness (ASTR §10).

A SpecialistRuntime drives one bounded coding task in its own OS subprocess (a Claude Agent SDK query() subprocess, or codex exec) and reports back only a :class:SpecialistResult. This Protocol is defined in strands_teams core without importing any coding-agent SDK or subprocess — the concrete adapters live in strands_teams_runtimes and are injected into the coding tool by dependency injection, so core stays vendor-free (plan §3; research-coding-runtimes.yaml architecture).

Implementations are typically async because they await a subprocess; a runtime_checkable Protocol only checks method presence, not signatures, so both sync and async adapters satisfy it structurally.

The five methods model a submit/poll/collect lifecycle:

  • submit(task, *, workspace, model_id, capabilities) — start a harness run rooted at workspace (its only writable root) and return an opaque run-handle/id.
  • status(handle) — report the run's current lifecycle state.
  • send_input(handle, text) — feed additional input to a running harness.
  • cancel(handle) — cancel a running harness (e.g. on a wall-clock timeout).
  • collect(handle) — return the :class:SpecialistResult. Returns the structured result only — never the harness raw transcript, private chain-of-thought, or full terminal command log (ASTR §10.4).

submit

submit(task, *, workspace, model_id, capabilities=...)

Start a harness run rooted at workspace; return an opaque run handle.

Source code in packages/strands-teams/src/strands_teams/runtime.py
def submit(
    self,
    task: str,
    *,
    workspace: object,
    model_id: str,
    capabilities: Mapping[str, object] | None = ...,
) -> object:
    """Start a harness run rooted at ``workspace``; return an opaque run handle."""
    ...

status

status(handle)

Report the run's current lifecycle state (e.g. running / done).

Source code in packages/strands-teams/src/strands_teams/runtime.py
def status(self, handle: object) -> str:
    """Report the run's current lifecycle state (e.g. ``running`` / ``done``)."""
    ...

send_input

send_input(handle, text)

Feed additional input to a running harness.

Source code in packages/strands-teams/src/strands_teams/runtime.py
def send_input(self, handle: object, text: str) -> None:
    """Feed additional input to a running harness."""
    ...

cancel

cancel(handle)

Cancel a running harness (e.g. on a wall-clock timeout).

Source code in packages/strands-teams/src/strands_teams/runtime.py
def cancel(self, handle: object) -> None:
    """Cancel a running harness (e.g. on a wall-clock timeout)."""
    ...

collect

collect(handle)

Return the structured :class:SpecialistResult — never the raw transcript (ASTR §10.4).

Source code in packages/strands-teams/src/strands_teams/runtime.py
def collect(self, handle: object) -> SpecialistResult:
    """Return the structured :class:`SpecialistResult` — never the raw transcript (ASTR §10.4)."""
    ...

TeamsSettings

Bases: BaseSettings

Base settings for a strands_teams use case.

Subclass this and override model_config to set the env prefix, and set output_dir_name to the use case's artifact directory name.

Attributes:

Name Type Description
output_dir_name str

Directory name for run artifacts, relative to the target root (e.g., .code-context). Use cases override the default.

TeamInvocationState

Bases: TypedDict

Shared state propagated to every coordinator tool via invocation_state.

Built by the coordinator factory (:mod:strands_teams.coordinator) and passed to coordinator.invoke_async(prompt, invocation_state=...) by the runner, which strands forwards to each tool's tool_context.invocation_state. This replaces the pre-split module-level _output_dir / _repo_path / _tool_registry globals (AC-8-4).

Keys

layout: The run's :class:~strands_teams.layout.TeamsLayout (all paths). tool_registry: Map of tool name -> tool object, for resolving the string tool names in team-agent specs to real tool objects. node_model_config: Kwargs passed to BedrockModel(**config) when constructing each fresh team-agent node. execution_timeout: Default max seconds for an entire team swarm. node_timeout: Default max seconds per agent node within a team. use_case_name: The active use case's name (for logging/debug). rubric: The :class:~strands_teams.rubric.NarrativeRubric used by score_narrative. authority: The active team's :class:~strands_teams.models.AuthorityGrant (what it may do). Present only inside a dispatched team's swarm invocation_state; the coordinator's own state omits it. Treat it as a read-only shared reference — never mutate it from within a concurrently-executing node tool (AC-5-3; research-coding-runtimes.yaml strands_tool_pattern lines 20-22, 30). budget: The active team's :class:~strands_teams.models.Budget (how much it may spend). Same read-only, dispatched-team-only contract as authority. acceptance_criteria: The acceptance criteria the active team must satisfy. input_revisions: Opaque revision-identifier strings the team should build on (resolved to Phase-4 ArtifactRevision objects in a later phase; treated as opaque strings here — AC-2-7).

MissingUseCaseMemberError

MissingUseCaseMemberError(use_case, member)

Bases: TypeError

Raised when a UseCase omits a required member.

The message names the missing member so the failure is actionable rather than a silent default (AC-5-5).

Build the error, recording the use-case name and missing member.

Source code in packages/strands-teams/src/strands_teams/usecase.py
def __init__(self, use_case: str, member: str) -> None:
    """Build the error, recording the use-case name and missing member."""
    self.use_case = use_case
    self.member = member
    super().__init__(f"UseCase {use_case!r} is missing required member: {member!r}")

UseCase

Bases: ABC

Abstract contract a domain implements to run on strands_teams.

Class attributes (a concrete subclass must set these): name: Stable identifier for the use case. structured_output_model: Pydantic model type for the coordinator's structured output. findings_format_directive: Text injected into a dispatched team's Required-Output block describing the findings format.

Instance attributes (set in __init__): settings: The use case's :class:~strands_teams.settings.TeamsSettings subclass instance (env prefix + output dir). layout: The :class:~strands_teams.layout.TeamsLayout describing artifact paths for this run. rubric: The :class:~strands_teams.rubric.NarrativeRubric for scoring. phases: The :class:~strands_teams.phases.PhaseSpec (tool→phase + names). reasoning_prompts: Map of tool name to a reasoning-checkpoint prompt. fail_fast_exempt: Tool names whose error results are non-fatal.

domain_tools abstractmethod

domain_tools()

Return the domain-specific tool objects for the coordinator agent.

Source code in packages/strands-teams/src/strands_teams/usecase.py
@abstractmethod
def domain_tools(self) -> list[Any]:
    """Return the domain-specific tool objects for the coordinator agent."""

prompt_template abstractmethod

prompt_template()

Return the coordinator system-prompt template filename.

Source code in packages/strands-teams/src/strands_teams/usecase.py
@abstractmethod
def prompt_template(self) -> str:
    """Return the coordinator system-prompt template filename."""

template_dir abstractmethod

template_dir()

Return the directory containing this use case's prompt templates.

Source code in packages/strands-teams/src/strands_teams/usecase.py
@abstractmethod
def template_dir(self) -> Path:
    """Return the directory containing this use case's prompt templates."""

planning_context abstractmethod

planning_context()

Load the planning context injected into the coordinator prompt.

Typically reads the planning sentinel via self.layout.planning_sentinel_path().

Source code in packages/strands-teams/src/strands_teams/usecase.py
@abstractmethod
def planning_context(self) -> dict[str, Any]:
    """Load the planning context injected into the coordinator prompt.

    Typically reads the planning sentinel via ``self.layout.planning_sentinel_path()``.
    """

kickoff_prompt abstractmethod

kickoff_prompt(*, bundles_only, focus, extra_context)

Build the initial task prompt handed to the coordinator.

Parameters:

Name Type Description Default
bundles_only bool

Skip indexing/dispatch and regenerate artifacts only.

required
focus str | None

Optional area the run should prioritize.

required
extra_context str | None

Optional free-form context to append.

required

Returns:

Type Description
str

The kickoff prompt string.

Source code in packages/strands-teams/src/strands_teams/usecase.py
@abstractmethod
def kickoff_prompt(
    self,
    *,
    bundles_only: bool,
    focus: str | None,
    extra_context: str | None,
) -> str:
    """Build the initial task prompt handed to the coordinator.

    Args:
        bundles_only: Skip indexing/dispatch and regenerate artifacts only.
        focus: Optional area the run should prioritize.
        extra_context: Optional free-form context to append.

    Returns:
        The kickoff prompt string.
    """

artifact_structure_spec abstractmethod

artifact_structure_spec()

Return prompt text describing the sections each artifact must contain.

Source code in packages/strands-teams/src/strands_teams/usecase.py
@abstractmethod
def artifact_structure_spec(self) -> str:
    """Return prompt text describing the sections each artifact must contain."""

pre_run

pre_run(repo_path)

Optional deterministic pre-run step (e.g., indexing).

Default implementation is a no-op. A use case overrides this to produce its planning sentinel before the coordinator runs.

Parameters:

Name Type Description Default
repo_path Path

The target root the run operates on.

required
Source code in packages/strands-teams/src/strands_teams/usecase.py
def pre_run(self, repo_path: Path) -> None:
    """Optional deterministic pre-run step (e.g., indexing).

    Default implementation is a no-op. A use case overrides this to produce
    its planning sentinel before the coordinator runs.

    Args:
        repo_path: The target root the run operates on.
    """
    return

create_coordinator

create_coordinator(
    use_case, *, full_mode=False, focus=None, hooks=None
)

Create the coordinator Agent and its per-run invocation state.

Parameters:

Name Type Description Default
use_case UseCase

The domain use case supplying tools, prompt, model, layout, rubric, and structured-output model.

required
full_mode bool

Select full-mode reasoning effort and timeouts when True.

False
focus str | None

Optional focus area (the use case bakes this into the kickoff prompt built by the runner; kept for signature symmetry).

None
hooks list[HookProvider] | None

Optional HookProvider list added to the coordinator agent.

None

Returns:

Type Description
Agent

A (agent, invocation_state) tuple. The runner invokes the agent with

TeamInvocationState

coordinator.invoke_async(prompt, invocation_state=invocation_state).

Raises:

Type Description
MissingUseCaseMemberError

If the use case omits a required member (AC-5-5).

Source code in packages/strands-teams/src/strands_teams/coordinator.py
def create_coordinator(
    use_case: UseCase,
    *,
    full_mode: bool = False,
    focus: str | None = None,  # noqa: ARG001 — reserved for use-case kickoff; prompt handles focus
    hooks: list[HookProvider] | None = None,
) -> tuple[Agent, TeamInvocationState]:
    """Create the coordinator ``Agent`` and its per-run invocation state.

    Args:
        use_case: The domain use case supplying tools, prompt, model, layout,
            rubric, and structured-output model.
        full_mode: Select full-mode reasoning effort and timeouts when True.
        focus: Optional focus area (the use case bakes this into the kickoff
            prompt built by the runner; kept for signature symmetry).
        hooks: Optional ``HookProvider`` list added to the coordinator agent.

    Returns:
        A ``(agent, invocation_state)`` tuple. The runner invokes the agent with
        ``coordinator.invoke_async(prompt, invocation_state=invocation_state)``.

    Raises:
        MissingUseCaseMemberError: If the use case omits a required member (AC-5-5).
    """
    validate_use_case(use_case)

    settings = use_case.settings
    domain_tools = use_case.domain_tools()
    team_tools = list(TEAM_TOOLS)
    tools = [*team_tools, *domain_tools]

    tool_registry = _build_tool_registry(team_tools, domain_tools)
    node_model_config = _node_model_config(use_case, full_mode=full_mode)

    if full_mode:
        execution_timeout = float(settings.full_team_execution_timeout)
        node_timeout = float(settings.full_team_node_timeout)
    else:
        execution_timeout = float(settings.team_execution_timeout)
        node_timeout = float(settings.team_node_timeout)

    invocation_state: TeamInvocationState = {
        "layout": use_case.layout,
        "tool_registry": tool_registry,
        "node_model_config": node_model_config,
        "execution_timeout": execution_timeout,
        "node_timeout": node_timeout,
        "use_case_name": use_case.name,
        "rubric": use_case.rubric,
    }

    system_prompt = _compose_system_prompt(use_case)
    model = BedrockModel(**_coordinator_model_config(node_model_config))

    conversation_manager = SummarizingConversationManager(summary_ratio=0.3, preserve_recent_messages=10)

    agent = Agent(
        name="coordinator",
        system_prompt=system_prompt,
        model=model,
        tools=tools,
        structured_output_model=use_case.structured_output_model,
        callback_handler=None,
        conversation_manager=conversation_manager,
    )

    # The Agent's process_tools() expands ToolProvider instances (e.g. MCPClient
    # for GitNexus/context7) into individual tool objects in its registry. Merge
    # that expanded registry over the static one so dispatch_team resolves MCP
    # tool names like "gitnexus_query" to real tool objects instead of dropping
    # them as unknown strings.
    invocation_state["tool_registry"] = {**tool_registry, **dict(agent.tool_registry.registry)}

    if hooks:
        for hook in hooks:
            agent.hooks.add_hook(hook)

    logger.info(
        f"Created coordinator for use_case={use_case.name!r} with {len(tools)} tools "
        f"({len(team_tools)} team + {len(domain_tools)} domain), full_mode={full_mode}",
    )
    return agent, invocation_state

replay

replay(events, *, warnings=())

Reconstruct run state from events with no live model call (AC-3-1..AC-3-5).

Applies each event only after all its causal parents (:func:_causal_order, AC-3-3), records the transition sequence, tracks the latest non-superseded event per artifact binding, reconstructs finding and decision payloads, and reflects supersession (an event bearing epistemic_status="superseded" or an event naming another in its causal_parents with an epistemic_status in {accepted, rejected, superseded} supersedes that parent) while retaining the originals in the log (AC-3-4).

Deterministic: given the same events (and malformed-line warnings), the returned :class:ReplayState is identical across calls (AC-3-2).

Parameters:

Name Type Description Default
events Iterable[TeamEvent]

The parsed events to replay (typically EventLog.read()).

required
warnings Iterable[str]

Malformed-line warnings from parsing, folded into the result so :meth:ReplayState.to_canonical_json reflects them (AC-3-5).

()

Returns:

Name Type Description
A ReplayState

class:ReplayState reconstruction.

Source code in packages/strands-teams/src/strands_teams/events.py
def replay(events: Iterable[TeamEvent], *, warnings: Iterable[str] = ()) -> ReplayState:
    """Reconstruct run state from events with no live model call (AC-3-1..AC-3-5).

    Applies each event only after all its causal parents (:func:`_causal_order`,
    AC-3-3), records the transition sequence, tracks the latest non-superseded
    event per artifact binding, reconstructs finding and decision payloads, and
    reflects supersession (an event bearing ``epistemic_status="superseded"`` or an
    event naming another in its ``causal_parents`` with an
    ``epistemic_status`` in ``{accepted, rejected, superseded}`` supersedes that
    parent) while retaining the originals in the log (AC-3-4).

    Deterministic: given the same events (and malformed-line ``warnings``), the
    returned :class:`ReplayState` is identical across calls (AC-3-2).

    Args:
        events: The parsed events to replay (typically ``EventLog.read()``).
        warnings: Malformed-line warnings from parsing, folded into the result so
            :meth:`ReplayState.to_canonical_json` reflects them (AC-3-5).

    Returns:
        A :class:`ReplayState` reconstruction.
    """
    event_list = list(events)
    ordered, order_warnings = _causal_order(event_list)

    applied: list[str] = []
    transitions: list[str] = []
    artifacts: dict[str, str] = {}
    findings: dict[str, dict[str, Any]] = {}
    decisions: dict[str, dict[str, Any]] = {}
    superseded: set[str] = set()

    for event in ordered:
        applied.append(event.event_id)
        transitions.append(event.event_type)

        # Supersession (AC-3-4): a superseded-status event marks itself; an
        # accepted/rejected/superseded event supersedes the parents it references.
        if event.epistemic_status == "superseded":
            superseded.add(event.event_id)
        if event.epistemic_status in {"accepted", "rejected", "superseded"}:
            superseded.update(event.causal_parents)

        if _is_finding_event(event):
            findings[event.event_id] = dict(event.payload)
        if _is_decision_event(event):
            decisions[event.event_id] = dict(event.payload)

    # Artifacts: latest non-superseded event that touches each binding, in apply order.
    for event in ordered:
        if event.event_id in superseded:
            continue
        for binding in event.artifact_bindings:
            artifacts[binding] = event.event_id

    return ReplayState(
        applied=applied,
        transitions=transitions,
        artifacts=artifacts,
        findings=findings,
        decisions=decisions,
        superseded=sorted(superseded),
        warnings=[*warnings, *order_warnings],
    )

replay_log

replay_log(log)

Replay an :class:EventLog end to end, surfacing malformed-line warnings (AC-3-5).

Source code in packages/strands-teams/src/strands_teams/events.py
def replay_log(log: EventLog) -> ReplayState:
    """Replay an :class:`EventLog` end to end, surfacing malformed-line warnings (AC-3-5)."""
    events, warnings = log.read_with_warnings()
    return replay(events, warnings=warnings)

should_replan

should_replan(events, *, budget_threshold=0.0)

Decide whether the lead loop should replan (AC-6-1..AC-6-7).

Scans the event log for any of the five ASTR triggers — requirement.changed, critical_finding.created, task.failed, budget.threshold_exceeded, artifact.interface_changed — matching on either an explicit trigger-typed event or the structured payload of a generic dispatch/finding/revision event. Cumulative recorded cost crossing budget_threshold fires the budget trigger (AC-6-4). Returns replan=False with no triggers on a trigger-free log (AC-6-7).

Parameters:

Name Type Description Default
events Iterable[TeamEvent]

The events to evaluate (typically EventLog.read()).

required
budget_threshold float

The configured cumulative-cost bound; 0 disables the cumulative budget check (an explicit budget.threshold_exceeded event still fires).

0.0

Returns:

Name Type Description
A ReplanDecision

class:ReplanDecision naming the distinct triggers that fired.

Source code in packages/strands-teams/src/strands_teams/events.py
def should_replan(events: Iterable[TeamEvent], *, budget_threshold: float = 0.0) -> ReplanDecision:
    """Decide whether the lead loop should replan (AC-6-1..AC-6-7).

    Scans the event log for any of the five ASTR triggers — ``requirement.changed``,
    ``critical_finding.created``, ``task.failed``, ``budget.threshold_exceeded``,
    ``artifact.interface_changed`` — matching on either an explicit trigger-typed
    event or the structured payload of a generic dispatch/finding/revision event.
    Cumulative recorded cost crossing ``budget_threshold`` fires the budget trigger
    (AC-6-4). Returns ``replan=False`` with no triggers on a trigger-free log
    (AC-6-7).

    Args:
        events: The events to evaluate (typically ``EventLog.read()``).
        budget_threshold: The configured cumulative-cost bound; ``0`` disables the
            cumulative budget check (an explicit ``budget.threshold_exceeded`` event
            still fires).

    Returns:
        A :class:`ReplanDecision` naming the distinct triggers that fired.
    """
    event_list = list(events)
    fired: dict[str, str] = {}

    for event in event_list:
        hit = _event_fires(event)
        if hit is not None:
            trigger, reason = hit
            fired.setdefault(trigger, reason)

    if _budget_exceeded(event_list, budget_threshold=budget_threshold):
        fired.setdefault(TRIGGER_BUDGET_EXCEEDED, f"cumulative cost crossed threshold {budget_threshold}")

    ordered = [t for t in REPLAN_TRIGGERS if t in fired]
    return ReplanDecision(
        replan=bool(ordered),
        triggers=ordered,
        reasons=[fired[t] for t in ordered],
    )

evaluate_evidence_gate

evaluate_evidence_gate(findings)

Evaluate the evidence gate over a collection of findings (AC-5-1..5-3).

Parameters:

Name Type Description Default
findings Iterable[Finding]

The typed findings to check.

required

Returns:

Name Type Description
An EvidenceGateResult

class:EvidenceGateResult: passed is False when any

EvidenceGateResult

blocking=True finding lacks evidence, and flagged names each such

EvidenceGateResult

finding's statement so the coordinator can act on the gap.

Source code in packages/strands-teams/src/strands_teams/evidence_gate.py
def evaluate_evidence_gate(findings: Iterable[Finding]) -> EvidenceGateResult:
    """Evaluate the evidence gate over a collection of findings (AC-5-1..5-3).

    Args:
        findings: The typed findings to check.

    Returns:
        An :class:`EvidenceGateResult`: ``passed`` is False when any
        ``blocking=True`` finding lacks evidence, and ``flagged`` names each such
        finding's statement so the coordinator can act on the gap.
    """
    flagged: list[str] = []
    checked = 0
    for finding in findings:
        checked += 1
        if not finding_passes_gate(finding):
            flagged.append(finding.statement)
    return EvidenceGateResult(passed=not flagged, flagged=flagged, checked=checked)

gate_findings

gate_findings(findings)

Return the evidence-gate summary as a plain dict for embedding in tool JSON.

Convenience wrapper around :func:evaluate_evidence_gate producing the evidence_gate block used by the write and read tool results (AC-5-2, AC-5-3).

Source code in packages/strands-teams/src/strands_teams/evidence_gate.py
def gate_findings(findings: Iterable[Finding]) -> dict[str, object]:
    """Return the evidence-gate summary as a plain dict for embedding in tool JSON.

    Convenience wrapper around :func:`evaluate_evidence_gate` producing the
    ``evidence_gate`` block used by the write and read tool results (AC-5-2, AC-5-3).
    """
    result = evaluate_evidence_gate(findings)
    return {"passed": result.passed, "flagged": result.flagged, "checked": result.checked}

run_goal async

run_goal(
    goal,
    *,
    layout,
    invocation_state=None,
    dispatch=None,
    engine=None,
    max_iterations=8,
    budget_threshold=0.0,
    goal_id=None,
    thread_id="",
)

Drive the durable lead loop for goal to a terminal status (AC-5-1, AC-5-3..AC-5-7).

Runs decompose → compose → assign → dispatch → integrate each iteration, each as a :class:~strands_teams.durable.DurableStep recording a :class:~strands_teams.models.TeamEvent (AC-5-3). After integrate it consults :func:~strands_teams.events.should_replan over the run's :class:EventLog; when a trigger fires it records a goal.replan event and re-enters decompose (AC-5-7). Terminates — with a final goal.terminated event — on convergence (no replan), on reaching max_iterations, or on crossing budget_threshold (AC-5-6). Never calls asyncio.run (AC-5-2 is the sync boundary).

Parameters:

Name Type Description Default
goal str

The goal statement driving the loop.

required
layout TeamsLayout

The run's :class:~strands_teams.layout.TeamsLayout; its :meth:~strands_teams.layout.TeamsLayout.event_log_path is the plane.

required
invocation_state dict[str, Any] | None

The by-reference dict propagated to dispatched teams; goal_id / task_id / thread_id / layout are injected into a shallow copy so teams (and the default dispatch_team) see them (AC-5-4). Never mutated in place across concurrent node tools.

None
dispatch Callable[[dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]]] | None

Injectable dispatch callable (AC-5-5); defaults to :func:_default_dispatch (native-Swarm dispatch_team).

None
engine DurableStep | None

The :class:~strands_teams.durable.DurableStep engine; defaults to an in-process :class:~strands_teams.durable.AsyncioDurableEngine bound to the run's :class:EventLog.

None
max_iterations int

Hard cap on lead-loop iterations (AC-5-6).

8
budget_threshold float

Cumulative-cost bound; crossing it terminates the loop and fires the budget replan trigger (AC-5-6, AC-6-4).

0.0
goal_id str | None

Stable id for the goal (auto-assigned when omitted).

None
thread_id str

Optional conversation/thread id propagated to teams.

''

Returns:

Name Type Description
A GoalResult

class:GoalResult naming the terminal status and recorded transitions.

Source code in packages/strands-teams/src/strands_teams/goal_runner.py
async def run_goal(
    goal: str,
    *,
    layout: TeamsLayout,
    invocation_state: dict[str, Any] | None = None,
    dispatch: Callable[[dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]]] | None = None,
    engine: DurableStep | None = None,
    max_iterations: int = 8,
    budget_threshold: float = 0.0,
    goal_id: str | None = None,
    thread_id: str = "",
) -> GoalResult:
    """Drive the durable lead loop for ``goal`` to a terminal status (AC-5-1, AC-5-3..AC-5-7).

    Runs decompose → compose → assign → dispatch → integrate each iteration, each as
    a :class:`~strands_teams.durable.DurableStep` recording a
    :class:`~strands_teams.models.TeamEvent` (AC-5-3). After ``integrate`` it
    consults :func:`~strands_teams.events.should_replan` over the run's
    :class:`EventLog`; when a trigger fires it records a ``goal.replan`` event and
    re-enters decompose (AC-5-7). Terminates — with a final ``goal.terminated``
    event — on convergence (no replan), on reaching ``max_iterations``, or on
    crossing ``budget_threshold`` (AC-5-6). Never calls ``asyncio.run`` (AC-5-2 is
    the sync boundary).

    Args:
        goal: The goal statement driving the loop.
        layout: The run's :class:`~strands_teams.layout.TeamsLayout`; its
            :meth:`~strands_teams.layout.TeamsLayout.event_log_path` is the plane.
        invocation_state: The by-reference dict propagated to dispatched teams;
            ``goal_id`` / ``task_id`` / ``thread_id`` / ``layout`` are injected into
            a shallow copy so teams (and the default ``dispatch_team``) see them
            (AC-5-4). Never mutated in place across concurrent node tools.
        dispatch: Injectable dispatch callable (AC-5-5); defaults to
            :func:`_default_dispatch` (native-``Swarm`` ``dispatch_team``).
        engine: The :class:`~strands_teams.durable.DurableStep` engine; defaults to
            an in-process :class:`~strands_teams.durable.AsyncioDurableEngine` bound
            to the run's :class:`EventLog`.
        max_iterations: Hard cap on lead-loop iterations (AC-5-6).
        budget_threshold: Cumulative-cost bound; crossing it terminates the loop and
            fires the budget replan trigger (AC-5-6, AC-6-4).
        goal_id: Stable id for the goal (auto-assigned when omitted).
        thread_id: Optional conversation/thread id propagated to teams.

    Returns:
        A :class:`GoalResult` naming the terminal status and recorded transitions.
    """
    gid = goal_id or uuid4().hex
    log = EventLog(layout.event_log_path())
    step_engine: DurableStep = engine or AsyncioDurableEngine(log)
    dispatch_fn = dispatch or _default_dispatch
    # The default dispatch runs the real ``dispatch_team`` tool, which emits its own
    # ``team.dispatched`` event to this same log. An injected stub does not. Only hoist
    # a top-level event for a stub, so the default path is not double-counted by the
    # cumulative budget check (which sums cost across every event). AC-6-4.
    dispatch_self_emits = dispatch is None

    # A shallow copy carrying the loop's ids + layout by reference (AC-5-4). Teams
    # treat it read-only; concurrent node tools never mutate it.
    base_state: dict[str, Any] = {
        **(invocation_state or {}),
        "layout": layout,
        "goal_id": gid,
        "thread_id": thread_id,
    }

    _emit(
        log,
        TeamEvent(event_type="goal.started", actor="lead", goal_id=gid, thread_id=thread_id, payload={"goal": goal}),
    )

    iterations = 0
    replans = 0
    status = "converged"

    while True:
        if iterations >= max_iterations:
            status = "max_iterations"
            break

        iterations += 1
        task_id = f"{gid}-iter{iterations}"

        # Snapshot the log length BEFORE this iteration's stages append anything, so
        # the per-event replan check below is edge-triggered: only events appended
        # during THIS iteration can (re-)fire a trigger (AC-5-7). Level-checking the
        # whole cumulative log would let a single past task.failed / critical_finding
        # re-fire replan forever, so a transient failure that later recovers would
        # still burn to max_iterations instead of converging.
        cursor = len(log.read())

        for stage in LEAD_STAGES:
            step_id = f"{gid}:{iterations}:{stage}"

            async def _run_stage(
                stage: str = stage, task_id: str = task_id, iteration: int = iterations
            ) -> dict[str, Any]:
                if stage == "dispatch":
                    assignment = {
                        "team_id": task_id,
                        "mandate": goal,
                        "agents": [],
                        "goal_id": gid,
                        "task_id": task_id,
                    }
                    team_state = {**base_state, "task_id": task_id}
                    return await dispatch_fn(assignment, team_state)
                return {"stage": stage, "iteration": iteration}

            step = await step_engine.run_step(
                step_id,
                stage,
                _run_stage,
                goal_id=gid,
                task_id=task_id,
                thread_id=thread_id,
                actor="lead",
            )

            # Hoist the dispatch outcome onto the event plane as a top-level
            # ``team.dispatched`` event so should_replan reads its status /
            # resource_usage directly (the step.result event nests them under
            # ``result``). The default dispatch's real dispatch_team already emits
            # its own event to this log, so hoisting only for an injected stub avoids
            # double-counting cost in the cumulative budget check (AC-5-7, AC-6-3, AC-6-4).
            if stage == "dispatch" and not dispatch_self_emits:
                _emit(
                    log,
                    TeamEvent(
                        event_type="team.dispatched",
                        actor="lead",
                        goal_id=gid,
                        task_id=task_id,
                        thread_id=thread_id,
                        epistemic_status="observed",
                        payload=dict(step.payload),
                    ),
                )

        full_events = log.read()

        # Budget is intentionally CUMULATIVE (AC-6-4): summed across the whole run,
        # not just this iteration. Crossing it terminates the loop (AC-5-6). Evaluated
        # over the full log so prior iterations' recorded cost still counts.
        if budget_threshold > 0:
            budget_decision = should_replan(full_events, budget_threshold=budget_threshold)
            if TRIGGER_BUDGET_EXCEEDED in budget_decision.triggers:
                status = "budget_exceeded"
                break

        # Per-event replan triggers are EDGE-triggered (AC-5-7): evaluate only the
        # events appended during THIS iteration (``full_events[cursor:]``), never the
        # cumulative log. A single past task.failed / critical_finding must not
        # re-fire replan forever — otherwise a transient failure that later recovers
        # would still burn every iteration to max_iterations instead of converging.
        # Budget is passed as disabled here; it is handled cumulatively above.
        decision = should_replan(full_events[cursor:])

        if decision.replan:
            replans += 1
            _emit(
                log,
                TeamEvent(
                    event_type="goal.replan",
                    actor="lead",
                    goal_id=gid,
                    task_id=task_id,
                    thread_id=thread_id,
                    epistemic_status="inferred",
                    payload={"triggers": decision.triggers, "reasons": decision.reasons},
                ),
            )
            logger.debug(f"run_goal({gid}): replanning on triggers {decision.triggers}")
            continue

        status = "converged"
        break

    _emit(
        log,
        TeamEvent(
            event_type="goal.terminated",
            actor="lead",
            goal_id=gid,
            thread_id=thread_id,
            epistemic_status="accepted",
            payload={"status": status, "iterations": iterations, "replans": replans},
        ),
    )

    transitions = [e.event_type for e in log.read()]
    return GoalResult(
        goal_id=gid,
        status=status,
        iterations=iterations,
        replans=replans,
        event_log_path=str(layout.event_log_path()),
        transitions=transitions,
    )

run_goal_sync

run_goal_sync(
    goal,
    *,
    layout,
    invocation_state=None,
    dispatch=None,
    engine=None,
    max_iterations=8,
    budget_threshold=0.0,
    goal_id=None,
    thread_id="",
)

Synchronous wrapper around :func:run_goal (AC-5-2).

Mirrors :func:~strands_teams.runner.run_teams_sync: the single asyncio.run boundary. Never call this from inside an already-running event loop — use the async :func:run_goal there (research-coding-runtimes.yaml line 18).

Source code in packages/strands-teams/src/strands_teams/goal_runner.py
def run_goal_sync(
    goal: str,
    *,
    layout: TeamsLayout,
    invocation_state: dict[str, Any] | None = None,
    dispatch: Callable[[dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]]] | None = None,
    engine: DurableStep | None = None,
    max_iterations: int = 8,
    budget_threshold: float = 0.0,
    goal_id: str | None = None,
    thread_id: str = "",
) -> GoalResult:
    """Synchronous wrapper around :func:`run_goal` (AC-5-2).

    Mirrors :func:`~strands_teams.runner.run_teams_sync`: the single ``asyncio.run``
    boundary. Never call this from inside an already-running event loop — use the
    async :func:`run_goal` there (``research-coding-runtimes.yaml`` line 18).
    """
    return asyncio.run(
        run_goal(
            goal,
            layout=layout,
            invocation_state=invocation_state,
            dispatch=dispatch,
            engine=engine,
            max_iterations=max_iterations,
            budget_threshold=budget_threshold,
            goal_id=goal_id,
            thread_id=thread_id,
        ),
    )

compute_content_hash

compute_content_hash(facets)

Return a deterministic content hash over a revision's facet contents (AC-1-2).

The hash is a function of the facet content alone — not of any metadata (logical_name, produced_by, created_at). Each facet's content is hashed independently, then those per-facet hashes are folded into one digest via :func:compute_content_hash_from_facet_hashes (so the whole-artifact hash and the per-facet hashes on the persisted record always agree). Two revisions built from byte-identical facet content therefore produce equal hashes; any differing byte produces a different hash (AC-1-2).

Parameters:

Name Type Description Default
facets Mapping[str, str]

Mapping of facet name to that facet's content string. An empty mapping hashes to the well-defined fold of {}.

required

Returns:

Type Description
str

A 64-char lowercase hex SHA-256 digest.

Source code in packages/strands-teams/src/strands_teams/models.py
def compute_content_hash(facets: Mapping[str, str]) -> str:
    """Return a deterministic content hash over a revision's facet contents (AC-1-2).

    The hash is a function of the facet *content* alone — not of any metadata
    (``logical_name``, ``produced_by``, ``created_at``). Each facet's content is
    hashed independently, then those per-facet hashes are folded into one digest
    via :func:`compute_content_hash_from_facet_hashes` (so the whole-artifact hash
    and the per-facet hashes on the persisted record always agree). Two revisions
    built from byte-identical facet content therefore produce equal hashes; any
    differing byte produces a different hash (AC-1-2).

    Args:
        facets: Mapping of facet name to that facet's content string. An empty
            mapping hashes to the well-defined fold of ``{}``.

    Returns:
        A 64-char lowercase hex SHA-256 digest.
    """
    return compute_content_hash_from_facet_hashes(_facet_hashes(facets))

denying_decision

denying_decision(verdict, transition)

Build an auditable :class:Decision recording a denied transition (AC-4-6).

Used by the runner when a human rejects a gate or the engine refuses a transition, so the denial is captured as a first-class artifact rather than living only in a log line.

Parameters:

Name Type Description Default
verdict PolicyVerdict

The denying verdict.

required
transition TransitionRequest

The transition that was refused.

required

Returns:

Type Description
Decision

A frozen :class:Decision selecting deny.

Source code in packages/strands-teams/src/strands_teams/policy.py
def denying_decision(verdict: PolicyVerdict, transition: TransitionRequest) -> Decision:
    """Build an auditable :class:`Decision` recording a denied transition (AC-4-6).

    Used by the runner when a human rejects a gate or the engine refuses a
    transition, so the denial is captured as a first-class artifact rather than
    living only in a log line.

    Args:
        verdict: The denying verdict.
        transition: The transition that was refused.

    Returns:
        A frozen :class:`Decision` selecting ``deny``.
    """
    return Decision(
        question=f"Authorize '{transition.kind}' transition on '{transition.target_revision_id}'?",
        options=["allow", "deny"],
        selected="deny",
        rationale=verdict.reason or f"Denied at gate '{verdict.failed_gate}'.",
    )

invalidates_review

invalidates_review(old_binding, new_revision, review)

Decide whether new_revision invalidates a review bound to old_binding (AC-3-3..5).

Runs :func:semantic_diff between the revisions and intersects the changed facets with the facets the review covers:

  • invalidated when the intersection is non-empty — a facet the review examined changed, so the review must be re-opened (AC-3-3).
  • valid when the intersection is empty — the review's facets are untouched, so it is carried forward and reused rather than recomputed (AC-3-4). A review that covers no specific facets (empty reviewed_facets) is treated as covering the WHOLE artifact, so any changed facet invalidates it.
  • error when new_revision's lineage does not supersede the review's bound revision — the verdict cannot be trusted, so it is reported as an error rather than silently treated as valid or invalidated (AC-3-5).

Parameters:

Name Type Description Default
old_binding ArtifactRevision

The revision the review was produced against.

required
new_revision ArtifactRevision

The candidate new revision.

required
review ReviewBinding | Any

A revision-bound review — a :class:Review or a Phase 1 :class:~strands_teams.models.Finding (consumed structurally).

required

Returns:

Name Type Description
A ReviewVerdict

class:ReviewVerdict with the status, reason, and the changed /

ReviewVerdict

intersecting facet sets.

Source code in packages/strands-teams/src/strands_teams/revisions.py
def invalidates_review(
    old_binding: ArtifactRevision,
    new_revision: ArtifactRevision,
    review: ReviewBinding | Any,
) -> ReviewVerdict:
    """Decide whether ``new_revision`` invalidates a review bound to ``old_binding`` (AC-3-3..5).

    Runs :func:`semantic_diff` between the revisions and intersects the changed
    facets with the facets the review covers:

    - **invalidated** when the intersection is non-empty — a facet the review
      examined changed, so the review must be re-opened (AC-3-3).
    - **valid** when the intersection is empty — the review's facets are untouched,
      so it is carried forward and reused rather than recomputed (AC-3-4). A review
      that covers no specific facets (empty ``reviewed_facets``) is treated as
      covering the WHOLE artifact, so any changed facet invalidates it.
    - **error** when ``new_revision``'s lineage does not supersede the review's
      bound revision — the verdict cannot be trusted, so it is reported as an error
      rather than silently treated as valid or invalidated (AC-3-5).

    Args:
        old_binding: The revision the review was produced against.
        new_revision: The candidate new revision.
        review: A revision-bound review — a :class:`Review` or a Phase 1
            :class:`~strands_teams.models.Finding` (consumed structurally).

    Returns:
        A :class:`ReviewVerdict` with the status, reason, and the changed /
        intersecting facet sets.
    """
    bound_revision, covered = _review_binding(review)

    if not _lineage_supersedes(old_binding, new_revision, bound_revision):
        return ReviewVerdict(
            status="error",
            reason=(
                f"new_revision {new_revision.revision_id!r} does not supersede the review's bound revision "
                f"{bound_revision or old_binding.revision_id!r} (lineage: supersedes="
                f"{new_revision.supersedes}, parents={new_revision.parents})"
            ),
        )

    changed = semantic_diff(old_binding, new_revision)
    # Empty covered-facet set means the review covers the whole artifact: any change touches it.
    intersecting = sorted(changed) if not covered else sorted(changed & set(covered))

    if intersecting:
        return ReviewVerdict(
            status="invalidated",
            reason=f"changed facets {intersecting} intersect the review's covered facets",
            changed_facets=sorted(changed),
            intersecting_facets=intersecting,
        )
    return ReviewVerdict(
        status="valid",
        reason="no changed facet intersects the review's covered facets — reuse the review",
        changed_facets=sorted(changed),
        intersecting_facets=[],
    )

promote_artifact

promote_artifact(
    logical_name,
    expected_current_revision,
    proposed_revision,
    tool_context,
)

Promote a new revision of a logical artifact by optimistic compare-and-set.

Makes proposed_revision the current revision of logical_name ONLY when expected_current_revision still matches the alias's stored current revision — a durable optimistic lock. If another promotion changed the current revision since you read it, this returns a status="stale" error and leaves the alias unchanged; re-read the current revision and retry. Pass the sentinel "∅" as expected_current_revision for the FIRST promotion of a logical name.

The proposed revision's content is content-addressed: supply its content as proposed_revision={"facets": {"<facet>": "<content>", ...}} (preferred, so facet-level review invalidation works) or {"body": "<content>"}; the revision id and content hash are computed from that content. On success the prior current revision is recorded in the new revision's supersedes lineage.

Parameters:

Name Type Description Default
logical_name str

The logical artifact to promote (e.g. "BUNDLE.auth").

required
expected_current_revision str

The revision id you believe is current, or the sentinel "∅" for the first promotion.

required
proposed_revision dict[str, Any]

The new revision's content as a JSON dict — facets (a {name: content} map) and/or body (a single string), plus optional artifact_type, produced_by, parents, classification.

required
tool_context ToolContext

Framework-injected run context (invocation state).

required

Returns:

Type Description
str

On success, a JSON object with logical_name, the new current

str

current_revision id, and the superseded_revision id (the sentinel

str

when this was the first revision). On a failed compare-and-set,

str

{"status": "stale", "message": ..., "expected": ..., "actual": ...} so

str

you can re-read and retry.

Source code in packages/strands-teams/src/strands_teams/revisions.py
@tool(context=True)
def promote_artifact(
    logical_name: str,
    expected_current_revision: str,
    proposed_revision: dict[str, Any],
    tool_context: ToolContext,
) -> str:
    """Promote a new revision of a logical artifact by optimistic compare-and-set.

    Makes ``proposed_revision`` the current revision of ``logical_name`` ONLY when
    ``expected_current_revision`` still matches the alias's stored current revision
    — a durable optimistic lock. If another promotion changed the current revision
    since you read it, this returns a ``status="stale"`` error and leaves the alias
    unchanged; re-read the current revision and retry. Pass the sentinel ``"∅"`` as
    ``expected_current_revision`` for the FIRST promotion of a logical name.

    The proposed revision's content is content-addressed: supply its content as
    ``proposed_revision={"facets": {"<facet>": "<content>", ...}}`` (preferred, so
    facet-level review invalidation works) or ``{"body": "<content>"}``; the
    revision id and content hash are computed from that content. On success the
    prior current revision is recorded in the new revision's ``supersedes`` lineage.

    Args:
        logical_name: The logical artifact to promote (e.g. ``"BUNDLE.auth"``).
        expected_current_revision: The revision id you believe is current, or the
            sentinel ``"∅"`` for the first promotion.
        proposed_revision: The new revision's content as a JSON dict — ``facets``
            (a ``{name: content}`` map) and/or ``body`` (a single string), plus
            optional ``artifact_type``, ``produced_by``, ``parents``,
            ``classification``.
        tool_context: Framework-injected run context (invocation state).

    Returns:
        On success, a JSON object with ``logical_name``, the new current
        ``current_revision`` id, and the ``superseded_revision`` id (the sentinel
        when this was the first revision). On a failed compare-and-set,
        ``{"status": "stale", "message": ..., "expected": ..., "actual": ...}`` so
        you can re-read and retry.
    """
    layout = _layout_of(tool_context)
    try:
        proposed = _coerce_proposed_revision(logical_name, proposed_revision)
    except (PydanticValidationError, ValueError, TypeError) as e:
        return json.dumps({"status": "error", "message": f"invalid proposed_revision: {e}"})

    try:
        stored = layout.promote_revision(logical_name, expected_current_revision, proposed)
    except StaleArtifactError as e:
        logger.info(f"promote_artifact({logical_name}): stale CAS — expected {e.expected!r}, actual {e.actual!r}")
        return json.dumps(
            {
                "status": "stale",
                "message": str(e),
                "logical_name": logical_name,
                "expected": e.expected,
                "actual": e.actual,
            },
        )
    except OSError as e:
        return json.dumps({"status": "error", "message": f"promotion failed: {e}"})

    superseded = expected_current_revision if expected_current_revision != NO_CURRENT_REVISION else NO_CURRENT_REVISION
    result = PromotionResult(
        logical_name=logical_name,
        current_revision=stored.revision_id,
        superseded_revision=superseded,
    )
    return result.model_dump_json()

reconcile_reviews

reconcile_reviews(old_binding, new_revision, reviews)

Partition bound reviews into carried-forward, re-opened, and errored (AC-3-6).

When a re-run promotes a new revision of a logical artifact, every bound review that :func:invalidates_review reports as still valid is carried forward (reused), only the invalidated reviews are re-opened, and any lineage-mismatch review is surfaced as an error (AC-3-5, AC-3-6).

Parameters:

Name Type Description Default
old_binding ArtifactRevision

The revision the reviews were produced against.

required
new_revision ArtifactRevision

The newly promoted revision.

required
reviews Iterable[ReviewBinding | Any]

The bound reviews to reconcile.

required

Returns:

Name Type Description
A ReviewReconciliation

class:ReviewReconciliation grouping the verdicts.

Source code in packages/strands-teams/src/strands_teams/revisions.py
def reconcile_reviews(
    old_binding: ArtifactRevision,
    new_revision: ArtifactRevision,
    reviews: Iterable[ReviewBinding | Any],
) -> ReviewReconciliation:
    """Partition bound reviews into carried-forward, re-opened, and errored (AC-3-6).

    When a re-run promotes a new revision of a logical artifact, every bound review
    that :func:`invalidates_review` reports as still valid is carried forward
    (reused), only the invalidated reviews are re-opened, and any lineage-mismatch
    review is surfaced as an error (AC-3-5, AC-3-6).

    Args:
        old_binding: The revision the reviews were produced against.
        new_revision: The newly promoted revision.
        reviews: The bound reviews to reconcile.

    Returns:
        A :class:`ReviewReconciliation` grouping the verdicts.
    """
    carried: list[ReviewVerdict] = []
    reopened: list[ReviewVerdict] = []
    errored: list[ReviewVerdict] = []
    for review in reviews:
        verdict = invalidates_review(old_binding, new_revision, review)
        if verdict.status == "valid":
            carried.append(verdict)
        elif verdict.status == "invalidated":
            reopened.append(verdict)
        else:
            errored.append(verdict)
    return ReviewReconciliation(carried_forward=carried, reopened=reopened, errored=errored)

semantic_diff

semantic_diff(old_revision, new_revision)

Return the set of artifact facets whose content changed between two revisions (AC-3-2).

Compares the two revisions' per-facet content hashes (:attr:~strands_teams.models.ArtifactRevision.facets). A facet is changed when its hash differs between the revisions, when it exists in only one of them (added or removed), and unchanged when both hashes are equal. The comparison is over hashes, not raw content, so it is exact and cheap.

Parameters:

Name Type Description Default
old_revision ArtifactRevision

The earlier revision.

required
new_revision ArtifactRevision

The later revision.

required

Returns:

Type Description
set[str]

The set of changed facet names. Empty when the two revisions carry

set[str]

identical facet content.

Source code in packages/strands-teams/src/strands_teams/revisions.py
def semantic_diff(old_revision: ArtifactRevision, new_revision: ArtifactRevision) -> set[str]:
    """Return the set of artifact facets whose content changed between two revisions (AC-3-2).

    Compares the two revisions' per-facet content hashes
    (:attr:`~strands_teams.models.ArtifactRevision.facets`). A facet is *changed*
    when its hash differs between the revisions, when it exists in only one of them
    (added or removed), and unchanged when both hashes are equal. The comparison is
    over hashes, not raw content, so it is exact and cheap.

    Args:
        old_revision: The earlier revision.
        new_revision: The later revision.

    Returns:
        The set of changed facet names. Empty when the two revisions carry
        identical facet content.
    """
    old_facets = old_revision.facets
    new_facets = new_revision.facets
    changed: set[str] = set()
    for name in old_facets.keys() | new_facets.keys():
        if old_facets.get(name) != new_facets.get(name):
            changed.add(name)
    return changed

run_teams async

run_teams(
    use_case,
    *,
    repo_path,
    focus=None,
    extra_context=None,
    bundles_only=False,
    full_mode=False,
    quiet=False,
)

Run a use-case coordinator end to end.

Parameters:

Name Type Description Default
use_case UseCase

The domain use case to run.

required
repo_path str | Path

The target root the run operates on (passed to pre_run).

required
focus str | None

Optional focus area.

None
extra_context str | None

Optional free-form context for the kickoff prompt.

None
bundles_only bool

Skip dispatch and regenerate artifacts from existing findings.

False
full_mode bool

Use full-mode reasoning effort and timeouts.

False
quiet bool

Suppress the Rich display.

False

Returns:

Type Description
dict[str, Any]

Dict with run status, duration, output paths, and the structured output.

Source code in packages/strands-teams/src/strands_teams/runner.py
async def run_teams(
    use_case: UseCase,
    *,
    repo_path: str | Path,
    focus: str | None = None,
    extra_context: str | None = None,
    bundles_only: bool = False,
    full_mode: bool = False,
    quiet: bool = False,
) -> dict[str, Any]:
    """Run a use-case coordinator end to end.

    Args:
        use_case: The domain use case to run.
        repo_path: The target root the run operates on (passed to ``pre_run``).
        focus: Optional focus area.
        extra_context: Optional free-form context for the kickoff prompt.
        bundles_only: Skip dispatch and regenerate artifacts from existing findings.
        full_mode: Use full-mode reasoning effort and timeouts.
        quiet: Suppress the Rich display.

    Returns:
        Dict with run status, duration, output paths, and the structured output.
    """
    from .coordinator import create_coordinator

    repo = Path(repo_path).resolve()
    output_root = use_case.layout.output_root
    output_root.mkdir(parents=True, exist_ok=True)

    # Deterministic pre-run (e.g. indexing) unless we are only regenerating artifacts.
    if not bundles_only:
        use_case.pre_run(repo)

    state = _make_display_state(use_case, quiet=quiet, full_mode=full_mode)
    hooks = _make_hooks(use_case, full_mode=full_mode, state=state, quiet=quiet)
    coordinator, invocation_state = create_coordinator(use_case, full_mode=full_mode, focus=focus, hooks=hooks)

    prompt = use_case.kickoff_prompt(bundles_only=bundles_only, focus=focus, extra_context=extra_context)

    live = _start_live_display(use_case, state, full_mode=full_mode)
    try:
        stream_result = await _run_coordinator(coordinator, prompt, invocation_state)
    finally:
        _cleanup(live)

    _persist_structured_output(use_case, stream_result.structured_output)

    summary_path = use_case.layout.executive_summary_path()
    return {
        "status": stream_result.status,
        "error": stream_result.error_message,
        "duration_seconds": stream_result.duration_seconds,
        "repo_path": str(repo),
        "output_dir": str(output_root),
        "executive_summary_path": str(summary_path) if summary_path.exists() else None,
        "structured_output": stream_result.structured_output,
    }

run_teams_sync

run_teams_sync(
    use_case,
    *,
    repo_path,
    focus=None,
    extra_context=None,
    bundles_only=False,
    full_mode=False,
    quiet=False,
)

Synchronous wrapper around :func:run_teams.

Source code in packages/strands-teams/src/strands_teams/runner.py
def run_teams_sync(
    use_case: UseCase,
    *,
    repo_path: str | Path,
    focus: str | None = None,
    extra_context: str | None = None,
    bundles_only: bool = False,
    full_mode: bool = False,
    quiet: bool = False,
) -> dict[str, Any]:
    """Synchronous wrapper around :func:`run_teams`."""
    return asyncio.run(
        run_teams(
            use_case,
            repo_path=repo_path,
            focus=focus,
            extra_context=extra_context,
            bundles_only=bundles_only,
            full_mode=full_mode,
            quiet=quiet,
        ),
    )

dispatch_team async

dispatch_team(
    team_id,
    mandate,
    agents,
    tool_context,
    file_scope=None,
    key_questions=None,
    artifact_pointers=None,
    acceptance_criteria=None,
    authority=None,
    budget=None,
    input_revisions=None,
    max_handoffs=10,
    execution_timeout=None,
    node_timeout=None,
)

Dispatch a specialist team to investigate one area and persist its findings.

Each team runs as a native Strands Swarm — a group of agents that hand off to one another to complete a mandate. The team's task is automatically augmented with a Required-Output block instructing the last agent to write its findings and metadata files (paths are supplied in the augmented task). A confined write_file tool is auto-added to every agent.

TASK CONTRACT — a team is dispatched under a typed authority + budget contract: - authority grants capabilities. A team whose grant has may_create_blocking_finding false CANNOT persist a blocking finding: this tool strips any such finding from the team's typed findings and returns an error. Enforcement is in this tool's code, not the agents' prompts. Omit authority to permit blocking findings (legacy behavior). - budget bounds resources. A positive max_wall_clock_minutes sets the swarm's execution timeout to max_wall_clock_minutes * 60. - acceptance_criteria are the conditions the team must satisfy; they are surfaced in the team task AND checked for coverage by the quality hook. - input_revisions are opaque revision-id strings the team builds on.

AGENT SPEC FORMAT — each entry in agents is a dict with: - name (str): unique node name (e.g. "analyst", "reader"). - system_prompt (str): role and instructions for this agent. - tools (list[str]): tool names for this agent, resolved from the coordinator's tool registry. Unknown names are dropped.

Parameters:

Name Type Description Default
team_id str

Unique team identifier (e.g. "team-structure").

required
mandate str

What to investigate and why (1-3 sentences).

required
agents list[dict[str, Any]]

List of agent spec dicts (see AGENT SPEC FORMAT).

required
tool_context ToolContext

Framework-injected run context (invocation state).

required
file_scope list[str] | None

Optional file paths or directories to focus on.

None
key_questions list[str] | None

Optional questions the team should answer.

None
artifact_pointers list[str] | None

Optional pre-computed artifact paths to consult.

None
acceptance_criteria list[str] | None

Optional conditions the team's output must satisfy.

None
authority dict[str, Any] | None

Optional capability grant as a JSON dict with any of may_create_blocking_finding, may_write, may_approve (each defaults false). Omit to permit blocking findings (legacy).

None
budget dict[str, Any] | None

Optional resource envelope as a JSON dict with any of max_wall_clock_minutes, max_cost_usd, max_retries.

None
input_revisions list[str] | None

Optional opaque revision-id strings the team builds on.

None
max_handoffs int

Max handoffs (and iterations) within the team.

10
execution_timeout float | None

Max seconds for the entire team. Omit for the run default.

None
node_timeout float | None

Max seconds per agent turn. Omit for the run default.

None

Returns:

Type Description
str

JSON with team_id, status, duration_seconds, findings_path,

str

metadata_path, and a result summary. On failure — including a

str

rejected forbidden blocking finding — status is "error" and

str

message carries the reason.

Source code in packages/strands-teams/src/strands_teams/teams.py
@tool(context=True)
async def dispatch_team(
    team_id: str,
    mandate: str,
    agents: list[dict[str, Any]],
    tool_context: ToolContext,
    file_scope: list[str] | None = None,
    key_questions: list[str] | None = None,
    artifact_pointers: list[str] | None = None,
    acceptance_criteria: list[str] | None = None,
    authority: dict[str, Any] | None = None,
    budget: dict[str, Any] | None = None,
    input_revisions: list[str] | None = None,
    max_handoffs: int = 10,
    execution_timeout: float | None = None,
    node_timeout: float | None = None,
) -> str:
    """Dispatch a specialist team to investigate one area and persist its findings.

    Each team runs as a native Strands Swarm — a group of agents that hand off to
    one another to complete a mandate. The team's task is automatically augmented
    with a Required-Output block instructing the last agent to write its findings
    and metadata files (paths are supplied in the augmented task). A confined
    ``write_file`` tool is auto-added to every agent.

    TASK CONTRACT — a team is dispatched under a typed authority + budget contract:
        - ``authority`` grants capabilities. A team whose grant has
          ``may_create_blocking_finding`` false CANNOT persist a blocking finding:
          this tool strips any such finding from the team's typed findings and
          returns an error. Enforcement is in this tool's code, not the agents'
          prompts. Omit ``authority`` to permit blocking findings (legacy behavior).
        - ``budget`` bounds resources. A positive ``max_wall_clock_minutes`` sets
          the swarm's execution timeout to ``max_wall_clock_minutes * 60``.
        - ``acceptance_criteria`` are the conditions the team must satisfy; they
          are surfaced in the team task AND checked for coverage by the quality hook.
        - ``input_revisions`` are opaque revision-id strings the team builds on.

    AGENT SPEC FORMAT — each entry in ``agents`` is a dict with:
        - ``name`` (str): unique node name (e.g. ``"analyst"``, ``"reader"``).
        - ``system_prompt`` (str): role and instructions for this agent.
        - ``tools`` (list[str]): tool names for this agent, resolved from the
          coordinator's tool registry. Unknown names are dropped.

    Args:
        team_id: Unique team identifier (e.g. ``"team-structure"``).
        mandate: What to investigate and why (1-3 sentences).
        agents: List of agent spec dicts (see AGENT SPEC FORMAT).
        tool_context: Framework-injected run context (invocation state).
        file_scope: Optional file paths or directories to focus on.
        key_questions: Optional questions the team should answer.
        artifact_pointers: Optional pre-computed artifact paths to consult.
        acceptance_criteria: Optional conditions the team's output must satisfy.
        authority: Optional capability grant as a JSON dict with any of
            ``may_create_blocking_finding``, ``may_write``, ``may_approve``
            (each defaults false). Omit to permit blocking findings (legacy).
        budget: Optional resource envelope as a JSON dict with any of
            ``max_wall_clock_minutes``, ``max_cost_usd``, ``max_retries``.
        input_revisions: Optional opaque revision-id strings the team builds on.
        max_handoffs: Max handoffs (and iterations) within the team.
        execution_timeout: Max seconds for the entire team. Omit for the run default.
        node_timeout: Max seconds per agent turn. Omit for the run default.

    Returns:
        JSON with ``team_id``, ``status``, ``duration_seconds``, ``findings_path``,
        ``metadata_path``, and a result ``summary``. On failure — including a
        rejected forbidden blocking finding — ``status`` is ``"error"`` and
        ``message`` carries the reason.
    """
    state = _require_state(tool_context)
    layout = state["layout"]

    # Parse the task-contract dicts into typed models (AC-2-2). A malformed
    # LLM-supplied dict yields the tool's standard error envelope rather than an
    # escaping ValidationError (AC-2-2/AC-2-3).
    parsed = _parse_contract(layout, team_id=team_id, agent_count=len(agents), authority=authority, budget=budget)
    if isinstance(parsed, str):
        return parsed
    grant, team_budget = parsed

    # A positive wall-clock budget overrides the run-default execution timeout
    # (AC-2-5); an explicit ``execution_timeout`` argument still wins over both.
    effective_exec = _effective_execution_timeout(
        execution_timeout=execution_timeout,
        budget=team_budget,
        default=state["execution_timeout"],
    )
    effective_node = node_timeout if node_timeout is not None else state["node_timeout"]

    agent_names = [a.get("name", "?") for a in agents]
    logger.debug(
        f"dispatch_team({team_id}): execution_timeout={effective_exec}s, "
        f"node_timeout={effective_node}s, agents={agent_names}",
    )

    team_dir = layout.team_dir(team_id)
    team_dir.mkdir(parents=True, exist_ok=True)
    findings_path = str(layout.findings_path(team_id))
    findings_json_path = str(layout.findings_json_path(team_id))
    metadata_path = str(layout.metadata_path(team_id))

    # Persist the task contract next to the team's findings so it survives the run
    # and the quality hook can read acceptance criteria for coverage (AC-2-6).
    _write_team_contract(
        layout,
        team_id,
        authority=grant,
        budget=team_budget,
        acceptance_criteria=acceptance_criteria,
        input_revisions=input_revisions,
    )

    augmented_task = _build_augmented_task(
        mandate=mandate,
        findings_path=findings_path,
        findings_json_path=findings_json_path,
        metadata_path=metadata_path,
        file_scope=file_scope,
        key_questions=key_questions,
        artifact_pointers=artifact_pointers,
        acceptance_criteria=acceptance_criteria,
        input_revisions=input_revisions,
        output_root=str(layout.output_root),
    )

    write_file_tool = make_write_file(layout)
    write_findings_tool = make_write_findings(layout, team_id)
    start = time.monotonic()

    # Build a NEW invocation_state carrying the per-team contract by reference,
    # rather than mutating the coordinator's shared state dict (AC-5-1, AC-5-4).
    # The live AuthorityGrant/Budget objects cross no serialization boundary and
    # carry no session_manager; node tools must treat them read-only (AC-5-3).
    team_state: dict[str, Any] = {
        **state,
        "authority": grant,
        "budget": team_budget,
        "acceptance_criteria": list(acceptance_criteria or []),
        "input_revisions": list(input_revisions or []),
    }

    status, summary, message, error_type = await _run_and_enforce(
        agents,
        state=state,
        layout=layout,
        team_id=team_id,
        grant=grant,
        write_file_tool=write_file_tool,
        write_findings_tool=write_findings_tool,
        augmented_task=augmented_task,
        team_state=team_state,
        max_handoffs=max_handoffs,
        execution_timeout=effective_exec,
        node_timeout=effective_node,
    )
    duration = time.monotonic() - start

    # AC-2-2/AC-2-3: on EVERY path (success, failure, timeout) write today's debug
    # entry AND emit a typed dispatch TeamEvent to the event plane.
    _record_dispatch_completion(
        layout,
        state,
        team_id=team_id,
        status=status,
        duration_seconds=round(duration, 1),
        effective_exec=effective_exec,
        effective_node=effective_node,
        agent_names=agent_names,
        agent_count=len(agents),
        mandate=mandate,
        error_type=error_type,
    )

    response: dict[str, Any] = {
        "team_id": team_id,
        "status": status,
        "duration_seconds": round(duration, 1),
        "findings_path": findings_path,
        "findings_json_path": findings_json_path,
        "metadata_path": metadata_path,
        "summary": summary,
    }
    if message is not None:
        response["message"] = message
    if error_type:
        response["error_type"] = error_type
    return json.dumps(response)

enrich_artifact

enrich_artifact(area, feedback, tool_context)

Read an existing artifact and prepare enrichment context.

Call when score_narrative reports needs_revision. Returns the current artifact content plus the feedback so you can rewrite it with improvements via write_artifact.

Parameters:

Name Type Description Default
area str

The artifact area to enrich (e.g. "auth").

required
feedback str

Specific feedback from score_narrative about what to improve.

required
tool_context ToolContext

Framework-injected run context (invocation state).

required

Returns:

Type Description
str

JSON with the current artifact current_content, its line_count, the

str

feedback, and a rewrite instruction.

Source code in packages/strands-teams/src/strands_teams/teams.py
@tool(context=True)
def enrich_artifact(area: str, feedback: str, tool_context: ToolContext) -> str:
    """Read an existing artifact and prepare enrichment context.

    Call when ``score_narrative`` reports ``needs_revision``. Returns the current
    artifact content plus the feedback so you can rewrite it with improvements via
    ``write_artifact``.

    Args:
        area: The artifact area to enrich (e.g. ``"auth"``).
        feedback: Specific feedback from ``score_narrative`` about what to improve.
        tool_context: Framework-injected run context (invocation state).

    Returns:
        JSON with the current artifact ``current_content``, its ``line_count``, the
        ``feedback``, and a rewrite ``instruction``.
    """
    layout = _layout_of(tool_context)
    artifact_path = layout.artifact_path(area)
    if not artifact_path.exists():
        return json.dumps({"status": "error", "message": f"Artifact not found: {artifact_path}"})

    content = artifact_path.read_text()
    return json.dumps(
        {
            "status": "ok",
            "area": area,
            "current_content": content,
            "line_count": len(content.splitlines()),
            "feedback": feedback,
            "instruction": (
                "Rewrite this artifact incorporating the feedback above. "
                "Use write_artifact to save the improved version. "
                "Preserve all existing accurate information while adding depth."
            ),
        },
    )

read_team_findings

read_team_findings(tool_context, team_id=None)

Read findings from dispatched teams.

If team_id is None, lists all team directories with their status, metadata summary, and whether typed findings are present — use this to see which teams have completed. If team_id is given, returns that team's typed findings.json (parsed) when it exists and validates, AND the human findings.md narrative alongside it; when the typed sidecar is absent or invalid it falls back to the raw findings.md markdown.

Parameters:

Name Type Description Default
tool_context ToolContext

Framework-injected run context (invocation state).

required
team_id str | None

Specific team to read, or None to list all teams.

None

Returns:

Name Type Description
str

JSON listing all teams (when team_id is None), or the findings

str

content (when a team_id is given). For a typed read, findings is a

str

list of finding objects, source is "typed", evidence_gate flags

str

any blocking finding lacking evidence, findings_path points at the

str

human findings.md, and markdown carries its full narrative (mermaid,

file str

line prose, structured sections) when the file exists — this markdown

str

is the rich input for bundle synthesis. For a fallback read, findings

str

is the markdown string and source is "markdown".

Source code in packages/strands-teams/src/strands_teams/teams.py
@tool(context=True)
def read_team_findings(tool_context: ToolContext, team_id: str | None = None) -> str:
    """Read findings from dispatched teams.

    If ``team_id`` is ``None``, lists all team directories with their status,
    metadata summary, and whether typed findings are present — use this to see
    which teams have completed. If ``team_id`` is given, returns that team's typed
    ``findings.json`` (parsed) when it exists and validates, AND the human
    ``findings.md`` narrative alongside it; when the typed sidecar is absent or
    invalid it falls back to the raw ``findings.md`` markdown.

    Args:
        tool_context: Framework-injected run context (invocation state).
        team_id: Specific team to read, or ``None`` to list all teams.

    Returns:
        JSON listing all teams (when ``team_id`` is ``None``), or the findings
        content (when a ``team_id`` is given). For a typed read, ``findings`` is a
        list of finding objects, ``source`` is ``"typed"``, ``evidence_gate`` flags
        any blocking finding lacking evidence, ``findings_path`` points at the
        human ``findings.md``, and ``markdown`` carries its full narrative (mermaid,
        file:line prose, structured sections) when the file exists — this markdown
        is the rich input for bundle synthesis. For a fallback read, ``findings``
        is the markdown string and ``source`` is ``"markdown"``.
    """
    layout = _layout_of(tool_context)
    teams_root = layout.teams_dir()

    if team_id is None:
        if not teams_root.exists():
            return json.dumps({"status": "no_teams", "teams": []})
        teams = []
        for team_dir in sorted(teams_root.iterdir()):
            if not team_dir.is_dir():
                continue
            meta_path = team_dir / layout.metadata_name
            findings_file = team_dir / layout.findings_name
            meta = None
            if meta_path.exists():
                try:
                    meta = json.loads(meta_path.read_text())
                except (json.JSONDecodeError, OSError):
                    pass  # Corrupt/unreadable metadata — skip it.
            typed = _try_load_typed_findings(layout, team_dir.name)
            teams.append(
                {
                    "team_id": team_dir.name,
                    "has_findings": findings_file.exists(),
                    "findings_lines": len(findings_file.read_text().splitlines()) if findings_file.exists() else 0,
                    "has_typed_findings": typed is not None,
                    "typed_finding_count": len(typed) if typed is not None else 0,
                    "metadata": meta,
                },
            )
        return json.dumps({"status": "ok", "teams": teams}, indent=2)

    team_dir = layout.team_dir(team_id)
    if not team_dir.exists():
        return json.dumps({"status": "error", "message": f"Team directory not found: {team_id}"})

    # Prefer the typed sidecar, but ALWAYS also surface the human findings.md so
    # bundle synthesis keeps its rich markdown input (mermaid, file:line prose,
    # structured sections) — the typed findings augment, never replace, it
    # (AC-6-2). Even when findings.md is absent, ``findings_path`` lets the
    # coordinator discover the markdown and read it via ``read_file_bounded``.
    findings_file = layout.findings_path(team_id)
    typed = _try_load_typed_findings(layout, team_id)
    if typed is not None:
        return json.dumps(_typed_read_payload(team_id, typed, findings_file))

    if not findings_file.exists():
        return json.dumps(
            {
                "status": "no_findings",
                "message": f"Team {team_id} has no findings — it may still be running or failed before writing.",
            },
        )

    content = findings_file.read_text()
    return json.dumps(
        {
            "status": "ok",
            "team_id": team_id,
            "source": "markdown",
            "findings": content,
            "line_count": len(content.splitlines()),
        },
    )

score_narrative

score_narrative(area, tool_context)

Score a per-area artifact's quality using the run's rubric.

Call after write_artifact to evaluate quality. If needs_revision is True (total below the rubric's revision threshold), call enrich_artifact and rewrite via write_artifact.

Parameters:

Name Type Description Default
area str

The artifact area to score (e.g. "auth").

required
tool_context ToolContext

Framework-injected run context (invocation state).

required

Returns:

Type Description
str

JSON with area, per-dimension scores and counts, the total

str

average, the needs_revision flag, and improvement suggestions.

Source code in packages/strands-teams/src/strands_teams/teams.py
@tool(context=True)
def score_narrative(area: str, tool_context: ToolContext) -> str:
    """Score a per-area artifact's quality using the run's rubric.

    Call after ``write_artifact`` to evaluate quality. If ``needs_revision`` is
    True (total below the rubric's revision threshold), call ``enrich_artifact``
    and rewrite via ``write_artifact``.

    Args:
        area: The artifact area to score (e.g. ``"auth"``).
        tool_context: Framework-injected run context (invocation state).

    Returns:
        JSON with ``area``, per-dimension ``scores`` and ``counts``, the ``total``
        average, the ``needs_revision`` flag, and improvement ``suggestions``.
    """
    state = _require_state(tool_context)
    layout = state["layout"]
    rubric = state["rubric"]

    artifact_path = layout.artifact_path(area)
    if not artifact_path.exists():
        return json.dumps({"status": "error", "message": f"Artifact not found: {artifact_path}"})

    report = rubric.score(artifact_path.read_text())
    return json.dumps(
        {
            "status": "ok",
            "area": area,
            "scores": report.scores,
            "counts": report.counts,
            "total": report.total,
            "needs_revision": report.needs_revision,
            "suggestions": report.suggestions,
        },
    )

write_artifact

write_artifact(
    area, content, tool_context, is_executive_summary=False
)

Write a per-area narrative artifact or the top-level executive summary.

All artifact paths come from the run's layout. Diagrams must use mermaid code-fenced blocks (never ASCII art). The artifact section structure the coordinator should follow is described in its system prompt, not here.

Parameters:

Name Type Description Default
area str

Artifact area identifier (e.g. "auth"). Ignored when is_executive_summary is True.

required
content str

The full markdown content to write.

required
tool_context ToolContext

Framework-injected run context (invocation state).

required
is_executive_summary bool

If True, write the executive summary instead of a per-area artifact.

False

Returns:

Type Description
str

JSON with status, absolute path, relative_path, and line_count.

Source code in packages/strands-teams/src/strands_teams/teams.py
@tool(context=True)
def write_artifact(area: str, content: str, tool_context: ToolContext, is_executive_summary: bool = False) -> str:
    """Write a per-area narrative artifact or the top-level executive summary.

    All artifact paths come from the run's layout. Diagrams must use mermaid
    code-fenced blocks (never ASCII art). The artifact section structure the
    coordinator should follow is described in its system prompt, not here.

    Args:
        area: Artifact area identifier (e.g. ``"auth"``). Ignored when
            ``is_executive_summary`` is True.
        content: The full markdown content to write.
        tool_context: Framework-injected run context (invocation state).
        is_executive_summary: If True, write the executive summary instead of a
            per-area artifact.

    Returns:
        JSON with ``status``, absolute ``path``, ``relative_path``, and ``line_count``.
    """
    layout = _layout_of(tool_context)

    if is_executive_summary:
        file_path = layout.executive_summary_path()
    else:
        layout.artifacts_dir().mkdir(parents=True, exist_ok=True)
        file_path = layout.artifact_path(area)

    file_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        file_path.write_text(content, encoding="utf-8")
    except OSError as e:
        return json.dumps({"status": "error", "message": str(e)})

    line_count = len(content.splitlines())
    try:
        relative_path = str(file_path.relative_to(layout.output_root))
    except ValueError:
        relative_path = file_path.name
    logger.info(f"write_artifact: wrote {file_path.name} ({line_count} lines)")
    return json.dumps(
        {
            "status": "ok",
            "path": str(file_path),
            "relative_path": relative_path,
            "line_count": line_count,
        },
    )

validate_use_case

validate_use_case(use_case)

Validate a concrete UseCase supplies every required member.

Called by the coordinator factory (Wave C) before constructing the coordinator. Raises with the missing member's name rather than proceeding with a silent default (AC-5-5).

Parameters:

Name Type Description Default
use_case UseCase

The use-case instance to validate.

required

Raises:

Type Description
MissingUseCaseMemberError

If a required member is absent or None.

Source code in packages/strands-teams/src/strands_teams/usecase.py
def validate_use_case(use_case: UseCase) -> None:
    """Validate a concrete ``UseCase`` supplies every required member.

    Called by the coordinator factory (Wave C) before constructing the
    coordinator. Raises with the missing member's name rather than proceeding
    with a silent default (AC-5-5).

    Args:
        use_case: The use-case instance to validate.

    Raises:
        MissingUseCaseMemberError: If a required member is absent or ``None``.
    """
    name = getattr(use_case, "name", None) or type(use_case).__name__
    for member in _REQUIRED_MEMBERS:
        if getattr(use_case, member, None) is None:
            raise MissingUseCaseMemberError(name, member)

make_write_file

make_write_file(layout)

Build a write_file tool confined to layout.output_root.

The returned tool is a normal strands @tool whose tool_name is write_file. It closes over layout so it needs no module-level state.

Parameters:

Name Type Description Default
layout TeamsLayout

The run's layout, whose output_root all writes must stay under.

required

Returns:

Type Description
DecoratedFunctionTool

A decorated write_file tool object.

Source code in packages/strands-teams/src/strands_teams/write_file.py
def make_write_file(layout: TeamsLayout) -> DecoratedFunctionTool:
    """Build a ``write_file`` tool confined to ``layout.output_root``.

    The returned tool is a normal strands ``@tool`` whose ``tool_name`` is
    ``write_file``. It closes over ``layout`` so it needs no module-level state.

    Args:
        layout: The run's layout, whose ``output_root`` all writes must stay under.

    Returns:
        A decorated ``write_file`` tool object.
    """
    output_root = layout.output_root

    @tool(name="write_file")
    def write_file(file_path: str, content: str) -> str:
        """Write content to a file inside the run's output directory.

        USE THIS TOOL:
        - To write your team's findings.md and metadata.json.
        - To save any generated documentation or artifact.

        Security: writes are confined to the run's output directory. Paths that
        escape it (via ``..`` or an absolute path outside the root) are rejected.

        Args:
            file_path: Path to write. Absolute (inside the output root) or
                relative to the output root.
            content: String content to write.

        Returns:
            JSON with status, absolute path, and bytes written.
        """
        try:
            path = confine_write_path(file_path, output_root)
        except ValidationError as e:
            return json.dumps({"status": "error", "message": str(e)})

        path.parent.mkdir(parents=True, exist_ok=True)
        try:
            path.write_text(content, encoding="utf-8")
        except OSError as e:
            return json.dumps({"status": "error", "message": str(e)})

        logger.info(f"write_file: wrote {len(content)} bytes to {path}")
        return json.dumps(
            {
                "status": "success",
                "path": str(path),
                "bytes_written": len(content),
            },
        )

    return write_file

make_write_findings

make_write_findings(layout, team_id)

Build a write_findings tool that persists typed findings for one team.

The returned tool validates its input against the :class:~strands_teams.models.Finding schema, serializes the collection to the team's findings.json (via :meth:~strands_teams.layout.TeamsLayout.findings_json_path), and flags any blocking=True finding that lacks evidence through the shared evidence gate (AC-3-2, AC-3-4, AC-5-2). It closes over layout and team_id so it needs no module-level state and writes only inside the run's team directory.

Parameters:

Name Type Description Default
layout TeamsLayout

The run's layout; the sidecar path derives from it.

required
team_id str

The team whose findings.json this tool writes.

required

Returns:

Type Description
DecoratedFunctionTool

A decorated write_findings tool object.

Source code in packages/strands-teams/src/strands_teams/write_file.py
def make_write_findings(layout: TeamsLayout, team_id: str) -> DecoratedFunctionTool:
    """Build a ``write_findings`` tool that persists typed findings for one team.

    The returned tool validates its input against the :class:`~strands_teams.models.Finding`
    schema, serializes the collection to the team's ``findings.json`` (via
    :meth:`~strands_teams.layout.TeamsLayout.findings_json_path`), and flags any
    ``blocking=True`` finding that lacks evidence through the shared evidence gate
    (AC-3-2, AC-3-4, AC-5-2). It closes over ``layout`` and ``team_id`` so it needs
    no module-level state and writes only inside the run's team directory.

    Args:
        layout: The run's layout; the sidecar path derives from it.
        team_id: The team whose ``findings.json`` this tool writes.

    Returns:
        A decorated ``write_findings`` tool object.
    """

    @tool(name="write_findings")
    def write_findings(findings: list[dict[str, Any]]) -> str:
        """Write this team's typed findings to its ``findings.json`` sidecar.

        Call this once, as the LAST agent, IN ADDITION to writing the human
        ``findings.md``. Each finding is a JSON object with these keys:

        - ``statement`` (str, required): the finding in one or two sentences.
        - ``severity`` (str, required): one of ``info``/``low``/``medium``/``high``/``critical``.
        - ``blocking`` (bool): must be resolved before acceptance (default false).
          A blocking finding MUST carry at least one ``evidence`` ref.
        - ``disposition`` (str): ``open``/``acknowledged``/``remediated``/
          ``accepted_risk``/``rejected``/``superseded`` (default ``open``).
        - ``affected_artifacts`` (list[str]): paths/ids the finding is about.
        - ``evidence`` (list): each ``{"type": one of file_line|test|command|artifact|url|log,
          "uri": "src/auth.py:42", "detail": "optional note"}``.
        - ``confidence`` (float in [0,1], default 0.5).
        - ``created_by`` (str): your team/agent name.

        Args:
            findings: The list of finding objects to persist.

        Returns:
            JSON with ``status``, absolute ``path``, ``count``, and an
            ``evidence_gate`` summary flagging any blocking finding without
            evidence. On a schema violation, ``status`` is ``"error"`` and the
            file is not written.
        """
        try:
            parsed = coerce_findings(findings)
        except PydanticValidationError as e:
            return json.dumps({"status": "error", "message": f"findings failed schema validation: {e}"})

        path = layout.findings_json_path(team_id)
        path.parent.mkdir(parents=True, exist_ok=True)
        try:
            path.write_text(serialize_findings(parsed), encoding="utf-8")
        except OSError as e:
            return json.dumps({"status": "error", "message": str(e)})

        gate = gate_findings(parsed)
        logger.info(f"write_findings: wrote {len(parsed)} findings for {team_id} to {path}")
        return json.dumps(
            {
                "status": "success",
                "path": str(path),
                "count": len(parsed),
                "evidence_gate": gate,
            },
        )

    return write_findings