@ikon85/agent-workflow-kit 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +2 -2
  2. package/.agents/skills/grill-me/SKILL.md +1 -1
  3. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  4. package/.agents/skills/kit-update/SKILL.md +12 -0
  5. package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
  6. package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
  7. package/.agents/skills/retro/SKILL.md +24 -1
  8. package/.agents/skills/scale-check/SKILL.md +2 -2
  9. package/.agents/skills/setup-workflow/SKILL.md +44 -1
  10. package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
  11. package/.agents/skills/tdd/SKILL.md +68 -14
  12. package/.agents/skills/to-issues/SKILL.md +2 -2
  13. package/.agents/skills/verify-spike/SKILL.md +1 -1
  14. package/.agents/skills/wrapup/SKILL.md +14 -2
  15. package/.claude/hooks/drift-guard.py +5 -2
  16. package/.claude/hooks/kit-origin-edit-hint.py +64 -0
  17. package/.claude/skills/ask-matt/SKILL.md +2 -2
  18. package/.claude/skills/grill-me/SKILL.md +1 -1
  19. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  20. package/.claude/skills/kit-update/SKILL.md +12 -0
  21. package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
  22. package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
  23. package/.claude/skills/retro/SKILL.md +23 -0
  24. package/.claude/skills/scale-check/SKILL.md +2 -2
  25. package/.claude/skills/setup-workflow/SKILL.md +44 -1
  26. package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
  27. package/.claude/skills/skill-manifest.json +1 -1
  28. package/.claude/skills/tdd/SKILL.md +68 -14
  29. package/.claude/skills/to-issues/SKILL.md +2 -2
  30. package/.claude/skills/verify-spike/SKILL.md +1 -1
  31. package/.claude/skills/wrapup/SKILL.md +14 -2
  32. package/README.md +51 -7
  33. package/agent-workflow-kit.package.json +50 -34
  34. package/docs/adr/0001-consumer-divergence-policy.md +49 -0
  35. package/docs/agents/wave-anchor-template.md +1 -1
  36. package/package.json +1 -1
  37. package/scripts/board-sync.py +184 -5
  38. package/scripts/pr-body-check.py +34 -6
  39. package/scripts/pr_body_e2e.py +83 -0
  40. package/scripts/release-parity.mjs +34 -0
  41. package/scripts/release-parity.test.mjs +37 -0
  42. package/scripts/test_board_sync.py +208 -0
  43. package/scripts/test_census_backstop.py +56 -3
  44. package/scripts/test_orchestrate_wave_contract.py +116 -0
  45. package/scripts/test_pr_body_check.py +245 -0
  46. package/scripts/test_retro_wrapup_contract.py +111 -0
  47. package/scripts/test_tdd_contract.py +78 -0
  48. package/src/cli.mjs +49 -10
  49. package/src/commands/diff.mjs +5 -2
  50. package/src/commands/init.mjs +12 -0
  51. package/src/commands/own.mjs +16 -0
  52. package/src/commands/uninstall.mjs +8 -1
  53. package/src/commands/update.mjs +39 -11
  54. package/src/lib/bundle.mjs +4 -0
  55. package/src/lib/consumerPath.mjs +30 -0
  56. package/src/lib/manifest.mjs +18 -0
  57. package/src/lib/ownedDiff.mjs +88 -0
  58. package/src/lib/updateCandidate.mjs +14 -0
  59. package/src/lib/updateReconcile.mjs +45 -6
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """Behavior tests for bounded board operations; the fake seam never calls GitHub."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import json
8
+ import subprocess
9
+ import unittest
10
+ from contextlib import redirect_stderr, redirect_stdout
11
+ from unittest.mock import patch
12
+
13
+ import importlib.util
14
+ from pathlib import Path
15
+
16
+
17
+ SPEC = importlib.util.spec_from_file_location(
18
+ "board_sync_operations_test", Path(__file__).with_name("board-sync.py"))
19
+ bs = importlib.util.module_from_spec(SPEC)
20
+ assert SPEC.loader is not None
21
+ SPEC.loader.exec_module(bs)
22
+
23
+
24
+ class FakeGh:
25
+ def __init__(self, responses=None, failures=None):
26
+ self.responses = responses or {}
27
+ self.failures = failures or {}
28
+ self.calls = []
29
+
30
+ def __call__(self, args):
31
+ self.calls.append(args)
32
+ joined = " ".join(args)
33
+ for needle, body in self.failures.items():
34
+ if needle in joined:
35
+ error = bs.GhError("injected failure")
36
+ error.stdout = body
37
+ raise error
38
+ for needle, body in self.responses.items():
39
+ if needle in joined:
40
+ return body
41
+ return ""
42
+
43
+
44
+ def run(fake, argv):
45
+ old = bs._gh
46
+ bs._gh = fake
47
+ out = io.StringIO()
48
+ try:
49
+ with redirect_stdout(out):
50
+ code = bs.main(argv)
51
+ finally:
52
+ bs._gh = old
53
+ return code, out.getvalue()
54
+
55
+
56
+ class BoundedCalls(unittest.TestCase):
57
+ def test_full_board_and_ordinary_calls_have_distinct_clear_timeouts(self):
58
+ with patch.object(bs.subprocess, "run", side_effect=subprocess.TimeoutExpired("gh", 1)) as call:
59
+ with self.assertRaisesRegex(bs.GhError, "15s"):
60
+ bs._gh(["issue", "view", "1"])
61
+ self.assertEqual(call.call_args.kwargs["timeout"], 15)
62
+ with self.assertRaisesRegex(bs.GhError, "60s"):
63
+ bs._gh(["project", "item-list", "3"])
64
+ self.assertEqual(call.call_args.kwargs["timeout"], 60)
65
+
66
+ def test_partial_create_prints_issue_and_replay_safe_repair(self):
67
+ fake = FakeGh(
68
+ {"issue list": "[]", "issue create": "https://github.com/x/y/issues/17\n"},
69
+ {"project item-add": ""},
70
+ )
71
+ with patch.object(bs, "REPO", "x/y"):
72
+ with self.subTest("created issue remains visible"):
73
+ from tempfile import NamedTemporaryFile
74
+ with NamedTemporaryFile("w") as body:
75
+ body.write("<!-- program-leaf-source: p/1 -->\n")
76
+ body.flush()
77
+ code, out = run(fake, ["create", "--title", "S", "--body-file", body.name])
78
+ self.assertEqual(code, 1)
79
+ self.assertIn("#17 https://github.com/x/y/issues/17", out)
80
+ self.assertIn("board-sync.py add --issue 17", out)
81
+ self.assertIn("idempotent", out)
82
+
83
+
84
+ class WaveLookup(unittest.TestCase):
85
+ def test_search_finds_next_wave_without_full_scan(self):
86
+ fake = FakeGh({"search/issues": json.dumps({"items": [
87
+ {"title": f"{bs.WAVE_TITLE_PREFIX} 7 — Anchor"},
88
+ {"title": f"{bs.WAVE_TITLE_PREFIX} 8 / Slice 1 — Leaf"},
89
+ ]})})
90
+ code, out = run(fake, ["next-wave"])
91
+ self.assertEqual((code, out.strip()), (0, "9"))
92
+ self.assertFalse(any("item-list" in " ".join(c) for c in fake.calls))
93
+
94
+ def test_empty_search_falls_back_and_scan_skips_search(self):
95
+ fake = FakeGh({"search/issues": '{"items":[]}', "item-list": '{"items":[{"wave":4}]}'})
96
+ with redirect_stderr(io.StringIO()):
97
+ self.assertEqual(run(fake, ["next-wave"])[1].strip(), "5")
98
+ scan = FakeGh({"item-list": '{"items":[{"wave":9}]}'})
99
+ self.assertEqual(run(scan, ["next-wave", "--scan"])[1].strip(), "10")
100
+ self.assertFalse(any("search/issues" in " ".join(c) for c in scan.calls))
101
+
102
+ def test_promotion_refuses_foreign_anchor_but_allows_same_issue(self):
103
+ foreign = {"items": [{"number": 22, "title": f"{bs.WAVE_TITLE_PREFIX} 7 — Other"}]}
104
+ message = bs.wave_collision_guard(foreign, 7, 21)
105
+ self.assertIn("#22", message)
106
+ self.assertIn("next-wave", message)
107
+ self.assertIsNone(bs.wave_collision_guard(foreign, 7, 22))
108
+ leaf = {"items": [{"number": 23, "title": f"{bs.WAVE_TITLE_PREFIX} 7 / Slice 1 — X"}]}
109
+ self.assertIsNone(bs.wave_collision_guard(leaf, 7, 21))
110
+
111
+ def test_promote_collision_fails_before_any_write(self):
112
+ fake = FakeGh({
113
+ "--json labels": "",
114
+ "--json body": "Draft PRD\n",
115
+ "graphql": '{"data":{"repository":{"issue":{"projectItems":{"nodes":[]}}}}}',
116
+ "search/issues": json.dumps({"items": [
117
+ {"number": 22, "title": f"{bs.WAVE_TITLE_PREFIX} 7 — Other"},
118
+ ]}),
119
+ })
120
+ error = io.StringIO()
121
+ with redirect_stderr(error):
122
+ code, _ = run(fake, ["promote", "--issue", "21", "--wave", "7"])
123
+ self.assertEqual(code, 1)
124
+ self.assertIn("#22", error.getvalue())
125
+ self.assertFalse(any(c[:2] == ["issue", "edit"] for c in fake.calls))
126
+
127
+
128
+ class TargetedLookup(unittest.TestCase):
129
+ @staticmethod
130
+ def payload(project):
131
+ return {"data": {"repository": {"issue": {"projectItems": {"nodes": [{
132
+ "id": "PVTI_x", "project": {"id": project}, "fieldValues": {"nodes": [
133
+ {"number": 7, "field": {"id": bs.WAVE_FIELD_ID}},
134
+ {"name": "Spec", "field": {"id": bs.STATUS_FIELD_ID}},
135
+ ]},
136
+ }]}}}}}
137
+
138
+ def test_item_of_returns_configured_fields(self):
139
+ fake = FakeGh({"graphql": json.dumps(self.payload(bs.PROJECT_NODE_ID))})
140
+ code, out = run(fake, ["item-of", "--issue", "21"])
141
+ self.assertEqual(code, 0)
142
+ self.assertEqual(json.loads(out), {"itemId": "PVTI_x", "wave": 7,
143
+ "status": "Spec", "cluster": None})
144
+
145
+ def test_item_of_fails_clearly_when_absent(self):
146
+ payload = {"data": {"repository": {"issue": {"projectItems": {"nodes": []}}}}}
147
+ code, out = run(FakeGh({"graphql": json.dumps(payload)}),
148
+ ["item-of", "--issue", "99"])
149
+ self.assertEqual((code, out.strip()), (1, "NOT-ON-BOARD"))
150
+
151
+
152
+ class ArchiveDone(unittest.TestCase):
153
+ def item_payload(self, count=1):
154
+ items = [{"id": f"D{i}", "status": bs.STATUS_ROLES["done"]} for i in range(count)]
155
+ items += [{"id": "OPEN", "status": bs.STATUS_ROLES["inProgress"]},
156
+ {"id": "OLD", "status": bs.STATUS_ROLES["done"], "archived": True}]
157
+ return json.dumps({"items": items, "totalCount": len(items)})
158
+
159
+ def test_archive_defaults_to_dry_run_and_selects_only_active_done(self):
160
+ fake = FakeGh({"item-list": self.item_payload()})
161
+ code, out = run(fake, ["archive-done"])
162
+ self.assertEqual(code, 0)
163
+ self.assertIn("D0", out)
164
+ self.assertNotIn("OPEN", out)
165
+ self.assertNotIn("OLD", out)
166
+ self.assertFalse(any("archiveProjectV2Item" in " ".join(c) for c in fake.calls))
167
+
168
+ def test_apply_batches_at_thirty(self):
169
+ class BatchGh(FakeGh):
170
+ def __call__(self, args):
171
+ self.calls.append(args)
172
+ if "item-list" in args:
173
+ return self.responses["item-list"]
174
+ count = " ".join(args).count("archiveProjectV2Item")
175
+ return json.dumps({"data": {f"a{i}": {"item": {"id": f"x{i}"}}
176
+ for i in range(count)}})
177
+ fake = BatchGh({"item-list": self.item_payload(31)})
178
+ code, out = run(fake, ["archive-done", "--apply"])
179
+ self.assertEqual(code, 0)
180
+ self.assertIn("31 of 31", out)
181
+ calls = [c for c in fake.calls if "archiveProjectV2Item" in " ".join(c)]
182
+ self.assertEqual([" ".join(c).count("archiveProjectV2Item") for c in calls], [30, 1])
183
+
184
+ def test_truncated_input_refuses_without_mutation(self):
185
+ fake = FakeGh({"item-list": '{"items":[{"id":"D","status":"Done"}],"totalCount":2}'})
186
+ err = io.StringIO()
187
+ with redirect_stderr(err):
188
+ code, _ = run(fake, ["archive-done", "--apply"])
189
+ self.assertEqual(code, 1)
190
+ self.assertIn("partial archive", err.getvalue())
191
+ self.assertFalse(any("archiveProjectV2Item" in " ".join(c) for c in fake.calls))
192
+
193
+ def test_apply_reports_partial_failure_and_empty_replay_is_idempotent(self):
194
+ body = json.dumps({"data": {"a0": {"item": {"id": "D0"}}, "a1": None},
195
+ "errors": [{"path": ["a1"], "message": "already archived"}]})
196
+ fake = FakeGh({"item-list": self.item_payload(2)}, {"archiveProjectV2Item": body})
197
+ code, out = run(fake, ["archive-done", "--apply"])
198
+ self.assertEqual(code, 1)
199
+ self.assertIn("1 of 2", out)
200
+ self.assertIn("D1: already archived", out)
201
+ empty = FakeGh({"item-list": json.dumps({"items": [], "totalCount": 0})})
202
+ code, out = run(empty, ["archive-done", "--apply"])
203
+ self.assertEqual(code, 0)
204
+ self.assertIn("nothing to archive", out)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ unittest.main(verbosity=2)
@@ -4,6 +4,7 @@ import os
4
4
  import subprocess
5
5
  import sys
6
6
  import tempfile
7
+ import time
7
8
  import unittest
8
9
  from pathlib import Path
9
10
  from unittest.mock import patch
@@ -45,6 +46,35 @@ class CensusBackstopTest(unittest.TestCase):
45
46
  json.dumps(fresh) + "\n", encoding="utf-8"
46
47
  )
47
48
 
49
+ def test_census_bridge_uses_production_scale_budgets_and_clamps_proofs(self):
50
+ temporary, root = self.make_repo()
51
+ self.addCleanup(temporary.cleanup)
52
+ self.enable(root)
53
+ calls = []
54
+
55
+ def complete_scan(*args, **kwargs):
56
+ calls.append(kwargs)
57
+ return subprocess.CompletedProcess(
58
+ args=args[0], returncode=0,
59
+ stdout=json.dumps({"state": "bootstrap"}), stderr="",
60
+ )
61
+
62
+ with patch.object(DRIFT_GUARD.subprocess, "run", side_effect=complete_scan):
63
+ DRIFT_GUARD.scan_census_status(root)
64
+ DRIFT_GUARD.scan_census_status(root, proof_timeout_ms=10**9)
65
+
66
+ default_input = json.loads(calls[0]["input"])
67
+ clamped_input = json.loads(calls[1]["input"])
68
+ self.assertEqual(calls[0]["timeout"], 30)
69
+ self.assertEqual(default_input["proofTimeoutMs"], 12_000)
70
+ self.assertGreater(clamped_input["proofTimeoutMs"], 0)
71
+ self.assertLess(clamped_input["proofTimeoutMs"], calls[1]["timeout"] * 1_000)
72
+
73
+ for invalid in (False, 1.5, "12000"):
74
+ with self.subTest(invalid=invalid):
75
+ with self.assertRaisesRegex(ValueError, "proof timeout must be an integer"):
76
+ DRIFT_GUARD.scan_census_status(root, proof_timeout_ms=invalid)
77
+
48
78
  def test_missing_disabled_bootstrap_and_offline_are_visible_but_fail_open(self):
49
79
  temporary, root = self.make_repo()
50
80
  self.addCleanup(temporary.cleanup)
@@ -322,15 +352,14 @@ class CensusBackstopTest(unittest.TestCase):
322
352
  ("refresh_required", True))
323
353
  self.assertIn("proof:src", failed_test["reasons"])
324
354
 
325
- def test_activated_census_blocks_when_local_proof_times_out(self):
355
+ def test_production_scale_proof_completes_and_hung_proofs_stay_bounded(self):
326
356
  temporary, root = self.make_repo()
327
357
  self.addCleanup(temporary.cleanup)
328
358
  module = root / "scanner.mjs"
329
359
  test = root / "scanner.test.mjs"
330
360
  module.write_text("export function scanLocal() { return ['src']; }\n", encoding="utf-8")
331
361
  test.write_text(
332
- "import { test } from 'node:test'; "
333
- "test('hang', async () => new Promise(resolve => setTimeout(resolve, 250)));\n",
362
+ "import { test } from 'node:test'; test('proof', () => {});\n",
334
363
  encoding="utf-8",
335
364
  )
336
365
  subprocess.run(["git", "add", "scanner.mjs", "scanner.test.mjs"], cwd=root, check=True)
@@ -341,11 +370,32 @@ class CensusBackstopTest(unittest.TestCase):
341
370
  self.enable(root, local_scanners=[scanner])
342
371
  self.activate_current(root)
343
372
 
373
+ module.write_text(
374
+ "export async function scanLocal() { "
375
+ "await new Promise(resolve => setTimeout(resolve, 5200)); return ['src']; }\n",
376
+ encoding="utf-8",
377
+ )
378
+ valid_started = time.monotonic()
379
+ valid = DRIFT_GUARD.evaluate_census(root)
380
+ valid_elapsed = time.monotonic() - valid_started
381
+ self.assertEqual((valid["state"], valid["block_handoff"]), ("current", False))
382
+ self.assertGreater(valid_elapsed, 5)
383
+ self.assertLess(valid_elapsed, DRIFT_GUARD.CENSUS_BRIDGE_TIMEOUT_SECONDS)
384
+
385
+ test.write_text(
386
+ "import { test } from 'node:test'; "
387
+ "test('hang', async () => new Promise(resolve => setTimeout(resolve, 250)));\n",
388
+ encoding="utf-8",
389
+ )
390
+ module.write_text("export function scanLocal() { return ['src']; }\n", encoding="utf-8")
391
+ timeout_started = time.monotonic()
344
392
  result = DRIFT_GUARD.evaluate_census(root, proof_timeout_ms=25)
393
+ timeout_elapsed = time.monotonic() - timeout_started
345
394
 
346
395
  self.assertEqual((result["state"], result["block_handoff"]),
347
396
  ("refresh_required", True))
348
397
  self.assertIn("proof:src", result["reasons"])
398
+ self.assertLess(timeout_elapsed, 2)
349
399
 
350
400
  test.write_text("import { test } from 'node:test'; test('proof', () => {});\n",
351
401
  encoding="utf-8")
@@ -353,10 +403,13 @@ class CensusBackstopTest(unittest.TestCase):
353
403
  "export async function scanLocal() { return new Promise(() => {}); }\n",
354
404
  encoding="utf-8",
355
405
  )
406
+ scanner_timeout_started = time.monotonic()
356
407
  scanner_timeout = DRIFT_GUARD.evaluate_census(root, proof_timeout_ms=25)
408
+ scanner_timeout_elapsed = time.monotonic() - scanner_timeout_started
357
409
  self.assertEqual((scanner_timeout["state"], scanner_timeout["block_handoff"]),
358
410
  ("refresh_required", True))
359
411
  self.assertIn("proof:src", scanner_timeout["reasons"])
412
+ self.assertLess(scanner_timeout_elapsed, 2)
360
413
 
361
414
  def test_activated_census_fails_closed_when_bridge_is_unavailable(self):
362
415
  temporary, root = self.make_repo()
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """Reference-free behavioral contract for the portable orchestrate-wave skill.
3
+
4
+ The parity table names outcomes, not the consumer that first proved them. That
5
+ keeps the regression test useful in every repository that installs the kit.
6
+
7
+ Run: python3 scripts/test_orchestrate_wave_contract.py
8
+ """
9
+
10
+ import re
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+
15
+ REPO = Path(__file__).resolve().parent.parent
16
+ CLAUDE_SKILL = REPO / ".claude/skills/orchestrate-wave/SKILL.md"
17
+ CODEX_SKILL = REPO / ".agents/skills/orchestrate-wave/SKILL.md"
18
+ CLAUDE_BUILDER = (
19
+ REPO / ".claude/skills/orchestrate-wave/references/builder-contract.md"
20
+ )
21
+ CODEX_BUILDER = (
22
+ REPO / ".agents/skills/orchestrate-wave/references/builder-contract.md"
23
+ )
24
+
25
+
26
+ # Outcome -> fragments whose conjunction proves that portable behavior.
27
+ BEHAVIORAL_PARITY = {
28
+ "collision-safe claim": (
29
+ "wave-active/<anchor>",
30
+ "ahead",
31
+ "uncommitted changes",
32
+ "LOCAL annotated tag",
33
+ "never push",
34
+ ),
35
+ "owner-safe abort cleanup": (
36
+ "this run planted",
37
+ "On ANY wave STOP/abort",
38
+ "Never delete a claim marker observed during a preflight collision",
39
+ ),
40
+ "dependency-aware retirement": (
41
+ "topological",
42
+ "internal import graph",
43
+ "ONE atomic slice",
44
+ "cycle",
45
+ ),
46
+ "safe stacked landing": (
47
+ "Do NOT rely on auto-retarget",
48
+ "FRESH PR",
49
+ "merge order",
50
+ "manual gate",
51
+ ),
52
+ "completion propagation": (
53
+ "Closing Conditions",
54
+ "completion status",
55
+ "native parent",
56
+ "Program-PRD",
57
+ "program sync",
58
+ ),
59
+ }
60
+
61
+
62
+ def markdown_body(text: str) -> str:
63
+ """Ignore frontmatter representation differences between adapters."""
64
+ if not text.startswith("---\n"):
65
+ return text
66
+ return text.split("\n---\n", 1)[1]
67
+
68
+
69
+ class OrchestrateWaveContract(unittest.TestCase):
70
+ @classmethod
71
+ def setUpClass(cls):
72
+ cls.skill = CLAUDE_SKILL.read_text(encoding="utf-8")
73
+ cls.builder = CLAUDE_BUILDER.read_text(encoding="utf-8")
74
+
75
+ def test_reference_free_behavioral_parity_table(self):
76
+ prose = " ".join(self.skill.split())
77
+ for outcome, fragments in BEHAVIORAL_PARITY.items():
78
+ with self.subTest(outcome=outcome):
79
+ for fragment in fragments:
80
+ self.assertIn(" ".join(fragment.split()), prose)
81
+
82
+ self.assertNotRegex(self.skill, re.compile(r"/home/|#[0-9]{3,}"))
83
+
84
+ def test_builder_commands_finish_in_the_foreground(self):
85
+ prose = " ".join(self.builder.split())
86
+ for fragment in (
87
+ "IN THE FOREGROUND",
88
+ "never background a test/gate command",
89
+ "completed command results",
90
+ "exit status",
91
+ ):
92
+ self.assertIn(" ".join(fragment.split()), prose)
93
+
94
+ def test_current_portable_contracts_survive_the_port(self):
95
+ for fragment in (
96
+ "project layer",
97
+ "Native blocking edges are the frontier authority",
98
+ "AFK heartbeat",
99
+ "Re-run your project's full CI/verify gate CENTRALLY yourself",
100
+ "already authenticated",
101
+ ):
102
+ self.assertIn(fragment, self.skill)
103
+
104
+ def test_claude_and_codex_surfaces_match(self):
105
+ self.assertEqual(
106
+ markdown_body(self.skill),
107
+ markdown_body(CODEX_SKILL.read_text(encoding="utf-8")),
108
+ )
109
+ self.assertEqual(
110
+ self.builder,
111
+ CODEX_BUILDER.read_text(encoding="utf-8"),
112
+ )
113
+
114
+
115
+ if __name__ == "__main__":
116
+ unittest.main()
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env python3
2
+ """Behavior tests for the PR-body convention guard."""
3
+ import importlib.util
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest import mock
10
+ from pathlib import Path
11
+ from types import SimpleNamespace
12
+
13
+ PROFILE_PATH = Path(__file__).parent.parent / "docs/agents/board-sync.md"
14
+ TEST_ENV = os.environ.copy()
15
+ TEST_ENV["BOARD_SYNC_PROFILE"] = str(PROFILE_PATH)
16
+ _PREVIOUS_PROFILE = os.environ.get("BOARD_SYNC_PROFILE")
17
+ os.environ["BOARD_SYNC_PROFILE"] = str(PROFILE_PATH)
18
+
19
+ _SPEC = importlib.util.spec_from_file_location(
20
+ "pr_body_check", Path(__file__).parent / "pr-body-check.py")
21
+ pbc = importlib.util.module_from_spec(_SPEC)
22
+ _SPEC.loader.exec_module(pbc)
23
+ if _PREVIOUS_PROFILE is None:
24
+ os.environ.pop("BOARD_SYNC_PROFILE", None)
25
+ else:
26
+ os.environ["BOARD_SYNC_PROFILE"] = _PREVIOUS_PROFILE
27
+ import pr_body_e2e as e2e # noqa: E402
28
+
29
+ SCRIPT_PATH = Path(__file__).parent / "pr-body-check.py"
30
+
31
+ RETRO = "**Retro:** skipped — focused guard change"
32
+ VALID_LEAF_BODY = f"closes #149\n{RETRO}"
33
+
34
+
35
+ class E2eNaBodyEvidence(unittest.TestCase):
36
+ def test_no_trailer_and_no_evidence_is_green(self):
37
+ self.assertEqual(pbc.check_pr_body(VALID_LEAF_BODY, 149, None), [])
38
+
39
+ def test_no_trailer_with_evidence_is_green(self):
40
+ body = VALID_LEAF_BODY + "\nE2E: n/a — harmless extra context"
41
+ self.assertEqual(pbc.check_pr_body(body, 149, None), [])
42
+
43
+ def test_valid_trailer_without_body_evidence_is_actionable(self):
44
+ violations = pbc.check_pr_body(
45
+ VALID_LEAF_BODY,
46
+ 149,
47
+ None,
48
+ has_e2e_na_trailer=True,
49
+ )
50
+ self.assertTrue(any("E2E: n/a" in violation for violation in violations))
51
+
52
+ def test_valid_trailer_with_non_empty_evidence_is_green(self):
53
+ body = VALID_LEAF_BODY + "\nE2E: n/a — backend-only change"
54
+ self.assertEqual(
55
+ pbc.check_pr_body(body, 149, None, has_e2e_na_trailer=True), []
56
+ )
57
+
58
+ def test_valid_trailer_with_empty_evidence_is_actionable(self):
59
+ body = VALID_LEAF_BODY + "\nE2E: n/a — "
60
+ violations = pbc.check_pr_body(
61
+ body, 149, None, has_e2e_na_trailer=True
62
+ )
63
+ self.assertTrue(any("E2E: n/a" in violation for violation in violations))
64
+
65
+
66
+ class ExistingBodyRules(unittest.TestCase):
67
+ def test_anchor_slice_still_accepts_part_of_and_leaf_close(self):
68
+ body = f"Part of #130\ncloses #149\n{RETRO}"
69
+ self.assertEqual(pbc.check_pr_body(body, 149, 130), [])
70
+
71
+ def test_anchor_slice_still_rejects_close_on_anchor(self):
72
+ body = f"Part of #130\ncloses #130\n{RETRO}"
73
+ self.assertTrue(any("130" in item for item in pbc.check_pr_body(body, 149, 130)))
74
+
75
+ def test_leaf_still_requires_active_close(self):
76
+ body = f"`closes #149`\n{RETRO}"
77
+ self.assertTrue(any("closes #149" in item for item in pbc.check_pr_body(body, 149, None)))
78
+
79
+ def test_retro_line_still_required(self):
80
+ self.assertTrue(any("Retro" in item for item in pbc.check_pr_body("closes #149", 149, None)))
81
+
82
+ def test_wave_pr_still_requires_part_of_without_closing_anchor(self):
83
+ body = f"Part of #130\ncloses #149\n{RETRO}"
84
+ self.assertEqual(pbc.check_pr_body(body, 130, None, is_anchor=True), [])
85
+
86
+
87
+ class ImmutableRangeTrailer(unittest.TestCase):
88
+ def test_two_commit_range_finds_one_valid_trailer(self):
89
+ with tempfile.TemporaryDirectory() as temp_dir:
90
+ repo = Path(temp_dir)
91
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
92
+ subprocess.run(
93
+ ["git", "config", "user.email", "tests@example.invalid"],
94
+ cwd=repo,
95
+ check=True,
96
+ )
97
+ subprocess.run(
98
+ ["git", "config", "user.name", "Test User"], cwd=repo, check=True
99
+ )
100
+ (repo / "change.txt").write_text("base\n", encoding="utf-8")
101
+ subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
102
+ subprocess.run(["git", "commit", "-qm", "base"], cwd=repo, check=True)
103
+ base = subprocess.check_output(
104
+ ["git", "rev-parse", "HEAD"], cwd=repo, text=True
105
+ ).strip()
106
+ (repo / "change.txt").write_text("head\n", encoding="utf-8")
107
+ subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
108
+ subprocess.run(
109
+ [
110
+ "git",
111
+ "commit",
112
+ "-qm",
113
+ "head\n\nE2E-NA: backend-only change",
114
+ ],
115
+ cwd=repo,
116
+ check=True,
117
+ )
118
+ head = subprocess.check_output(
119
+ ["git", "rev-parse", "HEAD"], cwd=repo, text=True
120
+ ).strip()
121
+
122
+ self.assertTrue(e2e.fetch_has_e2e_na_trailer(base, head, cwd=repo))
123
+
124
+ def test_unreadable_range_fails_open(self):
125
+ self.assertFalse(e2e.fetch_has_e2e_na_trailer("missing-base", "missing-head"))
126
+
127
+ def test_empty_or_multiple_trailers_are_not_a_single_valid_trailer(self):
128
+ with mock.patch.object(
129
+ e2e, "_collect_e2e_na_trailers", side_effect=[[""], ["one", "two"]]
130
+ ):
131
+ self.assertFalse(e2e.fetch_has_e2e_na_trailer("base", "head"))
132
+ self.assertFalse(e2e.fetch_has_e2e_na_trailer("base", "head"))
133
+
134
+ def test_pr_range_reads_immutable_base_and_head_oids(self):
135
+ payload = '{"baseRefOid":"base-sha","headRefOid":"head-sha"}'
136
+ with mock.patch.object(e2e, "_run", return_value=(0, payload)):
137
+ self.assertEqual(e2e.fetch_pr_range("feat/149-guard"), ("base-sha", "head-sha"))
138
+
139
+ def test_checker_defaults_to_pr_range_when_no_overrides_are_given(self):
140
+ args = SimpleNamespace(base_sha=None, head_sha=None)
141
+ with (
142
+ mock.patch.object(pbc, "fetch_pr_range", return_value=("base", "head")) as get_range,
143
+ mock.patch.object(pbc, "fetch_has_e2e_na_trailer", return_value=True) as has_trailer,
144
+ ):
145
+ self.assertTrue(pbc.resolve_has_e2e_na(args, "feat/149-guard"))
146
+ get_range.assert_called_once_with("feat/149-guard")
147
+ has_trailer.assert_called_once_with("base", "head")
148
+
149
+ def test_unavailable_or_invalid_pr_range_fails_open(self):
150
+ with mock.patch.object(e2e, "_run", side_effect=[(1, ""), (0, "not-json")]):
151
+ self.assertEqual(e2e.fetch_pr_range("missing"), (None, None))
152
+ self.assertEqual(e2e.fetch_pr_range("invalid"), (None, None))
153
+
154
+ def test_explicit_range_requires_then_accepts_body_evidence(self):
155
+ with tempfile.TemporaryDirectory() as temp_dir:
156
+ repo = Path(temp_dir)
157
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
158
+ subprocess.run(
159
+ ["git", "config", "user.email", "tests@example.invalid"],
160
+ cwd=repo,
161
+ check=True,
162
+ )
163
+ subprocess.run(
164
+ ["git", "config", "user.name", "Test User"], cwd=repo, check=True
165
+ )
166
+ (repo / "change.txt").write_text("base\n", encoding="utf-8")
167
+ subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
168
+ subprocess.run(["git", "commit", "-qm", "base"], cwd=repo, check=True)
169
+ base = subprocess.check_output(
170
+ ["git", "rev-parse", "HEAD"], cwd=repo, text=True
171
+ ).strip()
172
+ (repo / "change.txt").write_text("head\n", encoding="utf-8")
173
+ subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
174
+ subprocess.run(
175
+ ["git", "commit", "-qm", "head\n\nE2E-NA: backend-only change"],
176
+ cwd=repo,
177
+ check=True,
178
+ )
179
+ head = subprocess.check_output(
180
+ ["git", "rev-parse", "HEAD"], cwd=repo, text=True
181
+ ).strip()
182
+ body_path = repo / "body.md"
183
+ base_body = f"Part of #130\ncloses #149\n{RETRO}"
184
+ command = [
185
+ sys.executable,
186
+ str(SCRIPT_PATH),
187
+ "--issue",
188
+ "149",
189
+ "--parent",
190
+ "130",
191
+ "--body-file",
192
+ str(body_path),
193
+ "--base-sha",
194
+ base,
195
+ "--head-sha",
196
+ head,
197
+ ]
198
+
199
+ body_path.write_text(base_body, encoding="utf-8")
200
+ missing = subprocess.run(
201
+ command, cwd=repo, capture_output=True, text=True, env=TEST_ENV
202
+ )
203
+ self.assertEqual(missing.returncode, 1, missing.stdout + missing.stderr)
204
+ self.assertIn("E2E: n/a", missing.stdout)
205
+
206
+ body_path.write_text(
207
+ base_body + "\nE2E: n/a — backend-only change\n", encoding="utf-8"
208
+ )
209
+ matching = subprocess.run(
210
+ command, cwd=repo, capture_output=True, text=True, env=TEST_ENV
211
+ )
212
+ self.assertEqual(matching.returncode, 0, matching.stdout + matching.stderr)
213
+
214
+
215
+ class ExistingExitCodes(unittest.TestCase):
216
+ def test_no_issue_is_still_exit_two(self):
217
+ result = subprocess.run(
218
+ [sys.executable, str(SCRIPT_PATH), "--branch", "main"],
219
+ capture_output=True,
220
+ text=True,
221
+ env=TEST_ENV,
222
+ )
223
+ self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
224
+
225
+ def test_unreadable_body_file_is_still_exit_two(self):
226
+ result = subprocess.run(
227
+ [
228
+ sys.executable,
229
+ str(SCRIPT_PATH),
230
+ "--issue",
231
+ "149",
232
+ "--parent",
233
+ "130",
234
+ "--body-file",
235
+ "/definitely/missing/body.md",
236
+ ],
237
+ capture_output=True,
238
+ text=True,
239
+ env=TEST_ENV,
240
+ )
241
+ self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
242
+
243
+
244
+ if __name__ == "__main__":
245
+ unittest.main()