@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.
- package/.agents/skills/ask-matt/SKILL.md +2 -2
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +12 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.agents/skills/retro/SKILL.md +24 -1
- package/.agents/skills/scale-check/SKILL.md +2 -2
- package/.agents/skills/setup-workflow/SKILL.md +44 -1
- package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
- package/.agents/skills/tdd/SKILL.md +68 -14
- package/.agents/skills/to-issues/SKILL.md +2 -2
- package/.agents/skills/verify-spike/SKILL.md +1 -1
- package/.agents/skills/wrapup/SKILL.md +14 -2
- package/.claude/hooks/drift-guard.py +5 -2
- package/.claude/hooks/kit-origin-edit-hint.py +64 -0
- package/.claude/skills/ask-matt/SKILL.md +2 -2
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/kit-update/SKILL.md +12 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.claude/skills/retro/SKILL.md +23 -0
- package/.claude/skills/scale-check/SKILL.md +2 -2
- package/.claude/skills/setup-workflow/SKILL.md +44 -1
- package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/tdd/SKILL.md +68 -14
- package/.claude/skills/to-issues/SKILL.md +2 -2
- package/.claude/skills/verify-spike/SKILL.md +1 -1
- package/.claude/skills/wrapup/SKILL.md +14 -2
- package/README.md +47 -7
- package/agent-workflow-kit.package.json +49 -33
- package/docs/adr/0001-consumer-divergence-policy.md +49 -0
- package/docs/agents/wave-anchor-template.md +1 -1
- package/package.json +1 -1
- package/scripts/board-sync.py +184 -5
- package/scripts/pr-body-check.py +34 -6
- package/scripts/pr_body_e2e.py +83 -0
- package/scripts/test_board_sync.py +208 -0
- package/scripts/test_census_backstop.py +56 -3
- package/scripts/test_orchestrate_wave_contract.py +116 -0
- package/scripts/test_pr_body_check.py +245 -0
- package/scripts/test_retro_wrapup_contract.py +111 -0
- package/scripts/test_tdd_contract.py +78 -0
- package/src/cli.mjs +44 -8
- package/src/commands/diff.mjs +5 -2
- package/src/commands/init.mjs +12 -0
- package/src/commands/own.mjs +16 -0
- package/src/commands/uninstall.mjs +8 -1
- package/src/commands/update.mjs +37 -9
- package/src/lib/bundle.mjs +4 -0
- package/src/lib/consumerPath.mjs +30 -0
- package/src/lib/manifest.mjs +18 -0
- package/src/lib/ownedDiff.mjs +88 -0
- package/src/lib/updateCandidate.mjs +14 -0
- 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
|
|
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()
|