@ikon85/agent-workflow-kit 0.43.0 → 0.44.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 (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -1,381 +0,0 @@
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
- CLAUDE_WORKFLOW = (
25
- REPO / ".claude/skills/orchestrate-wave/references/dispatch-workflow.md"
26
- )
27
- CODEX_WORKFLOW = (
28
- REPO / ".agents/skills/orchestrate-wave/references/dispatch-workflow.md"
29
- )
30
- CLAUDE_SUBAGENTS = (
31
- REPO / ".claude/skills/orchestrate-wave/references/dispatch-subagents.md"
32
- )
33
- CODEX_SUBAGENTS = (
34
- REPO / ".agents/skills/orchestrate-wave/references/dispatch-subagents.md"
35
- )
36
- CODEX_SURFACE = REPO / ".agents/skills/orchestrate-wave"
37
- EXPECTED_DIRECT_SPAWN_CONTRACTS = (
38
- "audit-skills/SKILL.md",
39
- "code-review/SKILL.md",
40
- "codebase-design/DESIGN-IT-TWICE.md",
41
- "improve-codebase-architecture/INTERFACE-DESIGN.md",
42
- "improve-codebase-architecture/SKILL.md",
43
- "research/SKILL.md",
44
- )
45
- DIRECT_SPAWN_PATTERN = re.compile(
46
- r"Run \*\*one read-only research subagent|"
47
- r"Run both axes as \*\*parallel sub-agents|"
48
- r"Spawn 3\+ sub-agents|"
49
- r"spawn 3\+ sub-agents|"
50
- r"make 3\+ parallel\s+`spawn_agent` calls|"
51
- r"Then use the Agent tool|"
52
- r"use `spawn_agent` with `agent_type: explorer`|"
53
- r"use `spawn_agent` with only|"
54
- r"Spin up a \*\*background agent",
55
- )
56
-
57
-
58
- # Outcome -> fragments whose conjunction proves that portable behavior.
59
- BEHAVIORAL_PARITY = {
60
- "collision-safe claim": (
61
- "wave-active/<anchor>",
62
- "ahead",
63
- "uncommitted changes",
64
- "LOCAL annotated tag",
65
- "never push",
66
- ),
67
- "compare-and-set claim protocol": (
68
- "src/lib/waveClaim.mjs",
69
- "claimWave",
70
- "compare-and-set",
71
- "acquired",
72
- ),
73
- "owner-safe abort cleanup": (
74
- "this run planted",
75
- "On ANY wave STOP/abort",
76
- "Never delete a claim marker observed during a preflight collision",
77
- "releaseWaveClaim",
78
- ),
79
- "dependency-aware retirement": (
80
- "topological",
81
- "internal import graph",
82
- "ONE atomic slice",
83
- "cycle",
84
- ),
85
- "safe stacked landing": (
86
- "Do NOT rely on auto-retarget",
87
- "FRESH PR",
88
- "merge order",
89
- "manual gate",
90
- ),
91
- "completion propagation": (
92
- "Closing Conditions",
93
- "completion status",
94
- "native parent",
95
- "Program-PRD",
96
- "program sync",
97
- ),
98
- "session-owned teardown": (
99
- "session.py begin",
100
- "session.py create",
101
- "refuses reused/pre-existing targets",
102
- "seal the receipt",
103
- "compare-deleted",
104
- "foreign targets",
105
- "recovery-OID evidence",
106
- ),
107
- "program authorization continuity": (
108
- "explicit whole-Program mandate",
109
- "all planned Waves",
110
- "Do not re-ask at each Wave boundary",
111
- "gated action",
112
- "meaningful authorized safe work",
113
- "must not be marked `blocked`",
114
- ),
115
- }
116
-
117
-
118
- def markdown_body(text: str) -> str:
119
- """Ignore frontmatter representation differences between adapters."""
120
- if not text.startswith("---\n"):
121
- return text
122
- return text.split("\n---\n", 1)[1]
123
-
124
-
125
- def direct_spawn_contracts(surface: str) -> tuple[str, ...]:
126
- root = REPO / surface
127
- return tuple(sorted(
128
- str(path.relative_to(root))
129
- for path in root.rglob("*.md")
130
- if DIRECT_SPAWN_PATTERN.search(path.read_text(encoding="utf-8"))
131
- ))
132
-
133
-
134
- class OrchestrateWaveContract(unittest.TestCase):
135
- @classmethod
136
- def setUpClass(cls):
137
- cls.skill = CLAUDE_SKILL.read_text(encoding="utf-8")
138
- cls.builder = CLAUDE_BUILDER.read_text(encoding="utf-8")
139
-
140
- def test_reference_free_behavioral_parity_table(self):
141
- prose = " ".join(self.skill.split())
142
- for outcome, fragments in BEHAVIORAL_PARITY.items():
143
- with self.subTest(outcome=outcome):
144
- for fragment in fragments:
145
- self.assertIn(" ".join(fragment.split()), prose)
146
-
147
- self.assertNotRegex(self.skill, re.compile(r"/home/|#[0-9]{3,}"))
148
-
149
- def test_builder_commands_finish_in_the_foreground(self):
150
- prose = " ".join(self.builder.split())
151
- for fragment in (
152
- "IN THE FOREGROUND",
153
- "never background a test/gate command",
154
- "completed command results",
155
- "exit status",
156
- ):
157
- self.assertIn(" ".join(fragment.split()), prose)
158
-
159
- def test_builder_report_contract_is_identical_on_every_path(self):
160
- """One report contract, whichever mechanic dispatched the builder."""
161
- prose = " ".join(self.builder.split())
162
- for fragment in (
163
- "identical on every orchestration path",
164
- "references/report-contracts.md",
165
- "exactly ONE JSON object",
166
- "src/lib/reportValidator.mjs",
167
- "semanticVerify",
168
- ):
169
- self.assertIn(" ".join(fragment.split()), prose)
170
-
171
- def test_current_portable_contracts_survive_the_port(self):
172
- for fragment in (
173
- "project layer",
174
- "Native blocking edges are the frontier authority",
175
- "AFK heartbeat",
176
- "Re-run your project's full CI/verify gate CENTRALLY yourself",
177
- "already authenticated",
178
- ):
179
- self.assertIn(fragment, self.skill)
180
-
181
- def test_capability_selector_routes_exactly_one_orchestration_mechanic(self):
182
- for fragment in (
183
- "## Orchestration mechanics",
184
- "literal `Workflow`",
185
- "do not emulate",
186
- "returns exactly one",
187
- "references/dispatch-workflow.md",
188
- "references/dispatch-subagents.md",
189
- "Path C",
190
- "Phase 1 uses the selected orchestration mechanics",
191
- "Phase 2 uses the selected orchestration mechanics",
192
- ):
193
- self.assertIn(fragment, self.skill)
194
-
195
- # Skeleton-size ratchet: detail belongs in references/, not here. 345 ->
196
- # 360 carried the sticky Program-authorization rule; 360 -> 368 adds the
197
- # per-slice pickup claim (#231). Both are phase-level responsibilities
198
- # the reference files cannot carry — they gate dispatch itself.
199
- self.assertLessEqual(len(self.skill.splitlines()), 368)
200
-
201
- def test_registry_ownership_distinguishes_safe_from_eager_registries(self):
202
- prose = " ".join(self.skill.split())
203
- for fragment in (
204
- "declaration-only registries",
205
- "may be predeclared by one hub",
206
- "eager/validated registries",
207
- "each appending only its own existing artifact after creation",
208
- "dependency edges",
209
- ):
210
- self.assertIn(fragment, prose)
211
-
212
- phase_one = prose.split("## Phase 1", 1)[1].split("## Phase 2", 1)[0]
213
- done = phase_one.split("**Done when:**", 1)[1]
214
- for fragment in (
215
- "either one declaration-only owner",
216
- "verbatim consume-only dependents",
217
- "or an explicit serialized owner sequence",
218
- "each owner appends only its own existing artifact",
219
- ):
220
- self.assertIn(fragment, done)
221
-
222
- def test_phase_one_orders_producers_before_surface_measurers(self):
223
- prose = " ".join(self.skill.split())
224
- phase_one = prose.split("## Phase 1", 1)[1].split("## Phase 2", 1)[0]
225
- for fragment in (
226
- "Producer/measurer coupling",
227
- "semantic dependency",
228
- "measurer strictly after the producer",
229
- "baseline reconciliation",
230
- "drop producer-resolved entries",
231
- ):
232
- self.assertIn(fragment, phase_one)
233
-
234
- done = phase_one.split("**Done when:**", 1)[1]
235
- for fragment in (
236
- "producer/measurer pair",
237
- "explicit edge/order",
238
- "reconciliation owner",
239
- ):
240
- self.assertIn(fragment, done)
241
-
242
- def test_phase_four_centrally_runs_builder_browser_specs_before_ci(self):
243
- prose = " ".join(self.skill.split())
244
- phase_four = prose.split("## Phase 4", 1)[1].split("## Phase 5", 1)[0]
245
- browser_contract = "builders authored or changed browser specs"
246
- central_gate = "Re-run your project's full CI/verify gate CENTRALLY yourself"
247
- for fragment in (
248
- browser_contract,
249
- "unverified artifact",
250
- "ONE central green run",
251
- "if `§Verify Recipe` names a local e2e runner",
252
- "Never invent a command under generic fallback",
253
- ):
254
- self.assertIn(fragment, phase_four)
255
- self.assertLess(phase_four.index(browser_contract), phase_four.index(central_gate))
256
-
257
- def test_phase_two_keeps_per_slice_routing_decisions(self):
258
- prose = " ".join(self.skill.split())
259
- self.assertIn("(a) inline vs delegate", prose)
260
- self.assertIn("(b) tier + effort", prose)
261
- self.assertIn("Standing rules", prose)
262
-
263
- def test_every_surface_dispatch_crosses_resolver_and_spawn_guard(self):
264
- prose = " ".join(self.skill.split())
265
- for fragment in (
266
- "provider-neutral Routing intent",
267
- "shared resolver",
268
- "spawn guard",
269
- "Claude or Codex adapter",
270
- "routingAdapters/codex.mjs",
271
- "Dispatch receipt",
272
- "requested and applied route",
273
- "environment precedence",
274
- "detected transport is not authorization",
275
- "AFK",
276
- ):
277
- self.assertIn(fragment, prose)
278
-
279
- def test_every_direct_spawn_contract_routes_through_the_shared_guard(self):
280
- required = (
281
- "provider-neutral Routing intent",
282
- "routeDispatcher.mjs",
283
- "shared spawn guard",
284
- "Dispatch receipt",
285
- )
286
- claude_actual = direct_spawn_contracts(".claude/skills")
287
- codex_actual = direct_spawn_contracts(".agents/skills")
288
- self.assertEqual(claude_actual, EXPECTED_DIRECT_SPAWN_CONTRACTS)
289
- self.assertEqual(codex_actual, EXPECTED_DIRECT_SPAWN_CONTRACTS)
290
- for relative in claude_actual:
291
- claude = " ".join(
292
- (REPO / ".claude/skills" / relative).read_text(encoding="utf-8").split()
293
- )
294
- codex = " ".join(
295
- (REPO / ".agents/skills" / relative).read_text(encoding="utf-8").split()
296
- )
297
- with self.subTest(contract=relative):
298
- for fragment in required:
299
- self.assertIn(fragment, claude)
300
- self.assertIn(fragment, codex)
301
- for unsupported in ("Agent tool", "subagent_type", "agent_type"):
302
- self.assertNotIn(unsupported, codex)
303
-
304
- def test_path_a_reference_locks_the_two_run_dispatch_contract(self):
305
- workflow = CLAUDE_WORKFLOW.read_text(encoding="utf-8")
306
- for fragment in (
307
- "meta.phases",
308
- "one `agent()` call per slice",
309
- "model`, `effort`, and `phase`",
310
- "inline schema literal",
311
- "Recon run",
312
- "reconcileReconReports",
313
- "Build run",
314
- "resumeFromRunId",
315
- "exactly once",
316
- "journal.jsonl",
317
- "timestamps through `args`",
318
- "Date.now()",
319
- "Math.random()",
320
- "Every orchestration path",
321
- ):
322
- self.assertIn(fragment, workflow)
323
-
324
- self.assertEqual(workflow, CODEX_WORKFLOW.read_text(encoding="utf-8"))
325
-
326
- def test_path_b_reference_locks_the_native_subagent_contract(self):
327
- subagents = CLAUDE_SUBAGENTS.read_text(encoding="utf-8")
328
- prose = " ".join(subagents.split())
329
- for fragment in (
330
- "one read-only explorer per slice",
331
- "one builder per slice",
332
- "explicit wait",
333
- "exactly ONE JSON object",
334
- "reportValidator.mjs",
335
- "reconcileReconReports",
336
- "main thread",
337
- "is not a PASS",
338
- "waveClaim",
339
- "spawn_agents_on_csv",
340
- "output_schema",
341
- "dormant",
342
- "task_name`, `message`, and `fork_turns",
343
- "no model or effort selector",
344
- "blocks differentiated AFK",
345
- "routingAdapters/codex.mjs",
346
- ):
347
- self.assertIn(" ".join(fragment.split()), prose)
348
-
349
- self.assertEqual(subagents, CODEX_SUBAGENTS.read_text(encoding="utf-8"))
350
-
351
- def test_codex_surface_carries_no_path_a_primitive_outside_its_pointer_target(self):
352
- """B-surface prose must not require Workflow-only primitives."""
353
- offenders = {}
354
- for path in sorted(CODEX_SURFACE.rglob("*.md")):
355
- if path.name == "dispatch-workflow.md":
356
- continue
357
- body = path.read_text(encoding="utf-8")
358
- hits = [term for term in ("journal.jsonl", "resumeFromRunId") if term in body]
359
- # The literal tool name is admissible ONLY inside the capability gate.
360
- if "`Workflow`" in body:
361
- gate = body.split("## Orchestration mechanics", 1)
362
- remainder = gate[0] + (gate[1].split("\n## ", 1)[1] if len(gate) > 1 and "\n## " in gate[1] else "")
363
- if "`Workflow`" in remainder:
364
- hits.append("`Workflow`")
365
- if hits:
366
- offenders[str(path.relative_to(REPO))] = hits
367
- self.assertEqual(offenders, {})
368
-
369
- def test_claude_and_codex_surfaces_match(self):
370
- self.assertEqual(
371
- markdown_body(self.skill),
372
- markdown_body(CODEX_SKILL.read_text(encoding="utf-8")),
373
- )
374
- self.assertEqual(
375
- self.builder,
376
- CODEX_BUILDER.read_text(encoding="utf-8"),
377
- )
378
-
379
-
380
- if __name__ == "__main__":
381
- unittest.main()
@@ -1,264 +0,0 @@
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_active_close_targets_extracts_all_forms(self):
83
- body = ("closes #12\nFixes: #13\n"
84
- "resolves https://github.com/iKon85/agent-workflow-kit/issues/14\n"
85
- "`closes #99` and Part of #320 and closes #12")
86
- self.assertEqual(pbc.active_close_targets(body), ["12", "13", "14"])
87
-
88
- def test_active_close_targets_empty_for_part_of_only(self):
89
- self.assertEqual(pbc.active_close_targets("Part of #320\n**Retro:** skipped — x"), [])
90
-
91
- def test_program_prd_label_counts_as_anchor(self):
92
- with mock.patch.object(pbc, "_run",
93
- return_value=(0, "type:program\npriority:medium")):
94
- self.assertTrue(pbc.fetch_is_anchor(320))
95
-
96
- def test_cluster_label_still_counts_as_anchor(self):
97
- with mock.patch.object(pbc, "_run",
98
- return_value=(0, "type:cluster")):
99
- self.assertTrue(pbc.fetch_is_anchor(41))
100
-
101
- def test_wave_pr_still_requires_part_of_without_closing_anchor(self):
102
- body = f"Part of #130\ncloses #149\n{RETRO}"
103
- self.assertEqual(pbc.check_pr_body(body, 130, None, is_anchor=True), [])
104
-
105
-
106
- class ImmutableRangeTrailer(unittest.TestCase):
107
- def test_two_commit_range_finds_one_valid_trailer(self):
108
- with tempfile.TemporaryDirectory() as temp_dir:
109
- repo = Path(temp_dir)
110
- subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
111
- subprocess.run(
112
- ["git", "config", "user.email", "tests@example.invalid"],
113
- cwd=repo,
114
- check=True,
115
- )
116
- subprocess.run(
117
- ["git", "config", "user.name", "Test User"], cwd=repo, check=True
118
- )
119
- (repo / "change.txt").write_text("base\n", encoding="utf-8")
120
- subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
121
- subprocess.run(["git", "commit", "-qm", "base"], cwd=repo, check=True)
122
- base = subprocess.check_output(
123
- ["git", "rev-parse", "HEAD"], cwd=repo, text=True
124
- ).strip()
125
- (repo / "change.txt").write_text("head\n", encoding="utf-8")
126
- subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
127
- subprocess.run(
128
- [
129
- "git",
130
- "commit",
131
- "-qm",
132
- "head\n\nE2E-NA: backend-only change",
133
- ],
134
- cwd=repo,
135
- check=True,
136
- )
137
- head = subprocess.check_output(
138
- ["git", "rev-parse", "HEAD"], cwd=repo, text=True
139
- ).strip()
140
-
141
- self.assertTrue(e2e.fetch_has_e2e_na_trailer(base, head, cwd=repo))
142
-
143
- def test_unreadable_range_fails_open(self):
144
- self.assertFalse(e2e.fetch_has_e2e_na_trailer("missing-base", "missing-head"))
145
-
146
- def test_empty_or_multiple_trailers_are_not_a_single_valid_trailer(self):
147
- with mock.patch.object(
148
- e2e, "_collect_e2e_na_trailers", side_effect=[[""], ["one", "two"]]
149
- ):
150
- self.assertFalse(e2e.fetch_has_e2e_na_trailer("base", "head"))
151
- self.assertFalse(e2e.fetch_has_e2e_na_trailer("base", "head"))
152
-
153
- def test_pr_range_reads_immutable_base_and_head_oids(self):
154
- payload = '{"baseRefOid":"base-sha","headRefOid":"head-sha"}'
155
- with mock.patch.object(e2e, "_run", return_value=(0, payload)):
156
- self.assertEqual(e2e.fetch_pr_range("feat/149-guard"), ("base-sha", "head-sha"))
157
-
158
- def test_checker_defaults_to_pr_range_when_no_overrides_are_given(self):
159
- args = SimpleNamespace(base_sha=None, head_sha=None)
160
- with (
161
- mock.patch.object(pbc, "fetch_pr_range", return_value=("base", "head")) as get_range,
162
- mock.patch.object(pbc, "fetch_has_e2e_na_trailer", return_value=True) as has_trailer,
163
- ):
164
- self.assertTrue(pbc.resolve_has_e2e_na(args, "feat/149-guard"))
165
- get_range.assert_called_once_with("feat/149-guard")
166
- has_trailer.assert_called_once_with("base", "head")
167
-
168
- def test_unavailable_or_invalid_pr_range_fails_open(self):
169
- with mock.patch.object(e2e, "_run", side_effect=[(1, ""), (0, "not-json")]):
170
- self.assertEqual(e2e.fetch_pr_range("missing"), (None, None))
171
- self.assertEqual(e2e.fetch_pr_range("invalid"), (None, None))
172
-
173
- def test_explicit_range_requires_then_accepts_body_evidence(self):
174
- with tempfile.TemporaryDirectory() as temp_dir:
175
- repo = Path(temp_dir)
176
- subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
177
- subprocess.run(
178
- ["git", "config", "user.email", "tests@example.invalid"],
179
- cwd=repo,
180
- check=True,
181
- )
182
- subprocess.run(
183
- ["git", "config", "user.name", "Test User"], cwd=repo, check=True
184
- )
185
- (repo / "change.txt").write_text("base\n", encoding="utf-8")
186
- subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
187
- subprocess.run(["git", "commit", "-qm", "base"], cwd=repo, check=True)
188
- base = subprocess.check_output(
189
- ["git", "rev-parse", "HEAD"], cwd=repo, text=True
190
- ).strip()
191
- (repo / "change.txt").write_text("head\n", encoding="utf-8")
192
- subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
193
- subprocess.run(
194
- ["git", "commit", "-qm", "head\n\nE2E-NA: backend-only change"],
195
- cwd=repo,
196
- check=True,
197
- )
198
- head = subprocess.check_output(
199
- ["git", "rev-parse", "HEAD"], cwd=repo, text=True
200
- ).strip()
201
- body_path = repo / "body.md"
202
- base_body = f"Part of #130\ncloses #149\n{RETRO}"
203
- command = [
204
- sys.executable,
205
- str(SCRIPT_PATH),
206
- "--issue",
207
- "149",
208
- "--parent",
209
- "130",
210
- "--body-file",
211
- str(body_path),
212
- "--base-sha",
213
- base,
214
- "--head-sha",
215
- head,
216
- ]
217
-
218
- body_path.write_text(base_body, encoding="utf-8")
219
- missing = subprocess.run(
220
- command, cwd=repo, capture_output=True, text=True, env=TEST_ENV
221
- )
222
- self.assertEqual(missing.returncode, 1, missing.stdout + missing.stderr)
223
- self.assertIn("E2E: n/a", missing.stdout)
224
-
225
- body_path.write_text(
226
- base_body + "\nE2E: n/a — backend-only change\n", encoding="utf-8"
227
- )
228
- matching = subprocess.run(
229
- command, cwd=repo, capture_output=True, text=True, env=TEST_ENV
230
- )
231
- self.assertEqual(matching.returncode, 0, matching.stdout + matching.stderr)
232
-
233
-
234
- class ExistingExitCodes(unittest.TestCase):
235
- def test_no_issue_is_still_exit_two(self):
236
- result = subprocess.run(
237
- [sys.executable, str(SCRIPT_PATH), "--branch", "main"],
238
- capture_output=True,
239
- text=True,
240
- env=TEST_ENV,
241
- )
242
- self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
243
-
244
- def test_unreadable_body_file_is_still_exit_two(self):
245
- result = subprocess.run(
246
- [
247
- sys.executable,
248
- str(SCRIPT_PATH),
249
- "--issue",
250
- "149",
251
- "--parent",
252
- "130",
253
- "--body-file",
254
- "/definitely/missing/body.md",
255
- ],
256
- capture_output=True,
257
- text=True,
258
- env=TEST_ENV,
259
- )
260
- self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
261
-
262
-
263
- if __name__ == "__main__":
264
- unittest.main()