feat: add Canonical Thread Handoff protocol (Closes #505)

Define CTH comment types, base template, parser/validator helpers, protocol
documentation with examples, and workflow/runbook/prompt updates so agents
discover and post authoritative thread handoffs before acting.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-08 03:44:58 -04:00
co-authored by Claude Opus 4.8
parent 9ada4762c5
commit 25cf323d14
10 changed files with 572 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
"""Canonical Thread Handoff (CTH) protocol for issue and PR comments (#505).
A CTH comment is the authoritative workflow handoff in a Gitea issue or PR
thread. It records current state, decisions, blockers, proof, and the exact
next prompt/action for the next LLM or person.
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any
MARKER = "<!-- cth:v1 -->"
CTH_TERM = "Canonical Thread Handoff"
CTH_ABBREV = "CTH"
CTH_TYPES = frozenset({
"State Handoff",
"Controller Decision",
"Author Handoff",
"Reviewer Handoff",
"Merger Handoff",
"Supersession Notice",
"Blocker",
})
_REQUIRED_BASE_FIELDS = (
"status",
"next owner",
"current blocker",
"decision",
"proof",
"next action",
"ready-to-paste prompt",
)
_HEADING_RE = re.compile(
r"^##\s+CTH:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_FIELD_RE = re.compile(
r"^([A-Za-z][A-Za-z0-9 /-]*):\s*(.+?)\s*$",
re.MULTILINE,
)
def format_cth_body(
*,
cth_type: str,
status: str,
next_owner: str,
current_blocker: str = "none",
decision: str = "none",
proof: str = "none",
next_action: str = "none",
ready_to_paste_prompt: str = "none",
extra_fields: dict[str, str] | None = None,
) -> str:
"""Render a canonical CTH comment body."""
normalized_type = (cth_type or "").strip()
if normalized_type not in CTH_TYPES:
raise ValueError(
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
)
lines = [
MARKER,
f"## CTH: {normalized_type}",
"",
f"Status: {(status or 'unknown').strip() or 'unknown'}",
f"Next owner: {(next_owner or 'unknown').strip() or 'unknown'}",
f"Current blocker: {(current_blocker or 'none').strip() or 'none'}",
f"Decision: {(decision or 'none').strip() or 'none'}",
f"Proof: {(proof or 'none').strip() or 'none'}",
f"Next action: {(next_action or 'none').strip() or 'none'}",
f"Ready-to-paste prompt: {(ready_to_paste_prompt or 'none').strip() or 'none'}",
]
for key, value in (extra_fields or {}).items():
label = (key or "").strip()
if not label:
continue
lines.append(f"{label}: {(value or 'none').strip() or 'none'}")
return "\n".join(lines)
def parse_cth_comment(body: str) -> dict[str, Any] | None:
"""Parse one CTH comment body, or None when not a CTH comment."""
text = body or ""
if MARKER not in text and not _HEADING_RE.search(text):
return None
heading = _HEADING_RE.search(text)
if not heading:
return None
cth_type = heading.group(1).strip()
fields: dict[str, str] = {}
for match in _FIELD_RE.finditer(text):
key = match.group(1).strip().lower()
if key.startswith("cth"):
continue
fields[key] = match.group(2).strip()
return {
"cth_type": cth_type,
"fields": fields,
"raw_body": text,
}
def assess_cth_comment(body: str) -> dict[str, Any]:
"""Validate a CTH comment has required base fields and a known type."""
parsed = parse_cth_comment(body)
reasons: list[str] = []
if not parsed:
return {
"valid": False,
"block": True,
"reasons": ["comment is not a Canonical Thread Handoff (CTH)"],
"parsed": None,
}
cth_type = parsed.get("cth_type") or ""
if cth_type not in CTH_TYPES:
reasons.append(
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
)
fields = parsed.get("fields") or {}
missing = [name for name in _REQUIRED_BASE_FIELDS if not (fields.get(name) or "").strip()]
if missing:
reasons.append(
"CTH missing required fields: " + ", ".join(missing)
)
vague_prompt = (fields.get("ready-to-paste prompt") or "").strip().lower()
if vague_prompt in {"", "none", "tbd", "n/a", "todo"}:
reasons.append("ready-to-paste prompt must be concrete and paste-ready")
return {
"valid": not reasons,
"block": bool(reasons),
"reasons": reasons,
"parsed": parsed,
"cth_type": cth_type,
}
def find_latest_cth(comments: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return the newest valid CTH comment from a Gitea comment list."""
latest: dict[str, Any] | None = None
latest_ts: datetime | None = None
for comment in comments or []:
body = comment.get("body") or ""
parsed = parse_cth_comment(body)
if not parsed:
continue
created = _parse_timestamp(comment.get("created_at"))
if latest is None or (created and (latest_ts is None or created >= latest_ts)):
latest = {
"comment_id": comment.get("id"),
"created_at": comment.get("created_at"),
"author": (comment.get("user") or {}).get("login"),
**parsed,
}
latest_ts = created
return latest
def assess_cth_supersedes_non_cth(
*,
latest_cth: dict[str, Any] | None,
narrative_claim: str,
) -> dict[str, Any]:
"""Fail closed when narrative relies on stale non-CTH state despite newer CTH."""
if not latest_cth:
return {"block": False, "reasons": []}
text = (narrative_claim or "").strip()
if not text:
return {"block": False, "reasons": []}
if parse_cth_comment(text):
return {"block": False, "reasons": []}
return {
"block": True,
"reasons": [
"workflow narrative must treat the latest CTH comment as authoritative; "
f"found newer CTH type '{latest_cth.get('cth_type')}' "
f"(comment #{latest_cth.get('comment_id')})"
],
}
def _parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
EXAMPLE_SCENARIOS = (
"pr_approved_ready_for_merger",
"pr_request_changes_to_author",
"duplicate_superseded_pr_closure",
"blocked_dirty_root_worktree",
"stale_head_requires_fresh_review",
"issue_implementation_handoff",
)
+142
View File
@@ -0,0 +1,142 @@
# Canonical Thread Handoff (CTH)
**CTH** = **Canonical Thread Handoff**
A CTH comment is the authoritative workflow handoff in a Gitea issue or PR
thread. It records current state, decisions, blockers, proof, and the exact
next prompt/action for the next LLM or person.
CTH comments complement — but do not replace — formal Gitea review state.
A CTH may summarize an APPROVE or REQUEST_CHANGES decision, yet merge gates
still require the live Gitea review verdict.
## CTH comment types
- `CTH: State Handoff`
- `CTH: Controller Decision`
- `CTH: Author Handoff`
- `CTH: Reviewer Handoff`
- `CTH: Merger Handoff`
- `CTH: Supersession Notice`
- `CTH: Blocker`
## Required base template
```md
## CTH: <Type>
Status:
Next owner:
Current blocker:
Decision:
Proof:
Next action:
Ready-to-paste prompt:
```
Extended fields may map to canonical issue/PR state templates from #495 when
that layer is available. Until then, keep the base fields complete.
## Discovery and posting rules
Before acting in an issue or PR thread:
1. **Find the latest CTH comment** before doing work.
2. Treat the **latest valid CTH** as the current handoff state.
3. **Post a new CTH** when you finish, block, skip, supersede, request
changes, approve, or hand off.
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
5. Casual discussion comments do not need to be CTH comments.
## Examples
### PR approved and ready for merger
```md
## CTH: Reviewer Handoff
Status: approved_at_current_head
Next owner: merger
Current blocker: none
Decision: APPROVE recorded at head abc123...
Proof: gitea_submit_pr_review performed; visible verdict APPROVE
Next action: eligible merger merges PR #N with pinned expected_head_sha
Ready-to-paste prompt: Merge PR #N for issue #M if live head still abc123... and merge gates pass.
```
### PR request-changes back to author
```md
## CTH: Reviewer Handoff
Status: request_changes_at_current_head
Next owner: author
Current blocker: unresolved findings in validation report
Decision: REQUEST_CHANGES at head def456...
Proof: gitea_submit_pr_review performed; blocking review visible
Next action: author fixes findings and pushes; reviewer re-validates fresh head
Ready-to-paste prompt: Fix PR #N review findings, push to feat/issue-M-..., post Author Handoff CTH.
```
### Duplicate / superseded PR closure
```md
## CTH: Supersession Notice
Status: superseded
Next owner: controller
Current blocker: duplicate branch/PR work
Decision: close PR #N; continue on PR #M
Proof: duplicate gate linked open PR #M for issue #K
Next action: controller closes superseded PR and records canonical state
Ready-to-paste prompt: Close superseded PR #N; confirm PR #M remains canonical for issue #K.
```
### Blocked workflow due to dirty root/worktree
```md
## CTH: Blocker
Status: blocked_preflight
Next owner: operator
Current blocker: dirty control checkout / branches worktree mismatch
Decision: stop before mutation
Proof: verify_preflight_purity failed; workspace diagnostics attached
Next action: repair worktree or relaunch MCP from branches/ worktree
Ready-to-paste prompt: Repair dirty workspace, relaunch MCP from branches/review-prN, retry review.
```
### Stale head requiring fresh review
```md
## CTH: Reviewer Handoff
Status: stale_head
Next owner: reviewer
Current blocker: live head differs from pinned review head
Decision: prior approval not valid for current head
Proof: expected_head_sha mismatch at merge gate
Next action: re-run validation and post fresh review decision
Ready-to-paste prompt: Re-validate PR #N at live head, dry-run review gates, submit fresh verdict.
```
### Issue implementation handoff
```md
## CTH: Author Handoff
Status: implementation_complete_pending_review
Next owner: reviewer
Current blocker: none
Decision: PR #N ready for review at head fedcba...
Proof: tests passed in branches/issue-M-...; PR opened with Closes #M
Next action: reviewer acquires lease and validates PR #N
Ready-to-paste prompt: Review PR #N for issue #M; pin head fedcba... before mutations.
```
## Related
- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md)
- [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
- #495 — canonical next-action comment fields
- #496 — fail-closed validation for workflow-changing comments
+21
View File
@@ -728,6 +728,27 @@ Never imply full-suite success unless the full-suite command itself passed
(`full_suite_passed: true`). A report that hides a failed or skipped check (`full_suite_passed: true`). A report that hides a failed or skipped check
is worse than a failing report. is worse than a failing report.
## Canonical Thread Handoff (CTH)
**CTH** = **Canonical Thread Handoff** — the authoritative workflow handoff
comment in a Gitea issue or PR thread. See
[`canonical-thread-handoff.md`](canonical-thread-handoff.md) for types,
templates, and examples.
Before acting in an issue or PR thread:
1. **Find the latest CTH comment** before doing work.
2. Treat the **latest valid CTH** as the current handoff state.
3. **Post a new CTH** when you finish, block, skip, supersede, request
changes, approve, or hand off.
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
A CTH summarizes workflow state for the next session. Formal Gitea review
verdicts remain authoritative for merge gates — a CTH is not merge approval
by itself.
Template: [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
## Controller Handoff (required, every task) ## Controller Handoff (required, every task)
Every task — implementation, review, merge, triage, documentation, Every task — implementation, review, merge, triage, documentation,
@@ -0,0 +1,36 @@
# Template: Canonical Thread Handoff (CTH)
Copy, fill the fields, and post as an issue or PR comment.
```text
## CTH: <Type>
Status: <current workflow state>
Next owner: <author|reviewer|merger|controller|operator>
Current blocker: <none or exact blocker>
Decision: <what was decided this session>
Proof: <commands, SHAs, gate outputs, or explicit pending proof>
Next action: <one concrete step for the next actor>
Ready-to-paste prompt: <full prompt the next session should paste>
```
Allowed `<Type>` values:
- State Handoff
- Controller Decision
- Author Handoff
- Reviewer Handoff
- Merger Handoff
- Supersession Notice
- Blocker
Rules:
- Find the latest CTH comment in the thread before starting work.
- Treat the latest valid CTH as the current source of truth.
- Post a new CTH when you finish, block, skip, supersede, approve, request
changes, or hand off.
- A CTH summarizes workflow state; formal Gitea review verdicts remain
authoritative for merge gates.
Full protocol: `docs/canonical-thread-handoff.md`
@@ -10,6 +10,9 @@ Load the canonical workflow first:
Final report schema: `schemas/review-merge-final-report.md`. Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow): Rules (llm-project-workflow):
- Find the latest CTH comment on the PR/issue thread before starting work.
Post a new CTH: Merger Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
- Only an eligible, NON-author reviewer merges. If authenticated user == PR - Only an eligible, NON-author reviewer merges. If authenticated user == PR
author → STOP. author → STOP.
- Do not merge unless the PR is open, mergeable, and its checks/review pass. - Do not merge unless the PR is open, mergeable, and its checks/review pass.
@@ -35,6 +35,9 @@ Load the canonical workflow first:
Final report schema: `schemas/review-merge-final-report.md`. Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow): Rules (llm-project-workflow):
- Find the latest CTH comment on the PR/issue thread before starting work.
Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
- Review in a SEPARATE detached review worktree, never the author's folder. - Review in a SEPARATE detached review worktree, never the author's folder.
- Worktree safety (#233): before checkout, diff, validation, review, or merge, - Worktree safety (#233): before checkout, diff, validation, review, or merge,
report the starting worktree path and whether it was dirty. If unrelated report the starting worktree path and whether it was dirty. If unrelated
@@ -10,6 +10,9 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue) Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
Rules (llm-project-workflow): Rules (llm-project-workflow):
- Find the latest CTH comment on the issue/PR thread before starting work.
Post a new CTH: Author Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
- No repo changes without a tracking issue. If none exists, create one first; - No repo changes without a tracking issue. If none exists, create one first;
if it can't be created, stop. if it can't be created, stop.
- Work only in an isolated branch worktree under branches/. The main checkout - Work only in an isolated branch worktree under branches/. The main checkout
@@ -26,6 +26,12 @@ workflow rules exactly.
## 0. Load the canonical workflow first ## 0. Load the canonical workflow first
Before starting PR work, **find the latest CTH comment** on the PR or linked
issue thread. Treat that CTH as the current handoff state. Post a new
**CTH: Reviewer Handoff**, **CTH: Merger Handoff**, **CTH: Blocker**, or
**CTH: Supersession Notice** when you finish, block, skip, supersede,
approve, request changes, or hand off. See `docs/canonical-thread-handoff.md`.
Before starting PR work, check whether the project provides a canonical PR review/merge workflow through a project skill, runbook, or MCP helper. Before starting PR work, check whether the project provides a canonical PR review/merge workflow through a project skill, runbook, or MCP helper.
If available, load it first and report: If available, load it first and report:
@@ -26,6 +26,12 @@ This is an author/coder workflow. It is not a reviewer workflow.
## 0. Load the canonical workflow first ## 0. Load the canonical workflow first
Before starting issue work, **find the latest CTH comment** on the target
issue or linked PR thread. Treat that CTH as the current handoff state unless
a newer authoritative gate supersedes it. Post a new **CTH: Author Handoff**
(or `CTH: Blocker`) when you finish, block, or hand off.
See `docs/canonical-thread-handoff.md`.
Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper. Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report: If available, load it first and report:
+139
View File
@@ -0,0 +1,139 @@
"""Tests for Canonical Thread Handoff (CTH) protocol (#505)."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import canonical_thread_handoff as cth # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
PROTOCOL_DOC = REPO_ROOT / "docs" / "canonical-thread-handoff.md"
TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "canonical-thread-handoff.md"
REVIEW_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "review-merge-pr.md"
WORK_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "work-issue.md"
REVIEW_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "review-pr.md"
START_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "start-issue.md"
MERGE_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "merge-pr.md"
class TestCthModule(unittest.TestCase):
def test_defines_cth_term_and_types(self):
self.assertEqual(cth.CTH_ABBREV, "CTH")
self.assertIn("State Handoff", cth.CTH_TYPES)
self.assertIn("Reviewer Handoff", cth.CTH_TYPES)
self.assertIn("Blocker", cth.CTH_TYPES)
def test_format_and_parse_round_trip(self):
body = cth.format_cth_body(
cth_type="Author Handoff",
status="implementation_complete",
next_owner="reviewer",
current_blocker="none",
decision="PR opened",
proof="pytest passed",
next_action="review PR #9",
ready_to_paste_prompt="Review PR #9 for issue #5",
)
parsed = cth.parse_cth_comment(body)
self.assertIsNotNone(parsed)
self.assertEqual(parsed["cth_type"], "Author Handoff")
self.assertEqual(parsed["fields"]["status"], "implementation_complete")
self.assertEqual(parsed["fields"]["next owner"], "reviewer")
def test_valid_cth_passes_assessment(self):
body = cth.format_cth_body(
cth_type="Reviewer Handoff",
status="approved",
next_owner="merger",
current_blocker="none",
decision="APPROVE",
proof="review id 42",
next_action="merge if gates pass",
ready_to_paste_prompt="Merge PR #12 with pinned head abcdef0123456789abcdef0123456789abcdef01",
)
result = cth.assess_cth_comment(body)
self.assertFalse(result["block"])
self.assertTrue(result["valid"])
def test_missing_fields_fail_closed(self):
body = "## CTH: Blocker\n\nStatus: blocked\n"
result = cth.assess_cth_comment(body)
self.assertTrue(result["block"])
self.assertIn("missing required fields", result["reasons"][0])
def test_find_latest_cth_prefers_newer_comment(self):
older = cth.format_cth_body(
cth_type="State Handoff",
status="old",
next_owner="author",
current_blocker="none",
decision="old",
proof="old",
next_action="old",
ready_to_paste_prompt="old prompt text here",
)
newer = cth.format_cth_body(
cth_type="Reviewer Handoff",
status="new",
next_owner="merger",
current_blocker="none",
decision="approve",
proof="new",
next_action="merge",
ready_to_paste_prompt="merge PR #3 now with pinned head",
)
comments = [
{"id": 1, "created_at": "2026-07-01T10:00:00Z", "body": older},
{"id": 2, "created_at": "2026-07-02T10:00:00Z", "body": newer},
]
latest = cth.find_latest_cth(comments)
self.assertEqual(latest["comment_id"], 2)
self.assertEqual(latest["cth_type"], "Reviewer Handoff")
def test_cth_does_not_replace_formal_reviews_documented_in_protocol(self):
text = PROTOCOL_DOC.read_text(encoding="utf-8")
self.assertIn("do not replace", text.lower())
self.assertIn("formal Gitea review", text)
def test_examples_cover_required_scenarios(self):
text = PROTOCOL_DOC.read_text(encoding="utf-8")
expected_phrases = (
"PR approved and ready for merger",
"request-changes back to author",
"superseded PR closure",
"dirty root/worktree",
"Stale head requiring fresh review",
"Issue implementation handoff",
)
for phrase in expected_phrases:
self.assertIn(phrase, text)
class TestCthDocumentationChecks(unittest.TestCase):
def test_runbook_references_cth_discovery_and_posting(self):
text = RUNBOOK.read_text(encoding="utf-8")
self.assertIn("Canonical Thread Handoff", text)
self.assertIn("Find the latest CTH comment", text)
self.assertIn("Post a new CTH", text)
self.assertIn("canonical-thread-handoff.md", text)
def test_workflows_reference_cth_rules(self):
for path in (REVIEW_WORKFLOW, WORK_WORKFLOW):
text = path.read_text(encoding="utf-8")
self.assertIn("latest CTH comment", text)
self.assertIn("canonical-thread-handoff.md", text)
def test_prompt_templates_reference_cth_rules(self):
for path in (REVIEW_TEMPLATE, START_TEMPLATE, MERGE_TEMPLATE, TEMPLATE):
text = path.read_text(encoding="utf-8")
self.assertIn("CTH", text)
self.assertIn("canonical-thread-handoff", text)
if __name__ == "__main__":
unittest.main()