@ikon85/agent-workflow-kit 0.43.0 → 0.44.1

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 (127) 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/skill-manifest.json +1 -1
  22. package/.claude/skills/to-issues/SKILL.md +43 -0
  23. package/.claude/skills/wrapup/SKILL.md +98 -54
  24. package/README.md +254 -7
  25. package/agent-workflow-kit.package.json +43 -43
  26. package/package.json +16 -12
  27. package/scripts/marker_lib.py +3 -0
  28. package/scripts/profile_globs.py +26 -45
  29. package/scripts/release-delta-guard.mjs +26 -9
  30. package/scripts/worktree-lifecycle/README.md +57 -160
  31. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  32. package/scripts/worktree-lifecycle/classify.py +480 -0
  33. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  34. package/scripts/worktree-lifecycle/core.py +68 -1429
  35. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  36. package/scripts/worktree-lifecycle/profile.py +46 -29
  37. package/scripts/worktree-lifecycle/setup.py +7 -19
  38. package/scripts/wrapup-land.py +1292 -545
  39. package/src/consumer-migrations.json +1 -16
  40. package/src/lib/bundle.mjs +31 -1
  41. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  42. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  43. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  44. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  45. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  46. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  47. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  48. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  49. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  50. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  51. package/docs/agents/board-sync.md +0 -74
  52. package/docs/agents/code-review.md +0 -14
  53. package/docs/agents/workflow-capabilities.json +0 -28
  54. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  55. package/docs/research/benchlm-routing-source.md +0 -198
  56. package/docs/research/consumer-owned-protocol-files.md +0 -238
  57. package/docs/research/frontend-agent-benchmarks.md +0 -282
  58. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  59. package/docs/research/provider-neutral-agent-routing.md +0 -207
  60. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  61. package/docs/research/wave-43-script-hook-census.md +0 -128
  62. package/scripts/build-kit.mjs +0 -117
  63. package/scripts/build-kit.test.mjs +0 -207
  64. package/scripts/census/census.test.mjs +0 -252
  65. package/scripts/census/state.test.mjs +0 -15
  66. package/scripts/census/transaction.test.mjs +0 -103
  67. package/scripts/census-contract.test.mjs +0 -227
  68. package/scripts/check-kit-staleness.mjs +0 -45
  69. package/scripts/check-kit-staleness.test.mjs +0 -19
  70. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  71. package/scripts/codex-exec.test.mjs +0 -871
  72. package/scripts/grill-census-wiring-guard.mjs +0 -87
  73. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  74. package/scripts/kit-release.test.mjs +0 -273
  75. package/scripts/kit-update-pr.test.mjs +0 -151
  76. package/scripts/lib/audit-refs.mjs +0 -146
  77. package/scripts/lib/scrub.mjs +0 -113
  78. package/scripts/lib/scrub.test.mjs +0 -187
  79. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  80. package/scripts/portability_profile_scan.py +0 -53
  81. package/scripts/release-delta-guard.test.mjs +0 -174
  82. package/scripts/release-parity.test.mjs +0 -90
  83. package/scripts/release-state.test.mjs +0 -360
  84. package/scripts/test_anchor_table.py +0 -69
  85. package/scripts/test_board_bootstrap.py +0 -348
  86. package/scripts/test_board_sync.py +0 -208
  87. package/scripts/test_board_sync_create_idempotency.py +0 -117
  88. package/scripts/test_board_sync_wave_title.py +0 -120
  89. package/scripts/test_census_backstop.py +0 -712
  90. package/scripts/test_census_forward_contract.py +0 -91
  91. package/scripts/test_census_update_contract.test.mjs +0 -454
  92. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  93. package/scripts/test_dist_kit_smoke.py +0 -220
  94. package/scripts/test_drift_guard_diagnostics.py +0 -295
  95. package/scripts/test_issue_claim_contract.py +0 -169
  96. package/scripts/test_kit_docs_language_census.py +0 -178
  97. package/scripts/test_marker_lib.py +0 -162
  98. package/scripts/test_orchestrate_wave_contract.py +0 -381
  99. package/scripts/test_pr_body_check.py +0 -264
  100. package/scripts/test_profile_globs.py +0 -280
  101. package/scripts/test_program_planning_contract.py +0 -133
  102. package/scripts/test_release_authorization_contract.py +0 -101
  103. package/scripts/test_render_anchor.py +0 -267
  104. package/scripts/test_retro_wrapup_contract.py +0 -153
  105. package/scripts/test_skill_code_review_seed.py +0 -89
  106. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  107. package/scripts/test_skill_frontmatter_lint.py +0 -234
  108. package/scripts/test_skill_gh_lint.py +0 -124
  109. package/scripts/test_skill_language_census.py +0 -254
  110. package/scripts/test_skill_optional_readiness.py +0 -171
  111. package/scripts/test_skill_portability_lint.py +0 -866
  112. package/scripts/test_skill_precommit_template.py +0 -30
  113. package/scripts/test_skill_publish_audit.py +0 -358
  114. package/scripts/test_skill_readiness_contract.py +0 -264
  115. package/scripts/test_skill_readiness_preflight.py +0 -180
  116. package/scripts/test_skill_required_readiness.py +0 -233
  117. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  118. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  119. package/scripts/test_skill_stale_name_lint.py +0 -126
  120. package/scripts/test_skill_surface_refs.py +0 -300
  121. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  122. package/scripts/test_tdd_contract.py +0 -78
  123. package/scripts/test_worktree_ignore_seed.py +0 -320
  124. package/scripts/test_worktree_setup_base_guard.py +0 -140
  125. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  126. package/scripts/test_wrapup_land.py +0 -428
  127. 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()