@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.
- package/.agents/skills/ask-matt/SKILL.md +4 -3
- package/.agents/skills/audit-skills/SKILL.md +6 -0
- package/.agents/skills/board-to-waves/SKILL.md +6 -2
- package/.agents/skills/code-review/SKILL.md +6 -0
- package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +11 -1
- package/.agents/skills/codex-adapter-sync/SKILL.md +23 -19
- package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
- package/.agents/skills/improve-codebase-architecture/SKILL.md +10 -1
- package/.agents/skills/kit-update/SKILL.md +13 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +54 -54
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +16 -3
- package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +91 -0
- package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +66 -0
- package/.agents/skills/orchestrate-wave/references/report-contracts.md +42 -0
- package/.agents/skills/research/SKILL.md +6 -0
- package/.agents/skills/scale-check/SKILL.md +9 -7
- package/.agents/skills/setup-workflow/SKILL.md +47 -1
- package/.agents/skills/setup-workflow/board-sync.md +7 -2
- package/.agents/skills/setup-workflow/workflow-overview.md +1 -2
- package/.agents/skills/to-issues/SKILL.md +66 -8
- package/.agents/skills/to-waves/SKILL.md +24 -12
- package/.claude/skills/ask-matt/SKILL.md +4 -3
- package/.claude/skills/audit-skills/SKILL.md +6 -0
- package/.claude/skills/board-to-waves/SKILL.md +6 -2
- package/.claude/skills/code-review/SKILL.md +6 -0
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +8 -0
- package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +6 -0
- package/.claude/skills/kit-update/SKILL.md +13 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +54 -54
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +16 -3
- package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +91 -0
- package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +66 -0
- package/.claude/skills/orchestrate-wave/references/report-contracts.md +42 -0
- package/.claude/skills/research/SKILL.md +6 -0
- package/.claude/skills/scale-check/SKILL.md +9 -7
- package/.claude/skills/setup-workflow/SKILL.md +47 -1
- package/.claude/skills/setup-workflow/board-sync.md +7 -2
- package/.claude/skills/setup-workflow/workflow-overview.md +1 -2
- package/.claude/skills/to-issues/SKILL.md +66 -8
- package/.claude/skills/to-waves/SKILL.md +24 -12
- package/README.md +126 -11
- package/agent-workflow-kit.package.json +291 -39
- package/docs/adr/0002-capability-gated-orchestration.md +70 -0
- package/docs/adr/0005-to-issues-is-the-planning-facade.md +42 -0
- package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +91 -0
- package/docs/agents/skills/local-ci.md +89 -0
- package/docs/agents/wave-anchor-template.md +2 -2
- package/docs/methodology.html +1 -1
- package/docs/methodology.svg +1 -1
- package/docs/workflow.html +2 -2
- package/docs/workflow.png +0 -0
- package/package.json +1 -1
- package/scripts/codex-exec.sh +47 -8
- package/scripts/codex-exec.test.mjs +56 -0
- package/scripts/test_codex_adapter_sync_contract.py +30 -15
- package/scripts/test_orchestrate_wave_contract.py +209 -0
- package/scripts/test_program_planning_contract.py +70 -0
- package/scripts/test_skill_portability_lint.py +54 -0
- package/scripts/test_worktree_setup_base_guard.py +140 -0
- package/scripts/worktree-lifecycle/setup.py +40 -0
- package/src/cli.mjs +109 -2
- package/src/commands/init.mjs +17 -1
- package/src/commands/routing-policy-update.mjs +204 -0
- package/src/commands/update.mjs +22 -0
- package/src/lib/agentSurfaceRegistry.mjs +60 -0
- package/src/lib/bundle.mjs +34 -0
- package/src/lib/capabilityMatrix.mjs +179 -0
- package/src/lib/dispatchReceipt.mjs +162 -0
- package/src/lib/frontendWorkloads.mjs +170 -0
- package/src/lib/reconcileReconReports.mjs +111 -0
- package/src/lib/reportValidator.mjs +186 -0
- package/src/lib/routeDispatcher.mjs +158 -0
- package/src/lib/routingAccessGraph.mjs +105 -0
- package/src/lib/routingAdapters/claude.mjs +62 -0
- package/src/lib/routingAdapters/codex.mjs +136 -0
- package/src/lib/routingCatalog.mjs +123 -0
- package/src/lib/routingEvidenceCache.mjs +222 -0
- package/src/lib/routingIntent.mjs +93 -0
- package/src/lib/routingPolicy.mjs +67 -0
- package/src/lib/routingProfile.mjs +334 -0
- package/src/lib/routingResolver.mjs +176 -0
- package/src/lib/routingSources/artificialAnalysis.mjs +129 -0
- package/src/lib/routingSources/benchlm.mjs +151 -0
- package/src/lib/routingSources/codeArena.mjs +162 -0
- package/src/lib/routingSources/deepswe.mjs +106 -0
- package/src/lib/routingSources/openhands.mjs +102 -0
- package/src/lib/routingSources/openhandsFrontend.mjs +135 -0
- 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)
|