@ikon85/agent-workflow-kit 0.27.1 → 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 (57) 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 +47 -7
  33. package/agent-workflow-kit.package.json +49 -33
  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/test_board_sync.py +208 -0
  41. package/scripts/test_census_backstop.py +56 -3
  42. package/scripts/test_orchestrate_wave_contract.py +116 -0
  43. package/scripts/test_pr_body_check.py +245 -0
  44. package/scripts/test_retro_wrapup_contract.py +111 -0
  45. package/scripts/test_tdd_contract.py +78 -0
  46. package/src/cli.mjs +44 -8
  47. package/src/commands/diff.mjs +5 -2
  48. package/src/commands/init.mjs +12 -0
  49. package/src/commands/own.mjs +16 -0
  50. package/src/commands/uninstall.mjs +8 -1
  51. package/src/commands/update.mjs +37 -9
  52. package/src/lib/bundle.mjs +4 -0
  53. package/src/lib/consumerPath.mjs +30 -0
  54. package/src/lib/manifest.mjs +18 -0
  55. package/src/lib/ownedDiff.mjs +88 -0
  56. package/src/lib/updateCandidate.mjs +14 -0
  57. package/src/lib/updateReconcile.mjs +45 -6
@@ -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()
@@ -0,0 +1,111 @@
1
+ """Contract tests for retro enforcement and safe wrapup workflow chaining."""
2
+
3
+ from pathlib import Path
4
+ import re
5
+ import unittest
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ SURFACES = (".claude", ".agents")
10
+
11
+
12
+ def skill(surface: str, name: str) -> str:
13
+ return (ROOT / surface / "skills" / name / "SKILL.md").read_text(
14
+ encoding="utf-8"
15
+ )
16
+
17
+
18
+ def contract_text(surface: str, name: str) -> str:
19
+ """Return prose with Markdown emphasis removed for wording assertions."""
20
+ return re.sub(r"\s+", " ", skill(surface, name).replace("**", ""))
21
+
22
+
23
+ class RetroEnforcementContract(unittest.TestCase):
24
+ def test_mechanical_check_precedes_target_and_weight_ladder(self):
25
+ for surface in SURFACES:
26
+ with self.subTest(surface=surface):
27
+ text = contract_text(surface, "retro")
28
+ mechanical = text.index("### 3a. Mechanical enforcement check")
29
+ ladder = text.index("### 3b. Determine target + weight")
30
+ self.assertLess(mechanical, ladder)
31
+
32
+ def test_machine_checkable_findings_cannot_stop_at_prose(self):
33
+ required = (
34
+ "Could a machine decide this",
35
+ "implement the enforcement",
36
+ "tracked enforcement issue",
37
+ "explicit trade-off",
38
+ )
39
+ for surface in SURFACES:
40
+ with self.subTest(surface=surface):
41
+ text = contract_text(surface, "retro")
42
+ for phrase in required:
43
+ self.assertIn(phrase, text)
44
+
45
+ def test_upstream_or_own_and_exact_sanitized_approval_survive(self):
46
+ required = (
47
+ "generic or project-specific",
48
+ "recommend `own`",
49
+ "sanitized preview",
50
+ "explicitly approves that exact text",
51
+ "does not need to be the kit maintainer",
52
+ "docs/agents/skills/<skill>.md",
53
+ )
54
+ for surface in SURFACES:
55
+ with self.subTest(surface=surface):
56
+ text = contract_text(surface, "retro")
57
+ for phrase in required:
58
+ self.assertIn(phrase, text)
59
+
60
+
61
+ class WrapupChainingContract(unittest.TestCase):
62
+ def test_retro_is_model_invocable_on_both_surfaces(self):
63
+ for surface in SURFACES:
64
+ with self.subTest(surface=surface):
65
+ frontmatter = skill(surface, "retro").split("---", 2)[1]
66
+ self.assertIn("disable-model-invocation: false", frontmatter)
67
+
68
+ def test_affirmative_retro_gate_chains_without_landing(self):
69
+ required = (
70
+ "invoke the `retro` skill immediately in this run",
71
+ "land nothing in this run",
72
+ "fresh explicit `/wrapup` invocation",
73
+ )
74
+ for surface in SURFACES:
75
+ with self.subTest(surface=surface):
76
+ text = contract_text(surface, "wrapup")
77
+ for phrase in required:
78
+ self.assertIn(phrase, text)
79
+
80
+ def test_only_model_invocable_non_deploying_workflows_chain(self):
81
+ required = (
82
+ "model-invocable",
83
+ "non-deploying",
84
+ "return control to the user",
85
+ "state the reason",
86
+ )
87
+ for surface in SURFACES:
88
+ with self.subTest(surface=surface):
89
+ text = contract_text(surface, "wrapup")
90
+ for phrase in required:
91
+ self.assertIn(phrase, text)
92
+
93
+ def test_wrapup_remains_manual_and_program_propagation_remains_present(self):
94
+ for surface in SURFACES:
95
+ with self.subTest(surface=surface):
96
+ text = skill(surface, "wrapup")
97
+ self.assertIn("disable-model-invocation: true", text)
98
+ self.assertIn("user's `/wrapup` input IS the explicit", text)
99
+ self.assertIn("program-sync", text)
100
+ self.assertIn("Phasen-Gates", text)
101
+
102
+ def test_new_contract_is_reference_free(self):
103
+ for surface in SURFACES:
104
+ with self.subTest(surface=surface):
105
+ combined = skill(surface, "retro") + skill(surface, "wrapup")
106
+ self.assertNotIn("testreporter", combined.lower())
107
+ self.assertIsNone(re.search(r"#[0-9]{3,}", combined))
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main(verbosity=2)
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Contract tests for existing-test-first TDD without weakening RED-first."""
3
+
4
+ from pathlib import Path
5
+ import re
6
+ import unittest
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ SURFACES = (
11
+ ROOT / ".claude" / "skills" / "tdd" / "SKILL.md",
12
+ ROOT / ".agents" / "skills" / "tdd" / "SKILL.md",
13
+ )
14
+ DECISIONS = ("REUSE", "EXTEND", "REPLACE", "NEW", "RETIRE", "NO-NEW-TEST")
15
+
16
+
17
+ class TddContractTest(unittest.TestCase):
18
+ def texts(self):
19
+ return {path: path.read_text(encoding="utf-8") for path in SURFACES}
20
+
21
+ def test_each_behavior_gets_exactly_one_existing_test_first_decision(self):
22
+ for path, text in self.texts().items():
23
+ with self.subTest(surface=path.relative_to(ROOT)):
24
+ self.assertIn("### 2. Existing-Test-First Decision", text)
25
+ self.assertIn("For each planned behavior, record exactly one", text)
26
+ for decision in DECISIONS:
27
+ self.assertEqual(
28
+ len(re.findall(rf"^- \*\*{re.escape(decision)}\*\*", text, re.MULTILINE)),
29
+ 1,
30
+ f"{decision} must have exactly one canonical definition",
31
+ )
32
+
33
+ def test_new_files_require_rejecting_the_nearest_owner_with_a_reason(self):
34
+ for path, text in self.texts().items():
35
+ with self.subTest(surface=path.relative_to(ROOT)):
36
+ self.assertIn("nearest existing owner", text)
37
+ self.assertIn("rejected with a reason", text)
38
+
39
+ def test_red_first_is_unconditional_for_executable_behavior_and_bug_fixes(self):
40
+ for path, text in self.texts().items():
41
+ with self.subTest(surface=path.relative_to(ROOT)):
42
+ self.assertIn(
43
+ "Every executable new behavior and bug fix must begin with a failing assertion",
44
+ text,
45
+ )
46
+ self.assertIn("REUSE and NO-NEW-TEST never bypass this RED-first invariant", text)
47
+ self.assertRegex(
48
+ text,
49
+ r"an already-green\s+assertion does not prove the requested change",
50
+ )
51
+
52
+ def test_retirement_keeps_negative_tests_only_for_durable_absence(self):
53
+ for path, text in self.texts().items():
54
+ with self.subTest(surface=path.relative_to(ROOT)):
55
+ self.assertIn("delete the tests that specified the retired behavior", text)
56
+ self.assertIn("only when absence itself is a durable", text)
57
+
58
+ def test_worked_matrix_and_counted_handoff_cover_all_decisions(self):
59
+ for path, text in self.texts().items():
60
+ with self.subTest(surface=path.relative_to(ROOT)):
61
+ matrix = re.search(
62
+ r"### Worked decision matrix\n(?P<body>.*?)(?=\n### |\n## )",
63
+ text,
64
+ re.DOTALL,
65
+ )
66
+ self.assertIsNotNone(matrix)
67
+ body = matrix.group("body")
68
+ for decision in DECISIONS:
69
+ self.assertRegex(body, rf"\|\s*{re.escape(decision)}\s*\|")
70
+ self.assertIn("list every behavior and its one decision", text)
71
+ self.assertIn(
72
+ "Reused X · Extended Y · New Z · Replaced/retired W",
73
+ text,
74
+ )
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()