@ikon85/agent-workflow-kit 0.32.1 → 0.34.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 (89) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +4 -3
  2. package/.agents/skills/audit-skills/SKILL.md +6 -0
  3. package/.agents/skills/board-to-waves/SKILL.md +6 -2
  4. package/.agents/skills/code-review/SKILL.md +6 -0
  5. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +11 -1
  6. package/.agents/skills/codex-adapter-sync/SKILL.md +23 -19
  7. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  8. package/.agents/skills/improve-codebase-architecture/SKILL.md +10 -1
  9. package/.agents/skills/kit-update/SKILL.md +13 -0
  10. package/.agents/skills/orchestrate-wave/SKILL.md +54 -54
  11. package/.agents/skills/orchestrate-wave/references/builder-contract.md +16 -3
  12. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +91 -0
  13. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +66 -0
  14. package/.agents/skills/orchestrate-wave/references/report-contracts.md +42 -0
  15. package/.agents/skills/research/SKILL.md +6 -0
  16. package/.agents/skills/scale-check/SKILL.md +9 -7
  17. package/.agents/skills/setup-workflow/SKILL.md +47 -1
  18. package/.agents/skills/setup-workflow/board-sync.md +7 -2
  19. package/.agents/skills/setup-workflow/workflow-overview.md +1 -2
  20. package/.agents/skills/to-issues/SKILL.md +66 -8
  21. package/.agents/skills/to-waves/SKILL.md +24 -12
  22. package/.claude/skills/ask-matt/SKILL.md +4 -3
  23. package/.claude/skills/audit-skills/SKILL.md +6 -0
  24. package/.claude/skills/board-to-waves/SKILL.md +6 -2
  25. package/.claude/skills/code-review/SKILL.md +6 -0
  26. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +8 -0
  27. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  28. package/.claude/skills/improve-codebase-architecture/SKILL.md +6 -0
  29. package/.claude/skills/kit-update/SKILL.md +13 -0
  30. package/.claude/skills/orchestrate-wave/SKILL.md +54 -54
  31. package/.claude/skills/orchestrate-wave/references/builder-contract.md +16 -3
  32. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +91 -0
  33. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +66 -0
  34. package/.claude/skills/orchestrate-wave/references/report-contracts.md +42 -0
  35. package/.claude/skills/research/SKILL.md +6 -0
  36. package/.claude/skills/scale-check/SKILL.md +9 -7
  37. package/.claude/skills/setup-workflow/SKILL.md +47 -1
  38. package/.claude/skills/setup-workflow/board-sync.md +7 -2
  39. package/.claude/skills/setup-workflow/workflow-overview.md +1 -2
  40. package/.claude/skills/to-issues/SKILL.md +66 -8
  41. package/.claude/skills/to-waves/SKILL.md +24 -12
  42. package/README.md +126 -11
  43. package/agent-workflow-kit.package.json +291 -39
  44. package/docs/adr/0002-capability-gated-orchestration.md +70 -0
  45. package/docs/adr/0005-to-issues-is-the-planning-facade.md +42 -0
  46. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +91 -0
  47. package/docs/agents/skills/local-ci.md +89 -0
  48. package/docs/agents/wave-anchor-template.md +2 -2
  49. package/docs/methodology.html +1 -1
  50. package/docs/methodology.svg +1 -1
  51. package/docs/workflow.html +2 -2
  52. package/docs/workflow.png +0 -0
  53. package/package.json +1 -1
  54. package/scripts/codex-exec.sh +47 -8
  55. package/scripts/codex-exec.test.mjs +56 -0
  56. package/scripts/test_codex_adapter_sync_contract.py +30 -15
  57. package/scripts/test_orchestrate_wave_contract.py +209 -0
  58. package/scripts/test_program_planning_contract.py +70 -0
  59. package/scripts/test_skill_portability_lint.py +54 -0
  60. package/scripts/test_worktree_setup_base_guard.py +140 -0
  61. package/scripts/worktree-lifecycle/setup.py +40 -0
  62. package/src/cli.mjs +109 -2
  63. package/src/commands/init.mjs +17 -1
  64. package/src/commands/routing-policy-update.mjs +204 -0
  65. package/src/commands/update.mjs +22 -0
  66. package/src/lib/agentSurfaceRegistry.mjs +60 -0
  67. package/src/lib/bundle.mjs +34 -0
  68. package/src/lib/capabilityMatrix.mjs +179 -0
  69. package/src/lib/dispatchReceipt.mjs +162 -0
  70. package/src/lib/frontendWorkloads.mjs +170 -0
  71. package/src/lib/reconcileReconReports.mjs +111 -0
  72. package/src/lib/reportValidator.mjs +186 -0
  73. package/src/lib/routeDispatcher.mjs +158 -0
  74. package/src/lib/routingAccessGraph.mjs +105 -0
  75. package/src/lib/routingAdapters/claude.mjs +62 -0
  76. package/src/lib/routingAdapters/codex.mjs +136 -0
  77. package/src/lib/routingCatalog.mjs +123 -0
  78. package/src/lib/routingEvidenceCache.mjs +222 -0
  79. package/src/lib/routingIntent.mjs +93 -0
  80. package/src/lib/routingPolicy.mjs +67 -0
  81. package/src/lib/routingProfile.mjs +334 -0
  82. package/src/lib/routingResolver.mjs +176 -0
  83. package/src/lib/routingSources/artificialAnalysis.mjs +129 -0
  84. package/src/lib/routingSources/benchlm.mjs +151 -0
  85. package/src/lib/routingSources/codeArena.mjs +162 -0
  86. package/src/lib/routingSources/deepswe.mjs +106 -0
  87. package/src/lib/routingSources/openhands.mjs +102 -0
  88. package/src/lib/routingSources/openhandsFrontend.mjs +135 -0
  89. package/src/lib/waveClaim.mjs +134 -0
@@ -21,6 +21,38 @@ CLAUDE_BUILDER = (
21
21
  CODEX_BUILDER = (
22
22
  REPO / ".agents/skills/orchestrate-wave/references/builder-contract.md"
23
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
+ )
24
56
 
25
57
 
26
58
  # Outcome -> fragments whose conjunction proves that portable behavior.
@@ -32,10 +64,17 @@ BEHAVIORAL_PARITY = {
32
64
  "LOCAL annotated tag",
33
65
  "never push",
34
66
  ),
67
+ "compare-and-set claim protocol": (
68
+ "src/lib/waveClaim.mjs",
69
+ "claimWave",
70
+ "compare-and-set",
71
+ "acquired",
72
+ ),
35
73
  "owner-safe abort cleanup": (
36
74
  "this run planted",
37
75
  "On ANY wave STOP/abort",
38
76
  "Never delete a claim marker observed during a preflight collision",
77
+ "releaseWaveClaim",
39
78
  ),
40
79
  "dependency-aware retirement": (
41
80
  "topological",
@@ -66,6 +105,15 @@ def markdown_body(text: str) -> str:
66
105
  return text.split("\n---\n", 1)[1]
67
106
 
68
107
 
108
+ def direct_spawn_contracts(surface: str) -> tuple[str, ...]:
109
+ root = REPO / surface
110
+ return tuple(sorted(
111
+ str(path.relative_to(root))
112
+ for path in root.rglob("*.md")
113
+ if DIRECT_SPAWN_PATTERN.search(path.read_text(encoding="utf-8"))
114
+ ))
115
+
116
+
69
117
  class OrchestrateWaveContract(unittest.TestCase):
70
118
  @classmethod
71
119
  def setUpClass(cls):
@@ -91,6 +139,18 @@ class OrchestrateWaveContract(unittest.TestCase):
91
139
  ):
92
140
  self.assertIn(" ".join(fragment.split()), prose)
93
141
 
142
+ def test_builder_report_contract_is_identical_on_every_path(self):
143
+ """One report contract, whichever mechanic dispatched the builder."""
144
+ prose = " ".join(self.builder.split())
145
+ for fragment in (
146
+ "identical on every orchestration path",
147
+ "references/report-contracts.md",
148
+ "exactly ONE JSON object",
149
+ "src/lib/reportValidator.mjs",
150
+ "semanticVerify",
151
+ ):
152
+ self.assertIn(" ".join(fragment.split()), prose)
153
+
94
154
  def test_current_portable_contracts_survive_the_port(self):
95
155
  for fragment in (
96
156
  "project layer",
@@ -101,6 +161,155 @@ class OrchestrateWaveContract(unittest.TestCase):
101
161
  ):
102
162
  self.assertIn(fragment, self.skill)
103
163
 
164
+ def test_capability_selector_routes_exactly_one_orchestration_mechanic(self):
165
+ for fragment in (
166
+ "## Orchestration mechanics",
167
+ "literal `Workflow`",
168
+ "do not emulate",
169
+ "returns exactly one",
170
+ "references/dispatch-workflow.md",
171
+ "references/dispatch-subagents.md",
172
+ "Path C",
173
+ "Phase 1 uses the selected orchestration mechanics",
174
+ "Phase 2 uses the selected orchestration mechanics",
175
+ ):
176
+ self.assertIn(fragment, self.skill)
177
+
178
+ self.assertLessEqual(len(self.skill.splitlines()), 345)
179
+
180
+ def test_registry_ownership_distinguishes_safe_from_eager_registries(self):
181
+ prose = " ".join(self.skill.split())
182
+ for fragment in (
183
+ "declaration-only registries",
184
+ "may be predeclared by one hub",
185
+ "eager/validated registries",
186
+ "each appending only its own existing artifact after creation",
187
+ "dependency edges",
188
+ ):
189
+ self.assertIn(fragment, prose)
190
+
191
+ phase_one = prose.split("## Phase 1", 1)[1].split("## Phase 2", 1)[0]
192
+ done = phase_one.split("**Done when:**", 1)[1]
193
+ for fragment in (
194
+ "either one declaration-only owner",
195
+ "verbatim consume-only dependents",
196
+ "or an explicit serialized owner sequence",
197
+ "each owner appends only its own existing artifact",
198
+ ):
199
+ self.assertIn(fragment, done)
200
+
201
+ def test_phase_two_keeps_per_slice_routing_decisions(self):
202
+ prose = " ".join(self.skill.split())
203
+ self.assertIn("(a) inline vs delegate", prose)
204
+ self.assertIn("(b) tier + effort", prose)
205
+ self.assertIn("Standing rules", prose)
206
+
207
+ def test_every_surface_dispatch_crosses_resolver_and_spawn_guard(self):
208
+ prose = " ".join(self.skill.split())
209
+ for fragment in (
210
+ "provider-neutral Routing intent",
211
+ "shared resolver",
212
+ "spawn guard",
213
+ "Claude or Codex adapter",
214
+ "routingAdapters/codex.mjs",
215
+ "Dispatch receipt",
216
+ "requested and applied route",
217
+ "environment precedence",
218
+ "detected transport is not authorization",
219
+ "AFK",
220
+ ):
221
+ self.assertIn(fragment, prose)
222
+
223
+ def test_every_direct_spawn_contract_routes_through_the_shared_guard(self):
224
+ required = (
225
+ "provider-neutral Routing intent",
226
+ "routeDispatcher.mjs",
227
+ "shared spawn guard",
228
+ "Dispatch receipt",
229
+ )
230
+ claude_actual = direct_spawn_contracts(".claude/skills")
231
+ codex_actual = direct_spawn_contracts(".agents/skills")
232
+ self.assertEqual(claude_actual, EXPECTED_DIRECT_SPAWN_CONTRACTS)
233
+ self.assertEqual(codex_actual, EXPECTED_DIRECT_SPAWN_CONTRACTS)
234
+ for relative in claude_actual:
235
+ claude = " ".join(
236
+ (REPO / ".claude/skills" / relative).read_text(encoding="utf-8").split()
237
+ )
238
+ codex = " ".join(
239
+ (REPO / ".agents/skills" / relative).read_text(encoding="utf-8").split()
240
+ )
241
+ with self.subTest(contract=relative):
242
+ for fragment in required:
243
+ self.assertIn(fragment, claude)
244
+ self.assertIn(fragment, codex)
245
+ for unsupported in ("Agent tool", "subagent_type", "agent_type"):
246
+ self.assertNotIn(unsupported, codex)
247
+
248
+ def test_path_a_reference_locks_the_two_run_dispatch_contract(self):
249
+ workflow = CLAUDE_WORKFLOW.read_text(encoding="utf-8")
250
+ for fragment in (
251
+ "meta.phases",
252
+ "one `agent()` call per slice",
253
+ "model`, `effort`, and `phase`",
254
+ "inline schema literal",
255
+ "Recon run",
256
+ "reconcileReconReports",
257
+ "Build run",
258
+ "resumeFromRunId",
259
+ "exactly once",
260
+ "journal.jsonl",
261
+ "timestamps through `args`",
262
+ "Date.now()",
263
+ "Math.random()",
264
+ "Every orchestration path",
265
+ ):
266
+ self.assertIn(fragment, workflow)
267
+
268
+ self.assertEqual(workflow, CODEX_WORKFLOW.read_text(encoding="utf-8"))
269
+
270
+ def test_path_b_reference_locks_the_native_subagent_contract(self):
271
+ subagents = CLAUDE_SUBAGENTS.read_text(encoding="utf-8")
272
+ prose = " ".join(subagents.split())
273
+ for fragment in (
274
+ "one read-only explorer per slice",
275
+ "one builder per slice",
276
+ "explicit wait",
277
+ "exactly ONE JSON object",
278
+ "reportValidator.mjs",
279
+ "reconcileReconReports",
280
+ "main thread",
281
+ "is not a PASS",
282
+ "waveClaim",
283
+ "spawn_agents_on_csv",
284
+ "output_schema",
285
+ "dormant",
286
+ "task_name`, `message`, and `fork_turns",
287
+ "no model or effort selector",
288
+ "blocks differentiated AFK",
289
+ "routingAdapters/codex.mjs",
290
+ ):
291
+ self.assertIn(" ".join(fragment.split()), prose)
292
+
293
+ self.assertEqual(subagents, CODEX_SUBAGENTS.read_text(encoding="utf-8"))
294
+
295
+ def test_codex_surface_carries_no_path_a_primitive_outside_its_pointer_target(self):
296
+ """B-surface prose must not require Workflow-only primitives."""
297
+ offenders = {}
298
+ for path in sorted(CODEX_SURFACE.rglob("*.md")):
299
+ if path.name == "dispatch-workflow.md":
300
+ continue
301
+ body = path.read_text(encoding="utf-8")
302
+ hits = [term for term in ("journal.jsonl", "resumeFromRunId") if term in body]
303
+ # The literal tool name is admissible ONLY inside the capability gate.
304
+ if "`Workflow`" in body:
305
+ gate = body.split("## Orchestration mechanics", 1)
306
+ remainder = gate[0] + (gate[1].split("\n## ", 1)[1] if len(gate) > 1 and "\n## " in gate[1] else "")
307
+ if "`Workflow`" in remainder:
308
+ hits.append("`Workflow`")
309
+ if hits:
310
+ offenders[str(path.relative_to(REPO))] = hits
311
+ self.assertEqual(offenders, {})
312
+
104
313
  def test_claude_and_codex_surfaces_match(self):
105
314
  self.assertEqual(
106
315
  markdown_body(self.skill),
@@ -11,6 +11,76 @@ class ProgramPlanningContractTest(unittest.TestCase):
11
11
  def skill(self, name: str) -> str:
12
12
  return (ROOT / ".claude" / "skills" / name / "SKILL.md").read_text()
13
13
 
14
+ def file(self, path: str) -> str:
15
+ return (ROOT / path).read_text()
16
+
17
+ def test_to_issues_is_the_public_planning_facade(self):
18
+ text = self.skill("to-issues")
19
+ self.assertIn("single public Planning facade", text)
20
+ self.assertIn("planning-mode=feature", text)
21
+ self.assertIn("planning-mode=program", text)
22
+ self.assertIn("Missing or contradictory identity", text)
23
+ self.assertIn("Size, prose, and model judgment never select the mode", text)
24
+
25
+ def test_program_identity_delegates_to_the_internal_graph_engine(self):
26
+ issues = self.skill("to-issues")
27
+ waves = self.skill("to-waves")
28
+ self.assertIn("delegate to the internal", issues)
29
+ self.assertIn("`to-waves` graph engine", issues)
30
+ self.assertIn("complete Program preview", issues)
31
+ self.assertIn("Internal Program graph engine", waves)
32
+ self.assertIn("compatibility entrypoint", waves)
33
+ self.assertIn("carries both", waves)
34
+ self.assertIn("never infer Program mode", waves)
35
+
36
+ def test_feature_identity_keeps_the_existing_decomposition_path(self):
37
+ text = self.skill("to-issues")
38
+ self.assertIn("Feature identity", text)
39
+ self.assertIn("PROMOTE", text)
40
+ self.assertIn("ATOMIC", text)
41
+
42
+ def test_normal_routes_name_only_the_planning_facade(self):
43
+ route_files = (
44
+ ".claude/skills/ask-matt/SKILL.md",
45
+ ".claude/skills/scale-check/SKILL.md",
46
+ ".claude/skills/setup-workflow/workflow-overview.md",
47
+ ".claude/skills/board-to-waves/SKILL.md",
48
+ "CLAUDE.md",
49
+ "README.md",
50
+ "docs/workflow.html",
51
+ "docs/methodology.html",
52
+ "docs/methodology.svg",
53
+ )
54
+ for path in route_files:
55
+ with self.subTest(path=path):
56
+ text = self.file(path)
57
+ self.assertIn("to-issues", text)
58
+ self.assertNotIn("→ `to-waves`", text)
59
+ self.assertNotIn("→ to-waves", text)
60
+
61
+ def test_dual_surface_planning_skills_are_coherent(self):
62
+ skill_snippets = (
63
+ ("to-issues", "single public Planning facade"),
64
+ ("to-waves", "Internal Program graph engine"),
65
+ ("ask-matt", "the user never chooses that engine"),
66
+ ("scale-check", "explicit Program identity selects the internal graph path"),
67
+ ("setup-workflow", "explicit Program identity"),
68
+ ("board-to-waves", "not directly to a hidden engine"),
69
+ )
70
+ for skill, snippet in skill_snippets:
71
+ with self.subTest(skill=skill):
72
+ claude = self.file(f".claude/skills/{skill}/SKILL.md")
73
+ codex = self.file(f".agents/skills/{skill}/SKILL.md")
74
+ self.assertIn(snippet, claude)
75
+ self.assertIn(snippet, codex)
76
+
77
+ support_files = ("workflow-overview.md", "board-sync.md")
78
+ for name in support_files:
79
+ with self.subTest(support=name):
80
+ claude = self.file(f".claude/skills/setup-workflow/{name}")
81
+ codex = self.file(f".agents/skills/setup-workflow/{name}")
82
+ self.assertEqual(claude, codex)
83
+
14
84
  def test_to_waves_finishes_with_every_wave_execute_ready_by_default(self):
15
85
  text = self.skill("to-waves")
16
86
  self.assertIn("## 7. Program completion contract", text)
@@ -55,6 +55,17 @@ ENFORCED_CLASSES = {"generic", "vendored"}
55
55
  EXEMPT = "portability-lint: ok"
56
56
  MIRROR_XFORM_START = re.compile(r"^\s*<!--\s*mirror-xform:start(?:\s+([^>]*?))?\s*-->\s*$")
57
57
  MIRROR_XFORM_END = re.compile(r"^\s*<!--\s*mirror-xform:end\s*-->\s*$")
58
+ DIRECT_SPAWN_PATTERN = re.compile(
59
+ r"Run \*\*one read-only research subagent|"
60
+ r"Run both axes as \*\*parallel sub-agents|"
61
+ r"Spawn 3\+ sub-agents|"
62
+ r"spawn 3\+ sub-agents|"
63
+ r"make 3\+ parallel\s+`spawn_agent` calls|"
64
+ r"Then use the Agent tool|"
65
+ r"use `spawn_agent` with `agent_type: explorer`|"
66
+ r"use `spawn_agent` with only|"
67
+ r"Spin up a \*\*background agent",
68
+ )
58
69
 
59
70
  # Opaque, project-coupled board constants — meaningless in a foreign repo.
60
71
  CONSTANT_PATTERNS = [
@@ -240,6 +251,15 @@ def skill_md_set(tree: str, name: str) -> set[str]:
240
251
  return {p for p in skill_file_set(tree, name) if p.endswith(".md")}
241
252
 
242
253
 
254
+ def direct_spawn_contracts(tree: str) -> dict[str, str]:
255
+ root = REPO_ROOT / tree
256
+ return {
257
+ path.relative_to(root).as_posix(): path.read_text(encoding="utf-8")
258
+ for path in root.rglob("*.md")
259
+ if DIRECT_SPAWN_PATTERN.search(path.read_text(encoding="utf-8"))
260
+ }
261
+
262
+
243
263
  def mirror_presence_drift(name: str, claude: set[str], codex: set[str]) -> list[str]:
244
264
  problems = [
245
265
  f"{name}: .claude/skills/{name}/{rel} has no codex mirror"
@@ -555,6 +575,40 @@ class MirrorContentParity(unittest.TestCase):
555
575
  "`<!-- mirror-xform:start <reason> -->` / `<!-- mirror-xform:end -->` "
556
576
  "regions:\n" + "\n".join(problems))
557
577
 
578
+ def test_every_codex_direct_spawn_contract_uses_the_shared_guard(self):
579
+ claude = direct_spawn_contracts(".claude/skills")
580
+ codex = direct_spawn_contracts(".agents/skills")
581
+ self.assertTrue(claude, "direct-spawn census unexpectedly empty")
582
+ self.assertEqual(set(codex), set(claude))
583
+
584
+ problems = []
585
+ required = (
586
+ "provider-neutral Routing intent",
587
+ "routeDispatcher.mjs",
588
+ "shared spawn guard",
589
+ "Dispatch receipt",
590
+ )
591
+ for relative, body in codex.items():
592
+ prose = " ".join(body.split())
593
+ missing = [fragment for fragment in required if fragment not in prose]
594
+ if missing:
595
+ problems.append(f"{relative}: missing {', '.join(missing)}")
596
+ unsupported = [
597
+ token for token in ("Agent tool", "subagent_type", "agent_type")
598
+ if token in prose
599
+ ]
600
+ if unsupported:
601
+ problems.append(
602
+ f"{relative}: unsupported Codex spawn syntax "
603
+ f"{', '.join(unsupported)}"
604
+ )
605
+ self.assertEqual(
606
+ problems,
607
+ [],
608
+ f"Codex direct-spawn guard coverage drift ({len(codex)} of "
609
+ f"{len(claude)}):\n" + "\n".join(problems),
610
+ )
611
+
558
612
 
559
613
  class MirrorParityFixture(unittest.TestCase):
560
614
  """The file-set comparison catches an orphaned mirror file (regression guard)."""
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Worktree reuse must refuse a stale base on BOTH reuse paths.
3
+
4
+ `setup.py create()` can reach an existing worktree two ways: the target is
5
+ already a registered worktree, or only its branch exists locally and
6
+ `git worktree add <target> <branch>` silently ignores `--base`. Reuse is safe
7
+ only when the reused HEAD is AT the base or cleanly behind it (fast-forwardable);
8
+ anything ahead or diverged builds the slice on a stale base and must STOP.
9
+ """
10
+
11
+ import importlib.util
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import unittest
17
+ from argparse import Namespace
18
+ from contextlib import contextmanager
19
+ from pathlib import Path
20
+
21
+ REPO = Path(__file__).resolve().parent.parent
22
+ LIFECYCLE = REPO / "scripts/worktree-lifecycle"
23
+
24
+ PROFILE = """{
25
+ "worktreeLifecycle": {
26
+ "enabled": true,
27
+ "worktreeRoot": ".worktrees",
28
+ "branchTemplate": "{type}/{issue}-{slug}",
29
+ "pathTemplate": "{issue}-{slug}",
30
+ "setupSteps": []
31
+ }
32
+ }
33
+ """
34
+
35
+
36
+ def load_setup():
37
+ sys.path.insert(0, str(LIFECYCLE))
38
+ try:
39
+ spec = importlib.util.spec_from_file_location(
40
+ "worktree_setup_base_guard", LIFECYCLE / "setup.py"
41
+ )
42
+ module = importlib.util.module_from_spec(spec)
43
+ spec.loader.exec_module(module)
44
+ return module
45
+ finally:
46
+ sys.path.remove(str(LIFECYCLE))
47
+
48
+
49
+ def git(repo: Path, *args: str) -> str:
50
+ result = subprocess.run(
51
+ ["git", *args], cwd=repo, capture_output=True, text=True, check=True
52
+ )
53
+ return result.stdout.strip()
54
+
55
+
56
+ @contextmanager
57
+ def fixture():
58
+ """A repo whose `main` carries two commits: base_first, then head."""
59
+ with tempfile.TemporaryDirectory() as tmp:
60
+ repo = Path(tmp).resolve() / "repo"
61
+ repo.mkdir()
62
+ git(repo, "init", "--initial-branch=main")
63
+ git(repo, "config", "user.email", "test@example.invalid")
64
+ git(repo, "config", "user.name", "Test User")
65
+ profile = repo / "docs/agents/workflow-capabilities.json"
66
+ profile.parent.mkdir(parents=True)
67
+ profile.write_text(PROFILE, encoding="utf-8")
68
+ (repo / "README.md").write_text("# fixture\n", encoding="utf-8")
69
+ git(repo, "add", "-A")
70
+ git(repo, "commit", "-m", "first")
71
+ first = git(repo, "rev-parse", "HEAD")
72
+ (repo / "README.md").write_text("# fixture 2\n", encoding="utf-8")
73
+ git(repo, "commit", "-am", "second")
74
+ second = git(repo, "rev-parse", "HEAD")
75
+ yield repo, first, second
76
+
77
+
78
+ @contextmanager
79
+ def chdir(path: Path):
80
+ previous = Path.cwd()
81
+ os.chdir(path)
82
+ try:
83
+ yield
84
+ finally:
85
+ os.chdir(previous)
86
+
87
+
88
+ def args_for(base: str) -> Namespace:
89
+ return Namespace(
90
+ profile="docs/agents/workflow-capabilities.json",
91
+ base=base,
92
+ issue="173",
93
+ slug="wave-safety",
94
+ branch_type="feat",
95
+ )
96
+
97
+
98
+ class WorktreeReuseBaseGuard(unittest.TestCase):
99
+ def test_registered_worktree_reuse_stops_when_head_is_not_at_base(self):
100
+ setup = load_setup()
101
+ with fixture() as (repo, first, second):
102
+ target = repo / ".worktrees/173-wave-safety"
103
+ git(repo, "worktree", "add", "-b", "feat/173-wave-safety", str(target), second)
104
+ # base is the OLDER commit -> the registered worktree is ahead of it.
105
+ with chdir(repo):
106
+ with self.assertRaises(setup.LifecycleError) as stopped:
107
+ setup.create(args_for(first))
108
+ self.assertIn("stale", str(stopped.exception).lower())
109
+
110
+ def test_existing_branch_reuse_stops_when_branch_is_not_at_base(self):
111
+ setup = load_setup()
112
+ with fixture() as (repo, first, second):
113
+ git(repo, "branch", "feat/173-wave-safety", second)
114
+ target = repo / ".worktrees/173-wave-safety"
115
+ with chdir(repo):
116
+ with self.assertRaises(setup.LifecycleError) as stopped:
117
+ setup.create(args_for(first))
118
+ self.assertIn("stale", str(stopped.exception).lower())
119
+ self.assertFalse(target.exists(), "no worktree may be created on a stale base")
120
+
121
+ def test_registered_worktree_reuse_allows_head_at_base(self):
122
+ setup = load_setup()
123
+ with fixture() as (repo, _first, second):
124
+ target = repo / ".worktrees/173-wave-safety"
125
+ git(repo, "worktree", "add", "-b", "feat/173-wave-safety", str(target), second)
126
+ with chdir(repo):
127
+ self.assertEqual(setup.create(args_for(second)).resolve(), target.resolve())
128
+
129
+ def test_existing_branch_reuse_allows_a_branch_cleanly_behind_base(self):
130
+ setup = load_setup()
131
+ with fixture() as (repo, first, second):
132
+ git(repo, "branch", "feat/173-wave-safety", first)
133
+ target = repo / ".worktrees/173-wave-safety"
134
+ with chdir(repo):
135
+ self.assertEqual(setup.create(args_for(second)).resolve(), target.resolve())
136
+ self.assertTrue(target.is_dir())
137
+
138
+
139
+ if __name__ == "__main__":
140
+ unittest.main()
@@ -84,6 +84,39 @@ def execute_step(
84
84
  raise LifecycleError(f"unsupported setup step kind: {kind!r}")
85
85
 
86
86
 
87
+ def commit_oid(repo: Path, rev: str) -> str | None:
88
+ result = run(["git", "rev-parse", "--verify", f"{rev}^{{commit}}"], cwd=repo, check=False)
89
+ return result.stdout.strip() if result.returncode == 0 else None
90
+
91
+
92
+ def ensure_reusable_base(main: Path, *, repo: Path, rev: str, base: str, label: str) -> None:
93
+ """Reuse is safe only AT the base or cleanly behind it; anything else is stale.
94
+
95
+ Both reuse paths skip `git worktree add`'s base argument, so without this
96
+ guard a slice silently builds on whatever the old branch/worktree pointed at.
97
+ """
98
+ base_oid = commit_oid(main, base)
99
+ if base_oid is None:
100
+ raise LifecycleError(f"base {base!r} is not resolvable")
101
+ current = commit_oid(repo, rev)
102
+ if current is None:
103
+ raise LifecycleError(f"{label}: HEAD is not resolvable")
104
+ if current == base_oid:
105
+ return
106
+ behind = run(
107
+ ["git", "merge-base", "--is-ancestor", current, base_oid],
108
+ cwd=main,
109
+ check=False,
110
+ )
111
+ if behind.returncode == 0:
112
+ return
113
+ raise LifecycleError(
114
+ f"{label}: HEAD {current[:7]} is stale against base {base} ({base_oid[:7]}) — "
115
+ "it is ahead of or diverged from the base. Land or rebase that work, or remove "
116
+ "the worktree/branch, before reusing it."
117
+ )
118
+
119
+
87
120
  def remove_failed_worktree(main: Path, target: Path, branch: str, created_branch: bool) -> None:
88
121
  run(["git", "worktree", "remove", "--force", str(target)], cwd=main, check=False)
89
122
  if created_branch:
@@ -101,10 +134,17 @@ def create(args: argparse.Namespace) -> Path:
101
134
  target = (main / profile.relative_path(args.issue, args.slug, args.branch_type)).resolve()
102
135
 
103
136
  if target in registered_worktrees(main):
137
+ ensure_reusable_base(
138
+ main, repo=target, rev="HEAD", base=args.base, label=f"worktree {target}"
139
+ )
104
140
  print(f"Worktree already exists: {target} ({branch})")
105
141
  return target
106
142
 
107
143
  branch_existed = local_branch_exists(main, branch)
144
+ if branch_existed:
145
+ ensure_reusable_base(
146
+ main, repo=main, rev=branch, base=args.base, label=f"branch {branch}"
147
+ )
108
148
  command = ["git", "worktree", "add", str(target)]
109
149
  command += [branch] if branch_existed else ["-b", branch, args.base]
110
150
  run(command, cwd=main)