Repository navigation
feat: implement issue #1404 — [Phase 2] Author a machine-readable interaction contract for every agentic role, per the standard #1418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
f5bb8dc
feat: implement issue #1404 — [Phase 2] Author a machine-readable int…
donpetry-bot b37dd82
fix(reviews): address review comments [skip ci-relay]
donpetry-bot 4f57cae
fix(reviews): address review comments [skip ci-relay]
donpetry-bot 0d62c2e
chore: dev-lead update (review-changes) [skip ci-relay]
donpetry-bot 9c4ac5d
fix(reviews): address review comments [skip ci-relay]
donpetry-bot f9135ed
Merge branch 'main' into dev-lead/issue-1404-20260802-0151
donpetry-bot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
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
207
interaction-contracts/validate-interaction-contracts.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.