Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- 'tests/**'
- 'agents/**'
- 'personas/**'
- 'interaction-contracts/**'
- 'evals/**'
- 'release/**'
- '.gitleaks.toml'
Expand Down Expand Up @@ -118,6 +119,7 @@
tests/test_readme_refresh_helpers.bats \
tests/test_readme_refresh_push.bats \
tests/test_validate_personas.bats \
tests/test_validate_interaction_contracts.bats \
tests/verify_persona_teams.bats \
tests/persona_runner.bats \
tests/test_dev_lead_concurrency_guard.bats \
Expand Down Expand Up @@ -191,7 +193,7 @@
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# NOTE: persist-credentials is intentionally left at the default (true) —

Check warning on line 196 in .github/workflows/lint.yml

View workflow job for this annotation

GitHub Actions / Lint

196:9 [comments-indentation] comment not indented like content
# vci_resolve_reusable needs `git fetch origin` to authenticate against this
# private repo when resolving same-repo channel tags. Setting
# persist-credentials: false would silently push every such caller into the
Expand Down Expand Up @@ -311,6 +313,29 @@
PERSONA_SCHEMA_REF: ${{ github.head_ref || github.ref_name }}
run: python3 personas/validate-personas.py personas

validate-interaction-contracts:
# Interaction-contract well-formedness gate (Story 2 / #1404, AC #5). Parses
# every machine-readable interaction contract — the co-located persona lenses
# (personas/<id>/interaction.yml) and the standalone runtime lenses
# (interaction-contracts/*.yml) — and asserts the §8.1 shape plus the
# invariants the shape alone can't express: kind/budget enums, timer role ∈
# {backstop,safety-net,self-heal} with justification + stop_condition +
# event_fast_path present, every declared workflow file exists, and the narrow
# §7 rule-1 self-trigger check (an emit must not name an event the same
# contract subscribes to). Fully HERMETIC — no schema fetch, no network — so
# unlike validate-personas it needs no cross-repo ref; keep it that way. Deep
# semantic self-trigger analysis is deferred to Story 4 / #1406.
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install PyYAML
run: pip3 install --quiet "pyyaml==6.0.3" --only-binary=":all:"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
- name: Validate interaction contracts
run: python3 interaction-contracts/validate-interaction-contracts.py .

verify-persona-teams:
# LIVE persona addressing-team check (#1280). The hermetic validate-personas.py
# (and its hermetic bats suite) verifies the handle SHAPE — the team slug ==
Expand Down
31 changes: 31 additions & 0 deletions interaction-contracts/ci-failure-analyst.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Interaction contract — ci-failure-analyst (runtime / deployment lens).
#
# Shape fixed by docs/agentic-interaction-model.md §8.1; standalone repo-local
# file per the §8.2 location decision (option b). Story 2 / #1404.
#
# The ci-failure-analyst has no persona manifest — its trigger lives entirely in
# .github/workflows/ci-failure-analyst.lock.yml, a byte-frozen ring-0 caller stub
# (caller-stub-freeze, AGENTS.md). This contract DESCRIBES that stub; it does not
# edit it. A single Class-1 reaction to check_run:[completed]; no schedule.
#
# It posts one analysis comment per failing head SHA, deduped by the leading
# `<!-- ci-analyst sha=<HEAD_SHA> -->` marker and serialized by the per-head-SHA
# concurrency lane — so it structurally cannot loop and is not bound by the
# pr-automation-budget (budget: none; its ceiling is the per-SHA idempotency key).

schema_version: 1
role: ci-failure-analyst
kind: runtime
workflows:
- .github/workflows/ci-failure-analyst.lock.yml
interaction:
triggers:
events:
- check_run
timers: []
emits:
- "comment:<!-- ci-analyst sha=<HEAD_SHA> -->"
idempotency_key: "check_run head_sha (one analysis comment per failing SHA, deduped by the ci-analyst marker)"
concurrency_lane: "ci-failure-analyst-<head_sha> (ci-failure-analyst.lock.yml, cancel-in-progress: false)"
stop_markers: []
budget: none
53 changes: 53 additions & 0 deletions interaction-contracts/dev-lead.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Interaction contract — dev-lead (runtime / deployment lens).
#
# Shape fixed by docs/agentic-interaction-model.md §8.1; standalone repo-local
# file per the §8.2 location decision (option b). Story 2 / #1404.
#
# DEPLOYMENT lens for the dev-lead runtime whose triggers live in the workflows
# (no persona manifest of its own). It folds the Class-1 event surface of
# dev-lead.yml together with the Class-2 self-heal backstop dev-lead-retry.yml.
# The role-level view is personas/dev-lead/interaction.yml.
#
# The dev-lead-retry.yml timer re-arms dev-lead by firing PAT-backed
# repository_dispatch (dev-lead-ci-failure / -reviews-retry / -issue-retry) — the
# events dev-lead.yml subscribes to above. That controlled re-dispatch is the
# TIMER's stop-condition-gated mechanism (captured in triggers.timers below), NOT
# a free emit; modeling it as the timer is what keeps the #860 rule-1 self-trigger
# check meaningful. Per §6.3 this timer is the flagged LEAK: event_fast_path is
# null, so the cron is the only path that reacts to a cleared rate limit.

schema_version: 1
role: dev-lead
kind: runtime
workflows:
- .github/workflows/dev-lead.yml
- .github/workflows/dev-lead-retry.yml
interaction:
triggers:
events:
- pull_request
- pull_request_review
- pull_request_review_comment
- issue_comment
- issues
- check_run
- repository_dispatch:dev-lead-ci-failure
Comment thread
don-petry marked this conversation as resolved.
Resolved
- repository_dispatch:dev-lead-reviews-retry
- repository_dispatch:dev-lead-issue-retry
timers:
- cron: "15 */2 * * *"
role: self-heal
justification: "dev-lead-retry.yml re-dispatches status=rate-limited PRs once the limit clears"
stop_condition: "PR still open and not needs-human-review / dev-lead:needs-human, and the pr-automation-budget is not exhausted"
event_fast_path: null
emits:
- "commit"
- "comment:<!-- pr-automation-budget exhausted -->"
- "label:needs-human-review"
idempotency_key: "one in-flight run per work item: check_run head_sha, else pull_request number, else issue number"
concurrency_lane: "dev-lead-pr-<pr> / dev-lead-issue-<issue> / dev-lead-ci-relay-<head_sha> (dev-lead-reusable.yml); dev-lead-retry (dev-lead-retry.yml)"
stop_markers:
- needs-human-review
- dev-lead:needs-human
- dev-lead:hands-off
budget: pr-automation-budget
46 changes: 46 additions & 0 deletions interaction-contracts/pr-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Interaction contract — pr-review (runtime / deployment lens).
#
# Shape fixed by docs/agentic-interaction-model.md §8.1; standalone repo-local
# file per the §8.2 location decision (option b). Story 2 / #1404.
#
# DEPLOYMENT lens for the pr-review runtime: the ring-0 caller stub
# pr-review-trigger.yml (Class-1 event fast path) folded together with the
# pr-review-sweep.yml backstop (Class-2). The role-level view is
# personas/pr-review/interaction.yml.
#
# pr-review-sweep re-dispatches a stuck PR's review through the normal trigger via
# a PAT (§5 Bridge A) — its stop-condition-gated re-dispatch is the TIMER below,
# not a free emit. Unlike dev-lead-retry, this timer DOES pair with an event fast
# path (workflow_run:[completed] scoped to the PR whose CI just went green), so it
# is a textbook §2 Class-2 backstop, not a §6.3 leak.
Comment thread
don-petry marked this conversation as resolved.
Resolved

schema_version: 1
role: pr-review
kind: runtime
workflows:
- .github/workflows/pr-review-trigger.yml
- .github/workflows/pr-review-sweep.yml
interaction:
triggers:
events:
- check_suite
- pull_request_review
- pull_request
- workflow_run
- repository_dispatch:pr-review-mention
timers:
- cron: "2,17,32,47 * * * *"
role: backstop
justification: "pr-review-sweep.yml re-dispatches reviews for PRs that went green after a ci-pending/ci-failing skip; the guaranteed <=15-min latency bound"
stop_condition: "PR still open and its review still stale/needed, and not human-gated"
event_fast_path: "workflow_run:[completed] scoped to the completing CI run's PR(s)"
emits:
- "review:approved"
- "review:changes-requested"
- "comment:<!-- pr-review -->"
idempotency_key: "pr_url + head_sha (the review marker makes a same-head re-review a fast no-op)"
concurrency_lane: "pr-review-pr-<pr_url> / pr-review-batch (pr-review.yml); pr-review-sweep-<branch|event_name> (pr-review-sweep.yml)"
stop_markers:
- needs-human-review
- pr-review:hands-off
budget: pr-automation-budget
207 changes: 207 additions & 0 deletions interaction-contracts/validate-interaction-contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Validate the per-role agentic interaction contracts.

Each agentic role declares a machine-readable interaction contract in the shape
fixed by docs/agentic-interaction-model.md §8.1 (Story 2 / #1404). Contracts live
in two standalone locations (§8.2 chose option (b) — standalone repo-local files,
NOT a persona.yml block, so there is no cross-repo persona.schema.json dependency):

* personas/<id>/interaction.yml — the 8 personas, co-located with persona.yml
* interaction-contracts/<name>.yml — the non-persona runtimes (dev-lead,
pr-review, ci-failure-analyst)

This validator is HERMETIC (no network, no live schema fetch). It confirms every
contract is WELL-FORMED and internally consistent:

* required fields present and correctly typed (schema_version, role, kind,
workflows, interaction.{triggers,emits,idempotency_key,concurrency_lane,
stop_markers,budget})
* each declared workflow path exists on disk (the contract is bound to a real
workflow, so it cannot be aspirational — the DEEP match of triggers against
each workflow's actual on: block is Story 4's validate-interaction-model,
#1406, not this check)
* every timer declares cron + role (backstop|safety-net|self-heal) +
justification + stop_condition + event_fast_path (§6.1/§6.2)
* emits never names an event class the role also subscribes to — the
well-formedness half of the #860 rule-1 self-trigger prohibition (§7): a
`dispatch:<type>` emit must not appear as a subscribed
`repository_dispatch:<type>` event, and no emit token may equal a bare
subscribed event name

Usage:
validate-interaction-contracts.py [repo_root] (default: current directory)

Exit 0 on success; non-zero with a single diagnostic on the first failure found.
"""
import sys
from pathlib import Path
from typing import NoReturn
Comment thread
don-petry marked this conversation as resolved.
Resolved

try:
import yaml
except ImportError:
print("::error::interaction contract invalid: PyYAML not installed (pip install pyyaml)", file=sys.stderr)
sys.exit(1)

TIMER_ROLES = frozenset({"backstop", "safety-net", "self-heal"})
KINDS = frozenset({"persona", "runtime"})
BUDGETS = frozenset({"pr-automation-budget", "none"})
EMIT_BARE = frozenset({"commit", "push"})
EMIT_PREFIXES = ("label:", "comment-marker:", "comment:", "review:", "dispatch:")


def fail(msg: str) -> NoReturn:
print(f"::error::interaction contract invalid: {msg}", file=sys.stderr)
sys.exit(1)


def discover_contracts(root: Path) -> list[Path]:
found: list[Path] = []
found += sorted((root / "personas").glob("*/interaction.yml"))
found += sorted((root / "interaction-contracts").glob("*.yml"))
return found


def _require_nonempty_str(value: object, where: str) -> str:
if not isinstance(value, str) or not value.strip():
fail(f"{where} must be a non-empty string, got {value!r}")
return value


def _require_list(value: object, where: str) -> list:
if not isinstance(value, list):
fail(f"{where} must be a list, got {value!r}")
return value


def check_timer(timer: object, where: str) -> None:
if not isinstance(timer, dict):
fail(f"{where} must be a mapping, got {timer!r}")
_require_nonempty_str(timer.get("cron"), f"{where}.cron")
role = timer.get("role")
if role not in TIMER_ROLES:
fail(f"{where}.role must be one of {sorted(TIMER_ROLES)} "
f"(backstop | safety-net | self-heal), got {role!r}")
_require_nonempty_str(timer.get("justification"), f"{where}.justification")
_require_nonempty_str(timer.get("stop_condition"), f"{where}.stop_condition")
# event_fast_path is required as a KEY (a null value is meaningful — it marks
# the §6.3 leak, a timer that is the only path to event-driven work).
if "event_fast_path" not in timer:
fail(f"{where} is missing event_fast_path (use null to declare the "
f"§6.3 no-fast-path leak explicitly)")
efp = timer["event_fast_path"]
if efp is not None and not (isinstance(efp, str) and efp.strip()):
fail(f"{where}.event_fast_path must be a non-empty string or null, got {efp!r}")


def check_self_trigger(events: list, emits: list, where: str) -> None:
"""The well-formedness half of #860 rule 1 (§7): a role must not name, in its
emits, an event class it also subscribes to. The DEEP semantic analysis
(e.g. a `comment` emit vs an `issue_comment` subscription, which real roles
do safely via actor/marker filtering) is Story 4's job — here we catch only
the unambiguous cases: a self-dispatch, and a literal event/emit collision."""
event_set = set(events)
for emit in emits:
if emit.startswith("dispatch:"):
dispatched = emit.split(":", 1)[1]
if f"repository_dispatch:{dispatched}" in event_set:
fail(f"{where}: emit {emit!r} self-triggers — the role also "
f"subscribes to repository_dispatch:{dispatched} (#860 rule 1)")
if emit in event_set:
fail(f"{where}: emit {emit!r} is also a subscribed event — a role "
f"must never be triggered by its own output (#860 rule 1)")


def check_contract(path: Path, repo_root: Path) -> None:
try:
contract = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
fail(f"could not read/parse {path}: {exc}")
if not isinstance(contract, dict):
fail(f"{path}: top-level document must be a mapping")

if contract.get("schema_version") != 1:
fail(f"{path}: schema_version must be 1, got {contract.get('schema_version')!r}")

role = _require_nonempty_str(contract.get("role"), f"{path}: role")
if contract.get("kind") not in KINDS:
fail(f"{path}: kind must be one of {sorted(KINDS)}, got {contract.get('kind')!r}")

workflows = _require_list(contract.get("workflows"), f"{path}: workflows")
if not workflows:
fail(f"{path}: workflows must name at least one deployed workflow")
repo_root_resolved = repo_root.resolve()
for wf in workflows:
_require_nonempty_str(wf, f"{path}: workflows[]")
resolved_wf = (repo_root / wf).resolve()
if not resolved_wf.is_relative_to(repo_root_resolved):
fail(f"{path}: workflows entry '{wf}' escapes the repository root — "
f"paths must be relative to the repo root")
if not resolved_wf.is_file():
fail(f"{path}: workflows entry '{wf}' does not exist (contract is bound "
f"to a real workflow — see AC #3)")

interaction = contract.get("interaction")
if not isinstance(interaction, dict):
fail(f"{path}: interaction must be a mapping")

triggers = interaction.get("triggers")
if not isinstance(triggers, dict):
fail(f"{path}: interaction.triggers must be a mapping")
events = _require_list(triggers.get("events"), f"{path}: interaction.triggers.events")
for ev in events:
_require_nonempty_str(ev, f"{path}: interaction.triggers.events[]")
timers = _require_list(triggers.get("timers"), f"{path}: interaction.triggers.timers")
for i, timer in enumerate(timers):
check_timer(timer, f"{path}: interaction.triggers.timers[{i}]")

emits = _require_list(interaction.get("emits"), f"{path}: interaction.emits")
if not emits:
fail(f"{path}: interaction.emits must be non-empty (a role that produces "
f"nothing has no self-trigger surface to prove safe — declare what it emits)")
for emit in emits:
_require_nonempty_str(emit, f"{path}: interaction.emits[]")
if emit not in EMIT_BARE:
matched = next((p for p in EMIT_PREFIXES if emit.startswith(p)), None)
if not matched or emit == matched:
fail(f"{path}: interaction.emits entry '{emit}' must be one of "
f"{sorted(EMIT_BARE)} or start with one of "
f"{list(EMIT_PREFIXES)} followed by a non-empty value so its "
f"produced event class is declared")
check_self_trigger(events, emits, f"{path}")

_require_nonempty_str(interaction.get("idempotency_key"),
f"{path}: interaction.idempotency_key")
_require_nonempty_str(interaction.get("concurrency_lane"),
f"{path}: interaction.concurrency_lane")
stop_markers = _require_list(interaction.get("stop_markers"),
f"{path}: interaction.stop_markers")
for m in stop_markers:
_require_nonempty_str(m, f"{path}: interaction.stop_markers[]")

budget = interaction.get("budget")
if budget not in BUDGETS:
fail(f"{path}: interaction.budget must be one of {sorted(BUDGETS)} "
f"(§9: obey the existing ceiling, do not invent a new numeric cap), "
f"got {budget!r}")

# A runtime that writes (declares the pr-automation-budget) must be able to
# stand down when a human takes over.
if budget == "pr-automation-budget" and not stop_markers:
fail(f"{path}: role '{role}' obeys the pr-automation-budget but declares no "
f"stop_markers — a write runtime must skip human-gated work (§6.2.3)")


def main() -> None:
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
contracts = discover_contracts(root)
if not contracts:
print(f"no interaction contracts found under {root} — nothing to validate.")
return
for path in contracts:
check_contract(path, root)
print(f"OK: {len(contracts)} interaction contract(s) valid, invariants hold.")


if __name__ == "__main__":
main()
Loading
Loading