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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agent/commands/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Input: project repository and its architecture, README and backlog.
3. Merge [the project contract](../../templates/AGENTS.md) into the target AGENTS.md; do not copy
the pattern library's contributor contract. Adapt `.agent/config.example.toml` into
`.agent/config.toml` with actual architecture/backlog paths, verification argv and merge authority.
Identify and link the project's existing resource-safety, verification, review/merge and
publication/privacy policies where applicable. Resolve conflicting instructions before dispatch;
missing policy decisions remain named setup gaps. Keep these rules in their project sources,
not copied into this protocol or a second policy document.
4. Preserve the project's README; add a short workflow pointer. Ensure `.agent-runs/` and HANDOFF.md
are ignored. Keep durable acceptance and decisions in tracked records or PRs.
5. Make one small item executable: approved target references, outcome, boundaries, dependencies,
Expand Down
6 changes: 4 additions & 2 deletions .agent/commands/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ Keep it at most 50 lines / 500 words. Validate SHA and links. Do not copy archit
full logs, secrets or the conversation transcript. Durable decisions/evidence belong in tracked files
or PRs. The local ignored handoff is advisory and may be reconstructed if lost.

Report its location and remaining gates. End the planner session after reconciliation; do not claim
to clear your own conversation. A fresh planner should recover using only repository/PR records.
Report its location and remaining gates. An interrupted session may hand off an open round; its
successor must recover that round before starting another. Only reconciliation closes the round.
Do not claim to clear your own conversation. A fresh planner should recover using only
repository/PR records.
3 changes: 2 additions & 1 deletion .agent/commands/reconcile.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ reconciliation PR. Use the actual default branch and handoff path. No report-pat
required. The CLI checks terminal assignments, merge ancestry and the handoff SHA. It checks local
evidence files at final main; it records URLs without fetching them. Verify the reference actually
shows this round's reconciliation into tracked state and that all required gates passed before
closing. Only then end the planner session and start a fresh round. Cancelled rounds also reconcile.
closing. Only then start a fresh round. An interrupted session may hand off this round while it
remains open; cancelled rounds also reconcile.
4 changes: 4 additions & 0 deletions .agent/commands/takeoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ remote knowledge or unavailable PR access. Do not mutate a dirty checkout during
Read the optional handoff. Compare its Main-SHA with known main: equal=current, ancestor=stale,
otherwise invalid. Reconstruct missing/stale information from Git, backlog and PR evidence. Inspect
any open round with `python3 scripts/round.py status ROUND`; recover it before planning another.
For an interrupted round, compare saved results with actual PR heads and merges and inspect
surviving workers and resource ownership. Report the outstanding work, unavailable gates with their
affected head, and the recovery owner and next action; recovery itself follows the protocol after
takeoff. Do not duplicate a worker or replay a merge because local state is stale.

Output purpose, accepted outcomes, active work/conflicts, decisions owed, next authoritative reads
and handoff freshness. Stop before selecting work. Conversation history is not required input.
14 changes: 11 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,19 @@ operating envelope and the actual behavior delta. A failed required gate keeps t
Do not silently edit the target to match implementation.

The CLI requires terminal outcomes, supplied merges reachable from final main, a reconciliation
evidence reference, and a bounded handoff naming the same full SHA. `--reconciliation` accepts an
evidence reference, and a bounded handoff naming the same full SHA, a valid
`Generated-UTC: YYYY-MM-DDTHH:MM:SSZ` timestamp, and nonempty `Round delta`, `Open work` and
`Next action` sections (`None` is valid for open work; template placeholders are not content).
These are structural checks, not a judgment of the prose. `--reconciliation` accepts an
existing tracked file path or an HTTP(S) evidence URL, such as the reconciliation PR. Local files
must be nonempty and committed at final main. URLs are recorded without network access; the reviewer
verifies that the reference is durable, accessible, and records the round's actual reconciliation
into tracked state. The caller also supplies final verification evidence. These checks do not judge
evidence truth, required-gate completeness, resource release, or whether the ref really is remote main.

`init` refuses another round while any local round remains open. Cancelling work does not waive
reconciliation. Only after closure should the planner session end and a new one start. No automated
supervisor is required; a human or agent can drive each phase.
reconciliation. A new round requires closure; an interrupted planner session may end while its round
remains open. No automated supervisor is required; a human or agent can drive each phase.

## Recovery and limits

Expand All @@ -90,6 +93,11 @@ keep it at most 50 lines / 500 words with full Main-SHA, UTC timestamp and next
must also live in PRs or tracked files so lost local records are recoverable. If the ledger is lost,
reconstruct and inspect active workers before initiating anything; the CLI cannot detect remote orphaned
work. Do not blindly replay a command that may have changed state.
On resumption, recover the existing round and compare its results with current PR heads and actual
merges. Inspect surviving workers and mutable-resource ownership before dispatching replacements.
An unavailable required gate stays pending: record its affected head, recovery owner and next action
in the existing result/PR evidence. Resume the owed work without changing delivered packets or
repeating completed external actions.

A fresh planner without prior chats must be able to name the remaining work, assigned branch/head,
required evidence and next permitted action. The tests exercise ledger behavior in temporary Git
Expand Down
24 changes: 24 additions & 0 deletions examples/one-round.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,27 @@ A new round is refused until this succeeds. Stop the planner session. A fresh pl
the outcome, remaining runtime work and next permitted action from the repository/PRs alone.

This is enough for a human or agent to drive the loop; there is no supervisor process to install.

## Interrupted round with CI unavailable

Suppose a later round has two assignments. A1 is merged; A2 has a PR but its required CI is queued
because runners are unavailable. Verify A1's actual merge and record its merged outcome if it has
not already been recorded. For A2, submit a candidate result with the gate marked `unavailable`;
retain earlier results unchanged. Its evidence names the affected head, the coordinator as recovery owner, and the
next action: restore runner availability and verify that head's required checks. A `blocked`
result with the same details is also appropriate if execution cannot continue.

Write the bounded handoff and end the interrupted session with the round still open. Do not cancel
A2 just to close the round. The replacement planner:

1. Reads the open ledger, original packets, saved results and actual PR/merge state.
2. Checks whether A2's worker survives and who owns its mutable resources; reuses it or confirms it
stopped and releases ownership before assigning a replacement the unchanged packet.
3. Records an already-completed merge missing from the ledger only after verifying it; never merges
A1 again or re-executes completed work solely because the session changed.
4. Restores access through the project's authorized operator, runs only outstanding or invalidated
checks, and imports a new A2 result with actual evidence. A changed PR head requires fresh review.
5. Reviews and merges A2, verifies combined state, reconciles both outcomes and closes the same round.

Runner restoration is project policy, not a CLI capability. No additional round, runtime state or
separate status document is needed for this recovery.
23 changes: 23 additions & 0 deletions scripts/round.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlsplit

Expand Down Expand Up @@ -242,6 +243,28 @@ def close(repo, root, manifest, args):
"Handoff is too long",
)
require(text(args.evidence), "Closure needs final verification evidence")
timestamp = re.search(
r"^Generated-UTC: ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)$",
handoff,
re.MULTILINE,
)
require(timestamp is not None, "Handoff needs Generated-UTC: YYYY-MM-DDTHH:MM:SSZ")
try:
datetime.strptime(timestamp[1], "%Y-%m-%dT%H:%M:%SZ")
except ValueError:
raise ValueError("Handoff Generated-UTC must be a valid UTC timestamp") from None
for heading in ("Round delta", "Open work", "Next action"):
section = re.search(
rf"^## {heading}[ \t]*\n(.*?)(?=^## |\Z)",
handoff,
re.MULTILINE | re.DOTALL,
)
body = re.sub(r"<!--.*?-->", "", section[1], flags=re.DOTALL) if section else ""
# Template placeholders such as <One exact action> are not content; autolinks are.
body = re.sub(
r"^[ \t]*<(?![^>\n]*://)[^<>\n]*>[ \t]*$", "", body, flags=re.MULTILINE
)
require(re.search(r"\w", body), f"Handoff needs a nonempty '{heading}' section")
manifest.update(
state="closed",
final_main_sha=final,
Expand Down
9 changes: 8 additions & 1 deletion templates/assignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@
- In scope / non-goals: <boundaries>
- Expected files and editable records: <ownership; name shared seams>
- Resources / artifacts: <permitted services, exclusive ownership, provisioned data or none>
- Acceptance and verification: <exact commands, environment, expected results and owner>
- Acceptance: <observable behavior and expected results>
- Connections / scale: <production callers, data boundaries and relevant limits, or N/A>
- Publication authority and PR evidence: <scope, required reports and residuals>

List only applicable gates (local, integration, live or final combined state). An unavailable required
gate remains pending; name its recovery owner and next action in the existing result/PR evidence.

| Gate | Command / environment | Owner | Required evidence |
| --- | --- | --- | --- |
| <applicable gate> | <exact invocation and safe environment> | <executor or coordinator> | <revision, results including skips, and durable reference> |

The round CLI adds identity, base and branch to the final packet. The packet must be sufficient in
an isolated checkout without access to planner-local records. No target changes or self-merging.
105 changes: 100 additions & 5 deletions tests/test_round.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,101 @@ def blocked_result(self, reason="Required fixture is missing"):
path.write_text(json.dumps(data))
return path

def handoff(self, sha=None):
return (
"# Handoff\nGenerated-UTC: 2026-10-09T10:00:00Z\n"
f"Main-SHA: {sha or self.base}\n\n"
"## Round delta\nTASK-1 reconciled in the backlog.\n\n"
"## Open work\nNone.\n\n"
"## Next action\nRead the backlog and select executable work.\n"
)

def test_close_rejects_incomplete_handoff_without_closing_round(self):
self.start()
self.cli(
"resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped"
)
valid = self.handoff()
invalid = [
(f"Main-SHA: {self.base}\n", "Generated-UTC"),
(valid.replace("Generated-UTC: 2026-10-09T10:00:00Z\n", ""), "Generated-UTC"),
(valid.replace("2026-10-09", "2026-02-30"), "Generated-UTC"),
(valid.replace("10:00:00Z", "10:00:00+00:00"), "Generated-UTC"),
]
for heading, body in (
("Round delta", "TASK-1 reconciled in the backlog."),
("Open work", "None."),
("Next action", "Read the backlog and select executable work."),
):
invalid.extend([
(valid.replace(f"## {heading}", f"## Other {heading}"), heading),
(valid.replace(body, ""), heading),
(valid.replace(body, "<!-- Fill this in -->"), heading),
(valid.replace(body, "<Fill this in>"), heading),
])
ledger = self.repo / ".agent-runs/R1/round.json"
original = ledger.read_text()
for handoff, diagnostic in invalid:
ledger.write_text(original)
with self.subTest(handoff=handoff):
(self.repo / "HANDOFF.md").write_text(handoff)
rejected = self.cli(
"close", "R1", "--main", "main", "--reconciliation", "README.md",
"--handoff", "HANDOFF.md", "--evidence", "Final checks passed", ok=False,
)
self.assertIn(diagnostic, rejected.stderr)
self.assertEqual(self.manifest()["state"], "open")

def test_close_rejects_unfilled_handoff_template_sections(self):
self.start()
self.cli(
"resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped"
)
template = (Path(__file__).resolve().parents[1] / "templates/handoff.md").read_text()
(self.repo / "HANDOFF.md").write_text(
template.replace("<timestamp>", "2026-10-09T10:00:00Z").replace(
"<full final main SHA>", self.base
)
)
rejected = self.cli(
"close", "R1", "--main", "main", "--reconciliation", "README.md",
"--handoff", "HANDOFF.md", "--evidence", "Final checks passed", ok=False,
)
self.assertIn("Round delta", rejected.stderr)
self.assertEqual(self.manifest()["state"], "open")

def test_close_accepts_autolink_as_section_content(self):
self.start()
self.cli(
"resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped"
)
(self.repo / "HANDOFF.md").write_text(
self.handoff().replace(
"Read the backlog and select executable work.",
"<https://example.invalid/project/issues/2>",
)
)
self.cli(
"close", "R1", "--main", "main", "--reconciliation", "README.md",
"--handoff", "HANDOFF.md", "--evidence", "Final checks passed",
)
self.assertEqual(self.manifest()["state"], "closed")

def test_close_preserves_handoff_size_limits(self):
self.start()
self.cli(
"resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped"
)
for extra in ("\n" * 51, "word " * 501):
with self.subTest(extra=extra):
(self.repo / "HANDOFF.md").write_text(self.handoff() + extra)
rejected = self.cli(
"close", "R1", "--main", "main", "--reconciliation", "README.md",
"--handoff", "HANDOFF.md", "--evidence", "Final checks passed", ok=False,
)
self.assertIn("too long", rejected.stderr)
self.assertEqual(self.manifest()["state"], "open")

def test_blocked_result_needs_no_pr_and_keeps_round_open(self):
self.start()
self.cli("record", "R1", "--result", str(self.blocked_result()))
Expand Down Expand Up @@ -358,7 +453,7 @@ def test_cancel_reconcile_then_next_round(self):
self.git("commit", "-qm", "reconcile cancelled assignment")
final_sha = self.git("rev-parse", "HEAD")
(self.repo / "HANDOFF.md").write_text(
f"# Handoff\nMain-SHA: {final_sha}\nNext: plan TASK-1.\n"
self.handoff(final_sha)
)
self.cli(
"close",
Expand Down Expand Up @@ -392,7 +487,7 @@ def test_close_accepts_reconciliation_pr_without_separate_report(self):
self.git("add", "backlog.md")
self.git("commit", "-qm", "reconcile task status in existing project state")
final_sha = self.git("rev-parse", "HEAD")
(self.repo / "HANDOFF.md").write_text(f"Main-SHA: {final_sha}\n")
(self.repo / "HANDOFF.md").write_text(self.handoff(final_sha))
reference = "https://example.invalid/project/pull/2"
self.cli(
"close",
Expand All @@ -416,7 +511,7 @@ def test_close_rejects_malformed_reconciliation_url(self):
self.cli(
"resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped"
)
(self.repo / "HANDOFF.md").write_text(f"Main-SHA: {self.base}\n")
(self.repo / "HANDOFF.md").write_text(self.handoff())
for reference in (
"https:///missing-host",
"https://",
Expand All @@ -443,7 +538,7 @@ def test_local_evidence_requires_committed_file_and_current_handoff(self):
"resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped"
)
(self.repo / "reconcile.md").write_text("Uncommitted evidence\n")
(self.repo / "HANDOFF.md").write_text(f"Main-SHA: {self.base}\n")
(self.repo / "HANDOFF.md").write_text(self.handoff())
args = (
"close",
"R1",
Expand Down Expand Up @@ -479,7 +574,7 @@ def test_merged_round_closes_and_terminal_result_cannot_change(self):
"Independent review and actual merge verified",
)
self.cli("record", "R1", "--result", str(result), ok=False)
(self.repo / "HANDOFF.md").write_text(f"Main-SHA: {self.base}\n")
(self.repo / "HANDOFF.md").write_text(self.handoff())
self.cli(
"close",
"R1",
Expand Down
Loading