diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 506c3cf..8e7adba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install \ - ruff \ + ruff==0.15.22 \ mdformat==1.0.0 \ mdformat-gfm \ mdformat-frontmatter \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 937eaff..2ba1175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,17 @@ summary: Chronological history of repository and skill changes. # Changelog +## 2026-07-25 — Added coordinator-neutral delegated ticket execution + +- fix: pin CI to the established Ruff rule set so dependency drift cannot + redefine the repository-wide lint gate +- feat: add a versioned delegated-execution contract for `implement-ticket` + (`b53efa674e929c181bdaac63ff0306cb756386db`) + ## 2026-07-21 — Completed carve-changesets and integrated ticket publication - feat: package the workflow suite as a plugin + (`b7ec1b593b9d211cd91101d94d0406c355b2ecd7`) - fix: fail closed on invalid carved handoffs (`dc4f5c1f3e33c25ad6258f7365506bd33255ed82`) - feat: integrate carved ticket publication diff --git a/skills/implement-ticket/SKILL.md b/skills/implement-ticket/SKILL.md index ca855e2..6147ea0 100644 --- a/skills/implement-ticket/SKILL.md +++ b/skills/implement-ticket/SKILL.md @@ -33,6 +33,9 @@ create a third shared workflow abstraction. oversized, and again before transferring candidate ownership. - Always read [cleanup and result](references/cleanup-and-result.md) before a merge or terminal handoff. +- When a caller supplies delegated-execution input, read and follow the + [delegated execution contract](references/delegated-execution/CONTRACT.md) + before any mutation. For cross-system work, record which system owns issue status, dependency state, source code, pull requests, checks, reviews, and merge. Never substitute a @@ -112,6 +115,40 @@ manual transition. State that consequence before publication or merge. Do not use automatic closing syntax when its effect conflicts with the resolved completion policy. +## Honor delegated execution when supplied + +Delegated execution is optional. Standalone behavior remains the default. + +When a caller supplies a delegated invocation: + +1. Validate it with the bundled delegated-execution validator before mutation. + Reject unsupported versions, unknown fields, excluded terminal states, or an + unusable checkpoint command. +2. Treat its authority as an additional ceiling. It may narrow the ordinary + operating contract but never widen repository, user, host, or provider + authority. +3. Run the caller's checkpoint command immediately before every action in the + contract's consequential-mutation vocabulary. Supply the exact current ticket + observation, candidate identity, sequence, and continuation token. Start at + exactly one greater than the invocation's `last_sequence`. Perform only the + one allowed action, rotate to the returned token, and retain every consumed + allowance for the terminal `authority_used` report. +4. Immediately after every candidate push or advancement, verify the full remote + ref and exact SHA, then run `candidate_published`. Do not continue until the + caller acknowledges that exact SHA. +5. If a checkpoint is denied, unavailable, malformed, ambiguous, or mismatched, + stop the proposed action and return `blocked`. Preserve already-published + implementation as a transferable handoff even when publication + acknowledgement failed. A denial preserves the prior sequence and token. +6. Validate the terminal result against the invocation and the caller's durable + ledger tail. Never return stale checkpoint state, a state the caller + excluded, or a claim that a local-only candidate is transferable. A material + ticket observation change blocks this invocation; reevaluation starts a fresh + one. + +The checkpoint command is the coordinator boundary. Do not infer its product, +contact a server on its behalf, or add coordinator-specific behavior. + After applying the whole-epic scope guard, and before creating a branch, worktree, or other implementation state for a ticket, verify that both `review-code-change` and `babysit-pr` are available and readable by stable name diff --git a/skills/implement-ticket/references/cleanup-and-result.md b/skills/implement-ticket/references/cleanup-and-result.md index 91e7cac..ee42d8e 100644 --- a/skills/implement-ticket/references/cleanup-and-result.md +++ b/skills/implement-ticket/references/cleanup-and-result.md @@ -50,7 +50,9 @@ unblocked work. Report newly ready work only as context. ## Result fields Return a concise documented handoff. Do not require a machine-readable schema -unless the caller has one. Include every applicable field: +unless the caller supplies the versioned delegated-execution contract. In that +mode, validate the structured result against both its schema and invocation +before return. Otherwise include every applicable field: - `terminal_state`: `ready_pr`, `ready_prs`, `merged`, `blocked`, or `requires_epic`; @@ -96,8 +98,11 @@ a remaining gate on a terminal `ready_pr`. For `ready_prs`, require a verified `carve-changesets: prs_open` result for the still-current ordered stack. Every PR must be open, correctly based, mergeable, and at its applicable non-merge gate; whole-chain equivalence and final-only -closing syntax must be verified. The only withheld actions are merge and -propagation. Do not list ordinary pending CI or review as a remaining gate. +closing syntax must be verified. Report each exact base ref, base SHA, head ref, +and head SHA so the first PR starts at the candidate base, each later PR starts +at the prior PR head, and the final PR head equals the candidate. The only +withheld actions are merge and propagation. Do not list ordinary pending CI or +review as a remaining gate. For `merged`, require a verified `babysit-pr: merged` or `carve-changesets: all_merged` result plus the independent mainline, @@ -115,3 +120,10 @@ For `requires_epic`, require all of: If the incoming handoff already contains the same marker, return `blocked` with `routing cycle detected` rather than another `requires_epic` result. + +For delegated execution, a blocked result distinguishes `none`, `local`, and +`published` implementation state. Published state includes the verified remote +URL, full ref, exact head, and publication topology and is transferable. Local +state is explicitly non-transferable and includes the reason publication could +not safely occur. Never substitute a workspace path or local-only SHA for a +durable handoff. diff --git a/skills/implement-ticket/references/delegated-execution/CONTRACT.md b/skills/implement-ticket/references/delegated-execution/CONTRACT.md new file mode 100644 index 0000000..0cb5f86 --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/CONTRACT.md @@ -0,0 +1,188 @@ +# Delegated execution contract + +This optional contract lets an external coordinator invoke `implement-ticket` +while retaining authority over consequential external mutations. It is generic: +the coordinator is opaque to Agent Scripts, and no Atelier concept appears in +the protocol. + +The capability identifier is +`agent-scripts.implement-ticket/delegated-execution/v1`. + +## Contract ownership + +- `capability.json` is the discovery manifest and is validated by + `capability.schema.json`. +- `invocation.schema.json` owns the invocation shape. +- `checkpoint-request.schema.json` and `checkpoint-response.schema.json` own the + synchronous fencing exchange. +- `result.schema.json` owns the terminal result shape. +- `validate.py` validates schemas and cross-field semantics without third-party + dependencies. +- This document owns process semantics that JSON Schema cannot express. + +All objects reject unknown fields. An unsupported version, malformed object, +failed validator, failed checkpoint command, or ambiguous response blocks +execution. + +## Invocation + +The caller validates and supplies one invocation object before any mutation. It +binds the run to: + +- one ticket and tracker; +- one repository, exact base ref, and exact base SHA; +- one caller-owned work identifier and revision; +- opaque caller-owned approval evidence; +- intent, scope, non-goals, constraints, and done definition; +- required validation and review expectations; +- a finite authority ceiling; +- one desired delivery outcome; +- the terminal states the caller can consume; +- an argv-style checkpoint command; +- the caller's last consumed checkpoint sequence; and +- an opaque continuation token. + +The checkpoint command is an array of executable and argument strings. Agent +Scripts sends one JSON checkpoint request on standard input and requires exactly +one JSON checkpoint response on standard output. It does not invoke a shell, +interpret extra output, persist credentials, or inspect the continuation token. +The first request sequence is exactly one greater than the invocation's +`last_sequence`, so a new delegate can continue an existing caller-owned claim. + +The generic terminal states remain `ready_pr`, `ready_prs`, `merged`, `blocked`, +and `requires_epic`. A caller may narrow this set. `implement-ticket` must not +select an outcome the invocation excludes. If its only correct outcome is +excluded, it returns `blocked` without performing the excluded action. + +## Consequential mutation checkpoint + +Immediately before every consequential external mutation, `implement-ticket` +sends a `pre_external_mutation` request. The finite action vocabulary is: + +- `repository.candidate.create` +- `repository.candidate.push` +- `pull_request.create` +- `pull_request.update` +- `review.reply` +- `review.resolve` +- `ticket.update` +- `ticket.dependencies.update` +- `ticket.followup.create` +- `changeset.carve` +- `pull_request.merge` +- `repository.branch.delete` +- `deployment.execute` +- `production.mutate` +- `destructive.execute` + +The request includes the invocation identity, current continuation token, +strictly increasing sequence, action, exact current ticket observation, exact +candidate when one exists, and a concise proposed-effect description. + +Candidate push, pull-request, review, carving, merge, and candidate-branch +deletion checkpoints require the exact candidate. A push checkpoint describes +the proposed remote URL, full ref, base SHA, and head SHA before publication. + +The coordinator must reread its authoritative state and return `allow` or +`deny`. `allow` must name the same invocation and request sequence and return a +new opaque continuation token. `deny` echoes the request's continuation token; +it does not advance durable sequence or token state. Identity mismatch, sequence +mismatch, token mismatch, malformed output, command failure, or unavailable +coordinator blocks the mutation. + +An `allow` decision authorizes only that one proposed mutation. It does not +cache authority for a later action. + +The bundled validator is stateless. The checkpoint command must atomically +compare the expected sequence and current continuation token with its durable +state before returning `allow`, then persist the returned sequence and token. +For an allowed request it must also persist the invocation ID, phase, action, +proposed effect, exact candidate identity, and acknowledgement before returning. +These records are an append-only authorization ledger; they let the caller +compare the terminal `authority_used` report with every consumed allowance. +Replaying a consumed request must fail. The validator's +`validate_checkpoint_progress` helper checks the sequence and token transition, +but the caller owns atomic persistence and the authorization ledger. + +## Candidate publication acknowledgement + +Immediately after every successful remote candidate publication or advancement, +`implement-ticket` sends a `candidate_published` checkpoint before any later +mutation or terminal result. + +The request contains the verified remote URL, full remote ref, base SHA, and +published head SHA. The coordinator returns `allow` only after it has durably +acknowledged that exact candidate. Its response must repeat the acknowledged +head SHA. A missing or different SHA blocks continuation. + +This post-publication acknowledgement closes the unavoidable interval between a +Git push and the coordinator's durable record. The pushed candidate remains a +recoverable project artifact even when acknowledgement fails. A blocked result +may report that verified published candidate whether acknowledgement succeeded +or failed. It becomes shared coordinator state only after the caller records it +in a later verified transition. + +## Terminal result + +The terminal result is always validated before return. It records: + +- terminal state and exact identities; +- whether implementation state is `none`, `local`, or `published`; +- a remotely reachable candidate and publication topology when one exists; +- whether the handoff is transferable; +- checkpoint sequence and final continuation token; +- validation and review observations; +- authority actually used; +- unresolved obligations; and +- one next action or blocking reason. + +`ready_pr`, `ready_prs`, and `merged` require published, transferable candidate +state. `ready_pr` requires exactly one PR; `ready_prs` requires a stack. +`requires_epic` requires no implementation state. + +Delivery terminals must report every required validation command as passed at +the exact candidate, satisfy requested independent review, and report zero +unresolved material feedback when requested. + +Published implementation must report the candidate push in `authority_used`. A +result containing a pull request must also report the corresponding pull-request +create or update action. Validation and review observation names are unique; +duplicate observations cannot override one another by array order. An ordered +`ready_prs` stack contains distinct pull request identities, URLs, and heads, +and its final pull request head equals the reported candidate head. Each PR +records its exact base ref, base SHA, head ref, and head SHA. The first base ref +and SHA equal the invocation base; every later base ref and SHA equal the +previous PR head ref and SHA. + +A blocked run with published implementation must return its transferable +candidate. It may have zero pull requests when execution blocked after the push +but before PR creation, including when candidate acknowledgement failed. A +blocked run with only local implementation returns no candidate, sets +`transferable` to false, and explains why no durable handoff exists. It must +never describe a local-only SHA as transferable. + +The caller must validate every terminal result against the durable checkpoint +ledger tail, not merely the invocation's starting position. The bundled +`validate_result_checkpoint_state` helper requires the terminal sequence and +continuation token to equal that caller-supplied tail. A stale terminal +checkpoint blocks handoff. + +A material ticket-observation change always causes the caller to deny the +current invocation. Eligibility may be reevaluated only before starting a fresh +invocation with a newly observed ticket contract. The current invocation's +terminal result therefore continues to identify its original ticket observation +truthfully. + +## Compatibility and failure + +Standalone invocations remain unchanged and may return the documented human +handoff. Delegated execution applies only when the caller supplies a valid v1 +invocation. + +There is no daemon, callback server, or background lease. The checkpoint command +is synchronous and caller-owned. If the caller disappears, execution fails +closed at the next checkpoint and preserves any already-published candidate. + +The coordinator identity is cooperative attribution, not authentication. +Operating-system permissions, repository access, and provider controls remain +the enforcement boundaries. diff --git a/skills/implement-ticket/references/delegated-execution/capability.json b/skills/implement-ticket/references/delegated-execution/capability.json new file mode 100644 index 0000000..9516a16 --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/capability.json @@ -0,0 +1,21 @@ +{ + "schema": "agent-scripts.implement-ticket/capability-manifest/v1", + "id": "agent-scripts.implement-ticket/delegated-execution/v1", + "contract": "CONTRACT.md", + "invocation_schema": "invocation.schema.json", + "checkpoint_request_schema": "checkpoint-request.schema.json", + "checkpoint_response_schema": "checkpoint-response.schema.json", + "result_schema": "result.schema.json", + "validator": "validate.py", + "terminal_states": [ + "ready_pr", + "ready_prs", + "merged", + "blocked", + "requires_epic" + ], + "checkpoint_phases": [ + "pre_external_mutation", + "candidate_published" + ] +} diff --git a/skills/implement-ticket/references/delegated-execution/capability.schema.json b/skills/implement-ticket/references/delegated-execution/capability.schema.json new file mode 100644 index 0000000..a7b3d4a --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/capability.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agent Scripts delegated implement-ticket capability manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "id", + "contract", + "invocation_schema", + "checkpoint_request_schema", + "checkpoint_response_schema", + "result_schema", + "validator", + "terminal_states", + "checkpoint_phases" + ], + "properties": { + "schema": { + "const": "agent-scripts.implement-ticket/capability-manifest/v1" + }, + "id": { + "const": "agent-scripts.implement-ticket/delegated-execution/v1" + }, + "contract": { + "const": "CONTRACT.md" + }, + "invocation_schema": { + "const": "invocation.schema.json" + }, + "checkpoint_request_schema": { + "const": "checkpoint-request.schema.json" + }, + "checkpoint_response_schema": { + "const": "checkpoint-response.schema.json" + }, + "result_schema": { + "const": "result.schema.json" + }, + "validator": { + "const": "validate.py" + }, + "terminal_states": { + "type": "array", + "const": [ + "ready_pr", + "ready_prs", + "merged", + "blocked", + "requires_epic" + ] + }, + "checkpoint_phases": { + "type": "array", + "const": [ + "pre_external_mutation", + "candidate_published" + ] + } + } +} diff --git a/skills/implement-ticket/references/delegated-execution/checkpoint-request.schema.json b/skills/implement-ticket/references/delegated-execution/checkpoint-request.schema.json new file mode 100644 index 0000000..bbc8a3a --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/checkpoint-request.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agent Scripts delegated implement-ticket checkpoint request", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "capability", + "invocation_id", + "continuation_token", + "sequence", + "phase", + "action", + "ticket_observation", + "candidate", + "proposed_effect" + ], + "properties": { + "schema": { + "const": "agent-scripts.implement-ticket/checkpoint-request/v1" + }, + "capability": { + "const": "agent-scripts.implement-ticket/delegated-execution/v1" + }, + "invocation_id": { + "type": "string", + "minLength": 1 + }, + "continuation_token": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "type": "integer", + "minimum": 1 + }, + "phase": { + "enum": ["pre_external_mutation", "candidate_published"] + }, + "action": { + "enum": [ + "repository.candidate.create", + "repository.candidate.push", + "pull_request.create", + "pull_request.update", + "review.reply", + "review.resolve", + "ticket.update", + "ticket.dependencies.update", + "ticket.followup.create", + "changeset.carve", + "pull_request.merge", + "repository.branch.delete", + "deployment.execute", + "production.mutate", + "destructive.execute" + ] + }, + "ticket_observation": { + "type": "string", + "minLength": 1 + }, + "candidate": { + "type": ["object", "null"], + "additionalProperties": false, + "required": [ + "repository", + "remote_url", + "remote_ref", + "base_sha", + "head_sha" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "remote_url": { + "type": "string", + "minLength": 1 + }, + "remote_ref": { + "type": "string", + "pattern": "^refs/.+$" + }, + "base_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + }, + "head_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + } + } + }, + "proposed_effect": { + "type": "string", + "minLength": 1 + } + } +} diff --git a/skills/implement-ticket/references/delegated-execution/checkpoint-response.schema.json b/skills/implement-ticket/references/delegated-execution/checkpoint-response.schema.json new file mode 100644 index 0000000..221f2c2 --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/checkpoint-response.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agent Scripts delegated implement-ticket checkpoint response", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "invocation_id", + "request_sequence", + "prior_continuation_token", + "continuation_token", + "decision", + "reason", + "acknowledged_candidate_sha" + ], + "properties": { + "schema": { + "const": "agent-scripts.implement-ticket/checkpoint-response/v1" + }, + "invocation_id": { + "type": "string", + "minLength": 1 + }, + "request_sequence": { + "type": "integer", + "minimum": 1 + }, + "prior_continuation_token": { + "type": "string", + "minLength": 1 + }, + "continuation_token": { + "type": "string", + "minLength": 1 + }, + "decision": { + "enum": ["allow", "deny"] + }, + "reason": { + "type": ["string", "null"] + }, + "acknowledged_candidate_sha": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{40,64}$" + } + } +} diff --git a/skills/implement-ticket/references/delegated-execution/invocation.schema.json b/skills/implement-ticket/references/delegated-execution/invocation.schema.json new file mode 100644 index 0000000..03530d7 --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/invocation.schema.json @@ -0,0 +1,243 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agent Scripts delegated implement-ticket invocation", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "capability", + "invocation_id", + "ticket", + "repository", + "work", + "validation", + "review", + "authority", + "desired_outcome", + "accepted_terminal_states", + "checkpoint" + ], + "properties": { + "schema": { + "const": "agent-scripts.implement-ticket/delegated-invocation/v1" + }, + "capability": { + "const": "agent-scripts.implement-ticket/delegated-execution/v1" + }, + "invocation_id": { + "type": "string", + "minLength": 1 + }, + "ticket": { + "$ref": "#/$defs/ticket" + }, + "repository": { + "$ref": "#/$defs/repository" + }, + "work": { + "$ref": "#/$defs/work" + }, + "validation": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "review": { + "type": "object", + "additionalProperties": false, + "required": ["independent", "unresolved_feedback_required"], + "properties": { + "independent": { + "type": "boolean" + }, + "unresolved_feedback_required": { + "type": "boolean" + } + } + }, + "authority": { + "type": "object", + "additionalProperties": false, + "required": ["allow"], + "properties": { + "allow": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/action" + } + } + } + }, + "desired_outcome": { + "enum": ["ready_pr", "ready_prs", "merged"] + }, + "accepted_terminal_states": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "ready_pr", + "ready_prs", + "merged", + "blocked", + "requires_epic" + ] + } + }, + "checkpoint": { + "type": "object", + "additionalProperties": false, + "required": ["command", "last_sequence", "continuation_token"], + "properties": { + "command": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "last_sequence": { + "type": "integer", + "minimum": 0 + }, + "continuation_token": { + "type": "string", + "minLength": 1 + } + } + } + }, + "$defs": { + "action": { + "enum": [ + "repository.candidate.create", + "repository.candidate.push", + "pull_request.create", + "pull_request.update", + "review.reply", + "review.resolve", + "ticket.update", + "ticket.dependencies.update", + "ticket.followup.create", + "changeset.carve", + "pull_request.merge", + "repository.branch.delete", + "deployment.execute", + "production.mutate", + "destructive.execute" + ] + }, + "ticket": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "id", "url", "observation"], + "properties": { + "provider": { + "enum": ["github", "linear"] + }, + "id": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "observation": { + "type": "string", + "minLength": 1 + } + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["identity", "remote_url", "base_ref", "base_sha"], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "remote_url": { + "type": "string", + "minLength": 1 + }, + "base_ref": { + "type": "string", + "pattern": "^refs/.+$" + }, + "base_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + } + } + }, + "work": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "revision", + "approval_evidence", + "intent", + "scope", + "non_goals", + "constraints", + "done_definition" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "approval_evidence": { + "type": "string", + "minLength": 1 + }, + "intent": { + "type": "string", + "minLength": 1 + }, + "scope": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "non_goals": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "constraints": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "done_definition": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } +} diff --git a/skills/implement-ticket/references/delegated-execution/result.schema.json b/skills/implement-ticket/references/delegated-execution/result.schema.json new file mode 100644 index 0000000..7507431 --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/result.schema.json @@ -0,0 +1,302 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agent Scripts delegated implement-ticket terminal result", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "capability", + "invocation_id", + "terminal_state", + "ticket", + "repository", + "implementation_state", + "candidate", + "handoff", + "checkpoint", + "validation", + "reviews", + "feedback", + "authority_used", + "unresolved_obligations", + "blocking_reason", + "next_action" + ], + "properties": { + "schema": { + "const": "agent-scripts.implement-ticket/delegated-result/v1" + }, + "capability": { + "const": "agent-scripts.implement-ticket/delegated-execution/v1" + }, + "invocation_id": { + "type": "string", + "minLength": 1 + }, + "terminal_state": { + "enum": [ + "ready_pr", + "ready_prs", + "merged", + "blocked", + "requires_epic" + ] + }, + "ticket": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "id", "url", "observation"], + "properties": { + "provider": { + "enum": ["github", "linear"] + }, + "id": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "observation": { + "type": "string", + "minLength": 1 + } + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["identity", "base_ref", "base_sha"], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "base_ref": { + "type": "string", + "pattern": "^refs/.+$" + }, + "base_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + } + } + }, + "implementation_state": { + "enum": ["none", "local", "published"] + }, + "candidate": { + "type": ["object", "null"], + "additionalProperties": false, + "required": [ + "repository", + "remote_url", + "remote_ref", + "base_sha", + "head_sha", + "publication" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "remote_url": { + "type": "string", + "minLength": 1 + }, + "remote_ref": { + "type": "string", + "pattern": "^refs/.+$" + }, + "base_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + }, + "head_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + }, + "publication": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "pull_requests"], + "properties": { + "kind": { + "enum": ["ordinary", "stack"] + }, + "pull_requests": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "url", + "base_ref", + "base_sha", + "head_ref", + "head_sha", + "state" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "base_ref": { + "type": "string", + "minLength": 1 + }, + "base_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + }, + "head_ref": { + "type": "string", + "pattern": "^refs/.+$" + }, + "head_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + }, + "state": { + "enum": ["open", "merged"] + } + } + } + } + } + } + } + }, + "handoff": { + "type": "object", + "additionalProperties": false, + "required": ["transferable", "reason"], + "properties": { + "transferable": { + "type": "boolean" + }, + "reason": { + "type": ["string", "null"] + } + } + }, + "checkpoint": { + "type": "object", + "additionalProperties": false, + "required": ["last_sequence", "continuation_token"], + "properties": { + "last_sequence": { + "type": "integer", + "minimum": 0 + }, + "continuation_token": { + "type": "string", + "minLength": 1 + } + } + }, + "validation": { + "type": "array", + "items": { + "$ref": "#/$defs/observation" + } + }, + "reviews": { + "type": "array", + "items": { + "$ref": "#/$defs/observation" + } + }, + "feedback": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["unresolved_material_count", "candidate_sha", "observed_at"], + "properties": { + "unresolved_material_count": { + "type": "integer", + "minimum": 0 + }, + "candidate_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + }, + "observed_at": { + "type": "string", + "minLength": 1 + } + } + }, + "authority_used": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/action" + } + }, + "unresolved_obligations": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "blocking_reason": { + "type": ["string", "null"] + }, + "next_action": { + "type": "string", + "minLength": 1 + } + }, + "$defs": { + "action": { + "enum": [ + "repository.candidate.create", + "repository.candidate.push", + "pull_request.create", + "pull_request.update", + "review.reply", + "review.resolve", + "ticket.update", + "ticket.dependencies.update", + "ticket.followup.create", + "changeset.carve", + "pull_request.merge", + "repository.branch.delete", + "deployment.execute", + "production.mutate", + "destructive.execute" + ] + }, + "observation": { + "type": "object", + "additionalProperties": false, + "required": ["name", "outcome", "candidate_sha", "observed_at"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "outcome": { + "enum": ["passed", "failed", "unavailable"] + }, + "candidate_sha": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{40,64}$" + }, + "observed_at": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/skills/implement-ticket/references/delegated-execution/validate.py b/skills/implement-ticket/references/delegated-execution/validate.py new file mode 100644 index 0000000..66767cd --- /dev/null +++ b/skills/implement-ticket/references/delegated-execution/validate.py @@ -0,0 +1,532 @@ +#!/usr/bin/env python3 +"""Validate delegated implement-ticket protocol objects.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +HERE = Path(__file__).resolve().parent +SCHEMAS = { + "capability": HERE / "capability.schema.json", + "invocation": HERE / "invocation.schema.json", + "checkpoint-request": HERE / "checkpoint-request.schema.json", + "checkpoint-response": HERE / "checkpoint-response.schema.json", + "result": HERE / "result.schema.json", +} + +CANDIDATE_REQUIRED_ACTIONS = { + "repository.candidate.push", + "pull_request.create", + "pull_request.update", + "review.reply", + "review.resolve", + "changeset.carve", + "pull_request.merge", + "repository.branch.delete", +} +PUBLICATION_ACTIONS = CANDIDATE_REQUIRED_ACTIONS | { + "deployment.execute", + "production.mutate", + "destructive.execute", +} + + +def _path(parent: str, key: object) -> str: + if isinstance(key, int): + return f"{parent}[{key}]" + return f"{parent}.{key}" if parent else str(key) + + +def _matches_type(value: Any, expected: str) -> bool: + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + if expected == "null": + return value is None + return False + + +def _resolve_ref(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]: + reference = schema.get("$ref") + if not reference: + return schema + if not reference.startswith("#/$defs/"): + raise ValueError(f"unsupported schema reference: {reference}") + name = reference.removeprefix("#/$defs/") + return root["$defs"][name] + + +def validate_schema( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + at: str = "$", +) -> list[str]: + """Validate the JSON Schema subset used by this contract.""" + schema = _resolve_ref(schema, root) + errors: list[str] = [] + expected = schema.get("type") + if expected: + choices = expected if isinstance(expected, list) else [expected] + if not any(_matches_type(value, choice) for choice in choices): + return [f"{at}: expected {' or '.join(choices)}"] + + if "const" in schema and value != schema["const"]: + errors.append(f"{at}: expected constant {schema['const']!r}") + if "enum" in schema and value not in schema["enum"]: + errors.append(f"{at}: expected one of {schema['enum']!r}") + if isinstance(value, str): + if len(value) < schema.get("minLength", 0): + errors.append(f"{at}: string is too short") + if pattern := schema.get("pattern"): + if re.fullmatch(pattern, value) is None: + errors.append(f"{at}: does not match {pattern!r}") + if isinstance(value, int) and not isinstance(value, bool): + if "minimum" in schema and value < schema["minimum"]: + errors.append(f"{at}: must be at least {schema['minimum']}") + if isinstance(value, list): + if len(value) < schema.get("minItems", 0): + errors.append(f"{at}: expected at least {schema['minItems']} item(s)") + if schema.get("uniqueItems"): + encoded = [json.dumps(item, sort_keys=True) for item in value] + if len(encoded) != len(set(encoded)): + errors.append(f"{at}: expected unique items") + if item_schema := schema.get("items"): + for index, item in enumerate(value): + errors.extend( + validate_schema(item, item_schema, root, _path(at, index)) + ) + if isinstance(value, dict): + properties = schema.get("properties", {}) + for key in schema.get("required", []): + if key not in value: + errors.append(f"{at}: missing required property {key!r}") + if schema.get("additionalProperties") is False: + for key in value.keys() - properties.keys(): + errors.append(f"{_path(at, key)}: unknown property") + for key, child in value.items(): + if key in properties: + errors.extend( + validate_schema(child, properties[key], root, _path(at, key)) + ) + return errors + + +def _validate_invocation(value: dict[str, Any]) -> list[str]: + errors: list[str] = [] + accepted = value.get("accepted_terminal_states", []) + desired = value.get("desired_outcome") + if desired not in accepted: + errors.append("$.desired_outcome: must appear in accepted_terminal_states") + if "blocked" not in accepted: + errors.append("$.accepted_terminal_states: must include blocked") + return errors + + +def _validate_checkpoint_request(value: dict[str, Any]) -> list[str]: + errors: list[str] = [] + phase = value.get("phase") + candidate = value.get("candidate") + if value.get("action") in CANDIDATE_REQUIRED_ACTIONS and candidate is None: + errors.append("$.candidate: action requires exact candidate") + if phase == "candidate_published": + if candidate is None: + errors.append("$.candidate: candidate_published requires candidate") + if value.get("action") != "repository.candidate.push": + errors.append( + "$.action: candidate_published requires repository.candidate.push" + ) + return errors + + +def _validate_checkpoint_response(value: dict[str, Any]) -> list[str]: + errors: list[str] = [] + decision = value.get("decision") + reason = value.get("reason") + prior = value.get("prior_continuation_token") + continuation = value.get("continuation_token") + if decision == "deny" and not reason: + errors.append("$.reason: deny requires a reason") + if decision == "allow" and reason is not None: + errors.append("$.reason: allow requires null") + if decision == "allow" and continuation == prior: + errors.append("$.continuation_token: allow must rotate the token") + if decision == "deny" and continuation != prior: + errors.append("$.continuation_token: deny must preserve the prior token") + return errors + + +def _validate_result(value: dict[str, Any]) -> list[str]: + errors: list[str] = [] + terminal = value.get("terminal_state") + implementation = value.get("implementation_state") + candidate = value.get("candidate") + handoff = value.get("handoff", {}) + transferable = handoff.get("transferable") + reason = handoff.get("reason") + blocking_reason = value.get("blocking_reason") + authority_used = set(value.get("authority_used", [])) + + for collection in ("validation", "reviews"): + names = [observation["name"] for observation in value[collection]] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + errors.append( + f"$.{collection}: duplicate observation names " + ", ".join(duplicates) + ) + + if implementation == "published": + if candidate is None or transferable is not True: + errors.append( + "$.candidate: published implementation requires transferable candidate" + ) + if "repository.candidate.push" not in authority_used: + errors.append( + "$.authority_used: published implementation requires " + "repository.candidate.push" + ) + elif candidate is not None or transferable is not False: + errors.append( + "$.candidate: none or local implementation forbids transferable candidate" + ) + if transferable and reason is not None: + errors.append("$.handoff.reason: transferable handoff requires null") + if not transferable and not reason: + errors.append("$.handoff.reason: non-transferable handoff requires reason") + + if terminal == "blocked": + if not blocking_reason: + errors.append("$.blocking_reason: blocked requires a reason") + elif blocking_reason is not None: + errors.append(f"$.blocking_reason: {terminal} requires null") + + if terminal == "requires_epic" and implementation != "none": + errors.append("$.implementation_state: requires_epic requires none") + if implementation == "none" and authority_used: + errors.append("$.authority_used: no implementation requires no actions") + if implementation == "local": + excess = sorted(authority_used & PUBLICATION_ACTIONS) + if excess: + errors.append( + "$.authority_used: local implementation contradicts publication " + + ", ".join(excess) + ) + if terminal in {"ready_pr", "ready_prs", "merged"}: + if implementation != "published" or candidate is None: + errors.append( + "$.implementation_state: delivery terminal requires published candidate" + ) + if candidate is not None: + publication = candidate["publication"] + kind = publication["kind"] + pull_requests = publication["pull_requests"] + if pull_requests and not authority_used.intersection( + {"pull_request.create", "pull_request.update"} + ): + errors.append( + "$.authority_used: pull request publication requires create or update" + ) + if terminal == "ready_pr": + if kind != "ordinary" or len(pull_requests) != 1: + errors.append( + "$.candidate.publication: ready_pr requires one ordinary PR" + ) + elif pull_requests[0]["state"] != "open": + errors.append("$.candidate.publication: ready_pr requires open PR") + elif pull_requests[0]["head_sha"] != candidate["head_sha"]: + errors.append( + "$.candidate.publication: ready_pr head must match candidate" + ) + elif pull_requests[0]["head_ref"] != candidate["remote_ref"]: + errors.append( + "$.candidate.publication: ready_pr head ref must match candidate" + ) + elif pull_requests[0]["base_sha"] != candidate["base_sha"]: + errors.append( + "$.candidate.publication: ready_pr base must match candidate" + ) + if terminal == "ready_prs" and (kind != "stack" or len(pull_requests) < 2): + errors.append( + "$.candidate.publication: ready_prs requires a multi-PR stack" + ) + if terminal == "ready_prs" and any( + pull_request["state"] != "open" for pull_request in pull_requests + ): + errors.append("$.candidate.publication: ready_prs requires open PRs") + if terminal == "ready_prs": + for field in ("id", "url", "head_sha"): + values = [pull_request[field] for pull_request in pull_requests] + if len(values) != len(set(values)): + errors.append( + "$.candidate.publication: ready_prs requires unique " + f"pull request {field} values" + ) + expected_base = candidate["base_sha"] + expected_base_ref = None + for index, pull_request in enumerate(pull_requests): + if pull_request["base_sha"] != expected_base: + errors.append( + "$.candidate.publication: ready_prs base chain is invalid" + ) + break + if index > 0 and pull_request["base_ref"] != expected_base_ref: + errors.append( + "$.candidate.publication: ready_prs ref chain is invalid" + ) + break + expected_base = pull_request["head_sha"] + expected_base_ref = pull_request["head_ref"] + if terminal == "merged" and any( + pull_request["state"] != "merged" for pull_request in pull_requests + ): + errors.append("$.candidate.publication: merged requires merged PRs") + if terminal == "merged" and not pull_requests: + errors.append("$.candidate.publication: merged requires at least one PR") + if terminal in {"ready_prs", "merged"} and pull_requests: + if pull_requests[-1]["head_sha"] != candidate["head_sha"]: + errors.append( + "$.candidate.publication: final PR head must match candidate" + ) + if pull_requests[-1]["head_ref"] != candidate["remote_ref"]: + errors.append( + "$.candidate.publication: final PR head ref must match candidate" + ) + for collection in ("validation", "reviews"): + stale = [ + observation["name"] + for observation in value[collection] + if observation["candidate_sha"] not in {None, candidate["head_sha"]} + ] + if stale: + errors.append( + f"$.{collection}: candidate mismatch for " + ", ".join(stale) + ) + return errors + + +SEMANTIC_VALIDATORS = { + "capability": lambda value: [], + "invocation": _validate_invocation, + "checkpoint-request": _validate_checkpoint_request, + "checkpoint-response": _validate_checkpoint_response, + "result": _validate_result, +} + + +def validate(kind: str, value: dict[str, Any]) -> list[str]: + """Validate one delegated execution protocol object.""" + schema = json.loads(SCHEMAS[kind].read_text()) + errors = validate_schema(value, schema, schema) + if not errors: + errors.extend(SEMANTIC_VALIDATORS[kind](value)) + return errors + + +def validate_checkpoint_exchange( + request: dict[str, Any], + response: dict[str, Any], +) -> list[str]: + """Validate a response against the exact checkpoint request.""" + errors = validate("checkpoint-request", request) + errors.extend(validate("checkpoint-response", response)) + if errors: + return errors + comparisons = ( + ("invocation_id", "invocation_id"), + ("sequence", "request_sequence"), + ("continuation_token", "prior_continuation_token"), + ) + for request_field, response_field in comparisons: + if request[request_field] != response[response_field]: + errors.append(f"$.{response_field}: does not match request {request_field}") + acknowledged = response["acknowledged_candidate_sha"] + if response["decision"] == "deny": + if acknowledged is not None: + errors.append("$.acknowledged_candidate_sha: deny requires null") + elif request["phase"] == "candidate_published": + if acknowledged != request["candidate"]["head_sha"]: + errors.append( + "$.acknowledged_candidate_sha: does not match published candidate" + ) + elif acknowledged is not None: + errors.append( + "$.acknowledged_candidate_sha: pre-mutation response requires null" + ) + return errors + + +def validate_checkpoint_progress( + last_sequence: int, + current_token: str, + request: dict[str, Any], + response: dict[str, Any], +) -> list[str]: + """Validate one exchange against caller-persisted checkpoint state.""" + errors = validate_checkpoint_exchange(request, response) + if request.get("sequence") != last_sequence + 1: + errors.append("$.sequence: must advance caller state by exactly one") + if request.get("continuation_token") != current_token: + errors.append("$.continuation_token: does not match caller state") + return errors + + +def validate_result_for_invocation( + invocation: dict[str, Any], + result: dict[str, Any], +) -> list[str]: + """Validate a terminal result against its delegated invocation.""" + errors = validate("invocation", invocation) + errors.extend(validate("result", result)) + if errors: + return errors + if result["invocation_id"] != invocation["invocation_id"]: + errors.append("$.invocation_id: does not match invocation") + if result["terminal_state"] not in invocation["accepted_terminal_states"]: + errors.append("$.terminal_state: caller does not accept this state") + if result["ticket"] != invocation["ticket"]: + errors.append("$.ticket: does not match invocation") + expected_repository = { + "identity": invocation["repository"]["identity"], + "base_ref": invocation["repository"]["base_ref"], + "base_sha": invocation["repository"]["base_sha"], + } + if result["repository"] != expected_repository: + errors.append("$.repository: does not match invocation") + candidate = result["candidate"] + if candidate is not None: + candidate_expectations = { + "repository": invocation["repository"]["identity"], + "remote_url": invocation["repository"]["remote_url"], + "base_sha": invocation["repository"]["base_sha"], + } + for field, expected in candidate_expectations.items(): + if candidate[field] != expected: + errors.append(f"$.candidate.{field}: does not match invocation") + pull_requests = candidate["publication"]["pull_requests"] + if ( + pull_requests + and pull_requests[0]["base_ref"] != invocation["repository"]["base_ref"] + ): + errors.append( + "$.candidate.publication: first PR base_ref does not match invocation" + ) + allowed = set(invocation["authority"]["allow"]) + excess = sorted(set(result["authority_used"]) - allowed) + if excess: + errors.append("$.authority_used: exceeds invocation: " + ", ".join(excess)) + if result["terminal_state"] in {"ready_pr", "ready_prs", "merged"}: + candidate_sha = candidate["head_sha"] + observations = {item["name"]: item for item in result["validation"]} + for command in invocation["validation"]: + observation = observations.get(command) + if observation is None: + errors.append(f"$.validation: missing required command {command}") + elif ( + observation["outcome"] != "passed" + or observation["candidate_sha"] != candidate_sha + ): + errors.append( + f"$.validation: {command} did not pass at exact candidate" + ) + if invocation["review"]["independent"]: + current_reviews = [ + item + for item in result["reviews"] + if item["outcome"] == "passed" + and item["candidate_sha"] == candidate_sha + ] + if not current_reviews: + errors.append( + "$.reviews: independent review did not pass at exact candidate" + ) + if invocation["review"]["unresolved_feedback_required"]: + feedback = result["feedback"] + if ( + feedback is None + or feedback["candidate_sha"] != candidate_sha + or feedback["unresolved_material_count"] != 0 + ): + errors.append( + "$.feedback: unresolved material feedback is not zero at " + "exact candidate" + ) + return errors + + +def validate_result_checkpoint_state( + invocation: dict[str, Any], + result: dict[str, Any], + last_sequence: int, + current_token: str, +) -> list[str]: + """Validate a terminal result against its invocation and caller ledger tail.""" + errors = validate_result_for_invocation(invocation, result) + if errors: + return errors + if result["checkpoint"]["last_sequence"] != last_sequence: + errors.append("$.checkpoint.last_sequence: does not match caller ledger") + if result["checkpoint"]["continuation_token"] != current_token: + errors.append("$.checkpoint.continuation_token: does not match caller ledger") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "kind", + choices=sorted(SCHEMAS), + help="Protocol object kind", + ) + parser.add_argument("path", type=Path, help="JSON object to validate") + parser.add_argument( + "--invocation", + type=Path, + help="Validate a result against this invocation", + ) + parser.add_argument( + "--request", + type=Path, + help="Validate a checkpoint response against this request", + ) + args = parser.parse_args() + + try: + value = json.loads(args.path.read_text()) + except (OSError, json.JSONDecodeError) as error: + print(f"{args.path}: {error}", file=sys.stderr) + return 2 + if not isinstance(value, dict): + print("$: expected object", file=sys.stderr) + return 1 + if args.invocation: + if args.kind != "result": + parser.error("--invocation requires kind=result") + invocation = json.loads(args.invocation.read_text()) + errors = validate_result_for_invocation(invocation, value) + elif args.request: + if args.kind != "checkpoint-response": + parser.error("--request requires kind=checkpoint-response") + request = json.loads(args.request.read_text()) + errors = validate_checkpoint_exchange(request, value) + else: + errors = validate(args.kind, value) + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/implement-ticket/scripts/tests/test_delegated_execution_contract.py b/skills/implement-ticket/scripts/tests/test_delegated_execution_contract.py new file mode 100644 index 0000000..0ef9abb --- /dev/null +++ b/skills/implement-ticket/scripts/tests/test_delegated_execution_contract.py @@ -0,0 +1,581 @@ +"""Tests for the optional delegated implement-ticket protocol.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import unittest +from pathlib import Path +from types import ModuleType + +SKILL_ROOT = Path(__file__).resolve().parents[2] +CONTRACT_ROOT = SKILL_ROOT / "references" / "delegated-execution" +SHA_A = "a" * 40 +SHA_B = "b" * 40 +SHA_C = "c" * 40 + + +def load_validator() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "delegated_execution_validator", + CONTRACT_ROOT / "validate.py", + ) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load delegated execution validator") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def invocation() -> dict[str, object]: + return { + "schema": "agent-scripts.implement-ticket/delegated-invocation/v1", + "capability": "agent-scripts.implement-ticket/delegated-execution/v1", + "invocation_id": "run-123", + "ticket": { + "provider": "github", + "id": "123", + "url": "https://github.com/example/project/issues/123", + "observation": "sha256:ticket", + }, + "repository": { + "identity": "github:example/project", + "remote_url": "git@github.com:example/project.git", + "base_ref": "refs/heads/main", + "base_sha": SHA_A, + }, + "work": { + "id": "external-work-123", + "revision": 1, + "approval_evidence": "coordinator-record:approved-123", + "intent": "Add the approved behavior", + "scope": ["one reviewable change"], + "non_goals": ["merge"], + "constraints": ["preserve compatibility"], + "done_definition": ["one ready PR"], + }, + "validation": ["just test", "just lint"], + "review": { + "independent": True, + "unresolved_feedback_required": True, + }, + "authority": { + "allow": [ + "repository.candidate.create", + "repository.candidate.push", + "pull_request.create", + "pull_request.update", + "review.reply", + "review.resolve", + ] + }, + "desired_outcome": "ready_pr", + "accepted_terminal_states": [ + "ready_pr", + "blocked", + "requires_epic", + ], + "checkpoint": { + "command": ["example-coordinator", "checkpoint"], + "last_sequence": 0, + "continuation_token": "token-0", + }, + } + + +def candidate(kind: str = "ordinary") -> dict[str, object]: + return { + "repository": "github:example/project", + "remote_url": "git@github.com:example/project.git", + "remote_ref": "refs/heads/example-work", + "base_sha": SHA_A, + "head_sha": SHA_B, + "publication": { + "kind": kind, + "pull_requests": [ + { + "id": "456", + "url": "https://github.com/example/project/pull/456", + "base_ref": "refs/heads/main", + "base_sha": SHA_A, + "head_ref": "refs/heads/example-work", + "head_sha": SHA_B, + "state": "open", + } + ], + }, + } + + +def result() -> dict[str, object]: + source = invocation() + return { + "schema": "agent-scripts.implement-ticket/delegated-result/v1", + "capability": "agent-scripts.implement-ticket/delegated-execution/v1", + "invocation_id": "run-123", + "terminal_state": "ready_pr", + "ticket": source["ticket"], + "repository": { + "identity": "github:example/project", + "base_ref": "refs/heads/main", + "base_sha": SHA_A, + }, + "implementation_state": "published", + "candidate": candidate(), + "handoff": {"transferable": True, "reason": None}, + "checkpoint": { + "last_sequence": 4, + "continuation_token": "token-4", + }, + "validation": [ + { + "name": "just test", + "outcome": "passed", + "candidate_sha": SHA_B, + "observed_at": "2026-07-25T12:00:00Z", + } + ], + "reviews": [ + { + "name": "review-code-change", + "outcome": "passed", + "candidate_sha": SHA_B, + "observed_at": "2026-07-25T12:10:00Z", + } + ], + "feedback": { + "unresolved_material_count": 0, + "candidate_sha": SHA_B, + "observed_at": "2026-07-25T12:11:00Z", + }, + "authority_used": [ + "repository.candidate.create", + "repository.candidate.push", + "pull_request.create", + ], + "unresolved_obligations": [], + "blocking_reason": None, + "next_action": "Caller may accept the ready PR", + } + + +def checkpoint_request(phase: str = "pre_external_mutation") -> dict[str, object]: + published_candidate = candidate() + published_candidate.pop("publication") + return { + "schema": "agent-scripts.implement-ticket/checkpoint-request/v1", + "capability": "agent-scripts.implement-ticket/delegated-execution/v1", + "invocation_id": "run-123", + "continuation_token": "token-1", + "sequence": 2, + "phase": phase, + "action": "repository.candidate.push", + "ticket_observation": "sha256:ticket", + "candidate": published_candidate, + "proposed_effect": "Publish the exact candidate", + } + + +def checkpoint_response() -> dict[str, object]: + return { + "schema": "agent-scripts.implement-ticket/checkpoint-response/v1", + "invocation_id": "run-123", + "request_sequence": 2, + "prior_continuation_token": "token-1", + "continuation_token": "token-2", + "decision": "allow", + "reason": None, + "acknowledged_candidate_sha": None, + } + + +class DelegatedExecutionContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.validator = load_validator() + + def test_all_contract_files_are_valid_json_or_documented_markdown(self) -> None: + for path in CONTRACT_ROOT.glob("*.schema.json"): + self.assertIsInstance(json.loads(path.read_text()), dict) + manifest = json.loads((CONTRACT_ROOT / "capability.json").read_text()) + self.assertEqual([], self.validator.validate("capability", manifest)) + self.assertIn( + "agent-scripts.implement-ticket/delegated-execution/v1", + (CONTRACT_ROOT / "CONTRACT.md").read_text(), + ) + + def test_valid_invocation_and_result_match(self) -> None: + source = invocation() + value = result() + value["validation"].append( + { + "name": "just lint", + "outcome": "passed", + "candidate_sha": SHA_B, + "observed_at": "2026-07-25T12:01:00Z", + } + ) + self.assertEqual([], self.validator.validate("invocation", source)) + self.assertEqual( + [], + self.validator.validate_result_for_invocation(source, value), + ) + + def test_unknown_invocation_field_fails_closed(self) -> None: + value = invocation() + value["coordinator"] = "atelier" + self.assertIn( + "$.coordinator: unknown property", + self.validator.validate("invocation", value), + ) + + def test_desired_outcome_must_be_accepted(self) -> None: + value = invocation() + value["accepted_terminal_states"] = ["blocked", "requires_epic"] + self.assertIn( + "$.desired_outcome: must appear in accepted_terminal_states", + self.validator.validate("invocation", value), + ) + + def test_invocation_carries_checkpoint_resume_position(self) -> None: + value = invocation() + value["checkpoint"].pop("last_sequence") + self.assertIn( + "$.checkpoint: missing required property 'last_sequence'", + self.validator.validate("invocation", value), + ) + + def test_candidate_publication_requires_exact_acknowledgement(self) -> None: + request = checkpoint_request("candidate_published") + response = checkpoint_response() + response["acknowledged_candidate_sha"] = SHA_B + self.assertEqual( + [], + self.validator.validate_checkpoint_exchange(request, response), + ) + response["acknowledged_candidate_sha"] = SHA_A + self.assertIn( + "$.acknowledged_candidate_sha: does not match published candidate", + self.validator.validate_checkpoint_exchange(request, response), + ) + + def test_checkpoint_identity_and_token_must_match(self) -> None: + request = checkpoint_request() + response = checkpoint_response() + response["prior_continuation_token"] = "wrong" + self.assertIn( + "$.prior_continuation_token: does not match request continuation_token", + self.validator.validate_checkpoint_exchange(request, response), + ) + + def test_checkpoint_progress_rejects_replay(self) -> None: + request = checkpoint_request() + response = checkpoint_response() + self.assertEqual( + [], + self.validator.validate_checkpoint_progress( + 1, + "token-1", + request, + response, + ), + ) + self.assertTrue( + self.validator.validate_checkpoint_progress( + 2, + "token-2", + request, + response, + ) + ) + + def test_allow_rotates_token_and_deny_explains_reason(self) -> None: + response = checkpoint_response() + response["continuation_token"] = "token-1" + self.assertIn( + "$.continuation_token: allow must rotate the token", + self.validator.validate("checkpoint-response", response), + ) + response["decision"] = "deny" + response["continuation_token"] = "token-2" + errors = self.validator.validate("checkpoint-response", response) + self.assertIn("$.reason: deny requires a reason", errors) + self.assertIn( + "$.continuation_token: deny must preserve the prior token", + errors, + ) + response["reason"] = "Current authority changed" + response["continuation_token"] = response["prior_continuation_token"] + self.assertEqual([], self.validator.validate("checkpoint-response", response)) + + def test_local_blocked_state_is_explicitly_not_transferable(self) -> None: + value = result() + value.update( + { + "terminal_state": "blocked", + "implementation_state": "local", + "candidate": None, + "handoff": { + "transferable": False, + "reason": "Candidate push was not authorized", + }, + "blocking_reason": "Candidate push was not authorized", + } + ) + value["authority_used"] = ["repository.candidate.create"] + self.assertEqual([], self.validator.validate("result", value)) + + def test_published_blocked_state_preserves_transferable_candidate(self) -> None: + value = result() + value["terminal_state"] = "blocked" + value["blocking_reason"] = "Coordinator unavailable after publication" + value["candidate"]["publication"]["pull_requests"] = [] + self.assertEqual([], self.validator.validate("result", value)) + + def test_ready_pr_rejects_stack_or_local_only_candidate(self) -> None: + value = result() + value["candidate"] = candidate("stack") + self.assertIn( + "$.candidate.publication: ready_pr requires one ordinary PR", + self.validator.validate("result", value), + ) + value = result() + value["implementation_state"] = "local" + self.assertTrue(self.validator.validate("result", value)) + + def test_result_cannot_exceed_invocation_authority(self) -> None: + value = result() + value["authority_used"].append("pull_request.merge") + self.assertIn( + "$.authority_used: exceeds invocation: pull_request.merge", + self.validator.validate_result_for_invocation(invocation(), value), + ) + + def test_result_identity_must_match_invocation(self) -> None: + value = copy.deepcopy(result()) + value["ticket"]["observation"] = "sha256:changed" + self.assertIn( + "$.ticket: does not match invocation", + self.validator.validate_result_for_invocation(invocation(), value), + ) + + def test_terminal_checkpoint_must_match_caller_ledger_tail(self) -> None: + source = invocation() + source["validation"] = ["just test"] + value = result() + self.assertEqual( + [], + self.validator.validate_result_checkpoint_state( + source, + value, + 4, + "token-4", + ), + ) + value["checkpoint"] = { + "last_sequence": 0, + "continuation_token": "token-0", + } + errors = self.validator.validate_result_checkpoint_state( + source, + value, + 4, + "token-4", + ) + self.assertIn( + "$.checkpoint.last_sequence: does not match caller ledger", + errors, + ) + self.assertIn( + "$.checkpoint.continuation_token: does not match caller ledger", + errors, + ) + + def test_candidate_must_match_invocation_repository(self) -> None: + value = result() + value["candidate"]["repository"] = "github:other/project" + errors = self.validator.validate_result_for_invocation(invocation(), value) + self.assertIn( + "$.candidate.repository: does not match invocation", + errors, + ) + + def test_ready_pr_base_ref_must_match_invocation(self) -> None: + source = invocation() + source["validation"] = ["just test"] + value = result() + value["candidate"]["publication"]["pull_requests"][0]["base_ref"] = ( + "refs/heads/unrelated" + ) + self.assertIn( + "$.candidate.publication: first PR base_ref does not match invocation", + self.validator.validate_result_for_invocation(source, value), + ) + + def test_ready_pr_head_ref_must_match_candidate(self) -> None: + value = result() + value["candidate"]["publication"]["pull_requests"][0]["head_ref"] = ( + "refs/heads/unrelated" + ) + self.assertIn( + "$.candidate.publication: ready_pr head ref must match candidate", + self.validator.validate("result", value), + ) + + def test_delivery_requires_all_requested_current_gates(self) -> None: + errors = self.validator.validate_result_for_invocation( + invocation(), + result(), + ) + self.assertIn("$.validation: missing required command just lint", errors) + value = result() + value["validation"].append( + { + "name": "just lint", + "outcome": "failed", + "candidate_sha": SHA_B, + "observed_at": "2026-07-25T12:01:00Z", + } + ) + self.assertIn( + "$.validation: just lint did not pass at exact candidate", + self.validator.validate_result_for_invocation(invocation(), value), + ) + + def test_published_result_requires_publication_authority(self) -> None: + value = result() + value["authority_used"] = [] + errors = self.validator.validate("result", value) + self.assertIn( + "$.authority_used: published implementation requires " + "repository.candidate.push", + errors, + ) + self.assertIn( + "$.authority_used: pull request publication requires create or update", + errors, + ) + + def test_duplicate_observation_names_fail_closed(self) -> None: + value = result() + contradictory = copy.deepcopy(value["validation"][0]) + contradictory["outcome"] = "failed" + value["validation"].append(contradictory) + self.assertIn( + "$.validation: duplicate observation names just test", + self.validator.validate("result", value), + ) + + def test_ready_prs_require_unique_ordered_topology(self) -> None: + value = result() + value["terminal_state"] = "ready_prs" + value["candidate"]["publication"]["kind"] = "stack" + duplicate = copy.deepcopy(value["candidate"]["publication"]["pull_requests"][0]) + value["candidate"]["publication"]["pull_requests"].append(duplicate) + errors = self.validator.validate("result", value) + self.assertIn( + "$.candidate.publication: ready_prs requires unique pull request id values", + errors, + ) + self.assertIn( + "$.candidate.publication: ready_prs requires unique pull request head_sha " + "values", + errors, + ) + value["candidate"]["publication"]["pull_requests"][0]["head_sha"] = SHA_C + value["candidate"]["publication"]["pull_requests"][1].update( + { + "id": "789", + "url": "https://github.com/example/project/pull/789", + "base_ref": "refs/heads/stack-one", + "base_sha": SHA_A, + "head_ref": "refs/heads/stack-two", + "head_sha": SHA_B, + } + ) + self.assertIn( + "$.candidate.publication: ready_prs base chain is invalid", + self.validator.validate("result", value), + ) + value["candidate"]["publication"]["pull_requests"][1]["base_sha"] = SHA_C + value["candidate"]["publication"]["pull_requests"][0]["head_ref"] = ( + "refs/heads/stack-one" + ) + self.assertNotIn( + "$.candidate.publication: ready_prs base chain is invalid", + self.validator.validate("result", value), + ) + value["candidate"]["publication"]["pull_requests"][1]["base_ref"] = ( + "refs/heads/unrelated" + ) + self.assertIn( + "$.candidate.publication: ready_prs ref chain is invalid", + self.validator.validate("result", value), + ) + value["candidate"]["publication"]["pull_requests"][1]["base_ref"] = ( + "refs/heads/stack-one" + ) + value["candidate"]["publication"]["pull_requests"][1]["head_ref"] = ( + "refs/heads/unrelated" + ) + self.assertIn( + "$.candidate.publication: final PR head ref must match candidate", + self.validator.validate("result", value), + ) + + def test_delivery_requires_review_and_zero_feedback(self) -> None: + source = invocation() + source["validation"] = ["just test"] + value = result() + value["reviews"] = [] + value["feedback"]["unresolved_material_count"] = 1 + errors = self.validator.validate_result_for_invocation(source, value) + self.assertIn( + "$.reviews: independent review did not pass at exact candidate", + errors, + ) + self.assertTrue(any(error.startswith("$.feedback:") for error in errors)) + + def test_requires_epic_forbids_used_authority(self) -> None: + value = result() + value.update( + { + "terminal_state": "requires_epic", + "implementation_state": "none", + "candidate": None, + "handoff": { + "transferable": False, + "reason": "Whole epic requires implement-epic", + }, + "feedback": None, + "authority_used": [], + } + ) + self.assertEqual([], self.validator.validate("result", value)) + value["authority_used"] = ["repository.candidate.create"] + self.assertIn( + "$.authority_used: no implementation requires no actions", + self.validator.validate("result", value), + ) + + def test_push_checkpoint_requires_exact_candidate(self) -> None: + value = checkpoint_request() + value["candidate"] = None + self.assertIn( + "$.candidate: action requires exact candidate", + self.validator.validate("checkpoint-request", value), + ) + + def test_skill_loads_and_enforces_delegated_contract(self) -> None: + skill = (SKILL_ROOT / "SKILL.md").read_text() + result_reference = (SKILL_ROOT / "references/cleanup-and-result.md").read_text() + self.assertIn("delegated execution contract", skill) + self.assertIn("before every action", skill) + self.assertIn("invocation's `last_sequence`", skill) + self.assertIn("candidate_published", skill) + self.assertIn("versioned delegated-execution contract", result_reference) + + +if __name__ == "__main__": + unittest.main()