@ikon85/agent-workflow-kit 0.33.0 → 0.34.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 (85) 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-release/SKILL.md +29 -4
  10. package/.agents/skills/kit-update/SKILL.md +13 -0
  11. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  12. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  13. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  14. package/.agents/skills/research/SKILL.md +6 -0
  15. package/.agents/skills/scale-check/SKILL.md +9 -7
  16. package/.agents/skills/setup-workflow/SKILL.md +47 -1
  17. package/.agents/skills/setup-workflow/board-sync.md +7 -2
  18. package/.agents/skills/setup-workflow/workflow-overview.md +1 -2
  19. package/.agents/skills/to-issues/SKILL.md +66 -8
  20. package/.agents/skills/to-waves/SKILL.md +24 -12
  21. package/.claude/skills/ask-matt/SKILL.md +4 -3
  22. package/.claude/skills/audit-skills/SKILL.md +6 -0
  23. package/.claude/skills/board-to-waves/SKILL.md +6 -2
  24. package/.claude/skills/code-review/SKILL.md +6 -0
  25. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +8 -0
  26. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  27. package/.claude/skills/improve-codebase-architecture/SKILL.md +6 -0
  28. package/.claude/skills/kit-release/SKILL.md +29 -4
  29. package/.claude/skills/kit-update/SKILL.md +13 -0
  30. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  31. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  32. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  33. package/.claude/skills/research/SKILL.md +6 -0
  34. package/.claude/skills/scale-check/SKILL.md +9 -7
  35. package/.claude/skills/setup-workflow/SKILL.md +47 -1
  36. package/.claude/skills/setup-workflow/board-sync.md +7 -2
  37. package/.claude/skills/setup-workflow/workflow-overview.md +1 -2
  38. package/.claude/skills/to-issues/SKILL.md +66 -8
  39. package/.claude/skills/to-waves/SKILL.md +24 -12
  40. package/README.md +89 -13
  41. package/agent-workflow-kit.package.json +204 -44
  42. package/docs/adr/0004-release-intent-is-a-version-tag.md +42 -0
  43. package/docs/adr/0005-to-issues-is-the-planning-facade.md +42 -0
  44. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +91 -0
  45. package/docs/agents/skills/local-ci.md +89 -0
  46. package/docs/agents/wave-anchor-template.md +2 -2
  47. package/docs/methodology.html +1 -1
  48. package/docs/methodology.svg +1 -1
  49. package/docs/workflow.html +2 -2
  50. package/docs/workflow.png +0 -0
  51. package/package.json +1 -1
  52. package/scripts/codex-exec.sh +47 -8
  53. package/scripts/codex-exec.test.mjs +56 -0
  54. package/scripts/kit-release.test.mjs +178 -0
  55. package/scripts/release-state.mjs +35 -5
  56. package/scripts/release-state.test.mjs +122 -5
  57. package/scripts/test_codex_adapter_sync_contract.py +30 -15
  58. package/scripts/test_orchestrate_wave_contract.py +73 -0
  59. package/scripts/test_program_planning_contract.py +70 -0
  60. package/scripts/test_skill_portability_lint.py +54 -0
  61. package/src/cli.mjs +109 -2
  62. package/src/commands/init.mjs +17 -1
  63. package/src/commands/routing-policy-update.mjs +204 -0
  64. package/src/commands/update.mjs +22 -0
  65. package/src/lib/agentSurfaceRegistry.mjs +60 -0
  66. package/src/lib/bundle.mjs +24 -0
  67. package/src/lib/capabilityMatrix.mjs +85 -0
  68. package/src/lib/dispatchReceipt.mjs +162 -0
  69. package/src/lib/frontendWorkloads.mjs +170 -0
  70. package/src/lib/routeDispatcher.mjs +158 -0
  71. package/src/lib/routingAccessGraph.mjs +105 -0
  72. package/src/lib/routingAdapters/claude.mjs +62 -0
  73. package/src/lib/routingAdapters/codex.mjs +136 -0
  74. package/src/lib/routingCatalog.mjs +123 -0
  75. package/src/lib/routingEvidenceCache.mjs +222 -0
  76. package/src/lib/routingIntent.mjs +93 -0
  77. package/src/lib/routingPolicy.mjs +67 -0
  78. package/src/lib/routingProfile.mjs +334 -0
  79. package/src/lib/routingResolver.mjs +176 -0
  80. package/src/lib/routingSources/artificialAnalysis.mjs +129 -0
  81. package/src/lib/routingSources/benchlm.mjs +151 -0
  82. package/src/lib/routingSources/codeArena.mjs +162 -0
  83. package/src/lib/routingSources/deepswe.mjs +106 -0
  84. package/src/lib/routingSources/openhands.mjs +102 -0
  85. package/src/lib/routingSources/openhandsFrontend.mjs +135 -0
@@ -2,11 +2,18 @@ import { execFile } from 'node:child_process';
2
2
  import { mkdtemp, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
5
6
  import { promisify } from 'node:util';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { assertReleaseParity, releaseIdentityFromTarball } from './release-parity.mjs';
8
9
 
9
10
  const exec = promisify(execFile);
11
+ const DEFAULT_VISIBILITY = {
12
+ attempts: 6,
13
+ initialDelayMs: 1_000,
14
+ backoffFactor: 2,
15
+ sleep,
16
+ };
10
17
 
11
18
  const assertMatches = (local, remote, label) => assertReleaseParity({
12
19
  local,
@@ -28,7 +35,24 @@ export async function inspectRelease(adapter) {
28
35
  return { status: 'released', identity };
29
36
  }
30
37
 
31
- export async function reconcileRelease(adapter) {
38
+ async function awaitVisibility(read, phase, options = {}) {
39
+ const policy = { ...DEFAULT_VISIBILITY, ...options };
40
+ let delay = policy.initialDelayMs;
41
+ for (let attempt = 1; attempt <= policy.attempts; attempt += 1) {
42
+ const visible = await read();
43
+ if (visible) return visible;
44
+ if (attempt < policy.attempts) {
45
+ await policy.sleep(delay);
46
+ delay *= policy.backoffFactor;
47
+ }
48
+ }
49
+ throw new Error(
50
+ `${phase.operation} succeeded but ${phase.subject} was not visible `
51
+ + `after ${policy.attempts} ${phase.service} read attempts`,
52
+ );
53
+ }
54
+
55
+ export async function reconcileRelease(adapter, { visibility } = {}) {
32
56
  const { identity, tarball } = await adapter.local();
33
57
  let npm = await adapter.npm(identity);
34
58
  let github = await adapter.github(identity);
@@ -36,15 +60,21 @@ export async function reconcileRelease(adapter) {
36
60
  if (!npm) {
37
61
  if (github) throw new Error('GitHub release exists before npm package');
38
62
  await adapter.publishNpm({ identity, tarball });
39
- npm = await adapter.npm(identity);
40
- if (!npm) throw new Error('npm package missing after publish');
63
+ npm = await awaitVisibility(() => adapter.npm(identity), {
64
+ service: 'npm',
65
+ operation: 'npm publish',
66
+ subject: 'package',
67
+ }, visibility);
41
68
  }
42
69
  assertMatches(identity, npm, 'npm');
43
70
 
44
71
  if (!github) {
45
72
  await adapter.createGithub({ identity, tarball });
46
- github = await adapter.github(identity);
47
- if (!github) throw new Error('GitHub release missing after create');
73
+ github = await awaitVisibility(() => adapter.github(identity), {
74
+ service: 'GitHub',
75
+ operation: 'GitHub release creation',
76
+ subject: 'release',
77
+ }, visibility);
48
78
  }
49
79
  assertReleaseParity({ local: identity, npm, github });
50
80
  return { status: 'released', identity };
@@ -18,17 +18,62 @@ const identity = {
18
18
  tarballIntegrity: 'sha512-example', manifestSha256: 'abc123',
19
19
  };
20
20
 
21
- function adapter({ npmPublished = false, githubReleased = false, npmIdentity = identity } = {}) {
21
+ function adapter({
22
+ npmPublished = false,
23
+ githubReleased = false,
24
+ npmIdentity = identity,
25
+ npmInvisibleReads = 0,
26
+ githubInvisibleReads = 0,
27
+ publishNpmError,
28
+ createGithubError,
29
+ } = {}) {
22
30
  const events = [];
23
31
  let npmExists = npmPublished;
24
32
  let githubExists = githubReleased;
33
+ let npmReadsUntilVisible = npmInvisibleReads;
34
+ let githubReadsUntilVisible = githubInvisibleReads;
25
35
  return {
26
36
  events,
27
37
  local: async () => ({ identity, tarball: '/tmp/kit.tgz' }),
28
- npm: async () => { events.push('read npm'); return npmExists ? npmIdentity : null; },
29
- github: async () => { events.push('read github'); return githubExists ? identity : null; },
30
- publishNpm: async () => { events.push('publish npm'); npmExists = true; },
31
- createGithub: async () => { events.push('create github'); githubExists = true; },
38
+ npm: async () => {
39
+ events.push('read npm');
40
+ if (!npmExists) return null;
41
+ if (npmReadsUntilVisible > 0) {
42
+ npmReadsUntilVisible -= 1;
43
+ return null;
44
+ }
45
+ return npmIdentity;
46
+ },
47
+ github: async () => {
48
+ events.push('read github');
49
+ if (!githubExists) return null;
50
+ if (githubReadsUntilVisible > 0) {
51
+ githubReadsUntilVisible -= 1;
52
+ return null;
53
+ }
54
+ return identity;
55
+ },
56
+ publishNpm: async () => {
57
+ events.push('publish npm');
58
+ if (publishNpmError) throw publishNpmError;
59
+ npmExists = true;
60
+ },
61
+ createGithub: async () => {
62
+ events.push('create github');
63
+ if (createGithubError) throw createGithubError;
64
+ githubExists = true;
65
+ },
66
+ };
67
+ }
68
+
69
+ function visibilityOptions(attempts, delays = []) {
70
+ return {
71
+ visibility: {
72
+ attempts,
73
+ initialDelayMs: 10,
74
+ backoffFactor: 2,
75
+ sleep: async (delay) => delays.push(delay),
76
+ },
32
77
  };
33
78
  }
34
79
 
@@ -40,6 +85,72 @@ test('a fresh release publishes npm, verifies registry readback, then creates Gi
40
85
  ]);
41
86
  });
42
87
 
88
+ test('npm visibility is retried with bounded backoff after a successful publish', async () => {
89
+ const fixture = adapter({ npmInvisibleReads: 2 });
90
+ const delays = [];
91
+ assert.deepEqual(
92
+ await reconcileRelease(fixture, visibilityOptions(4, delays)),
93
+ { status: 'released', identity },
94
+ );
95
+ assert.deepEqual(fixture.events, [
96
+ 'read npm', 'read github', 'publish npm',
97
+ 'read npm', 'read npm', 'read npm',
98
+ 'create github', 'read github',
99
+ ]);
100
+ assert.deepEqual(delays, [10, 20]);
101
+ });
102
+
103
+ test('GitHub visibility is retried with bounded backoff after a successful create', async () => {
104
+ const fixture = adapter({ npmPublished: true, githubInvisibleReads: 2 });
105
+ const delays = [];
106
+ assert.deepEqual(
107
+ await reconcileRelease(fixture, visibilityOptions(4, delays)),
108
+ { status: 'released', identity },
109
+ );
110
+ assert.deepEqual(fixture.events, [
111
+ 'read npm', 'read github', 'create github', 'read github', 'read github', 'read github',
112
+ ]);
113
+ assert.deepEqual(delays, [10, 20]);
114
+ });
115
+
116
+ test('permanent npm absence fails at the bound with post-publish phase evidence', async () => {
117
+ const fixture = adapter({ npmInvisibleReads: 4 });
118
+ const delays = [];
119
+ await assert.rejects(
120
+ reconcileRelease(fixture, visibilityOptions(3, delays)),
121
+ /npm publish succeeded but package was not visible after 3 npm read attempts/,
122
+ );
123
+ assert.deepEqual(delays, [10, 20]);
124
+ assert.equal(fixture.events.filter((event) => event === 'read npm').length, 4);
125
+ assert.ok(!fixture.events.includes('create github'));
126
+ });
127
+
128
+ test('permanent GitHub absence fails at the bound with post-create phase evidence', async () => {
129
+ const fixture = adapter({ npmPublished: true, githubInvisibleReads: 4 });
130
+ const delays = [];
131
+ await assert.rejects(
132
+ reconcileRelease(fixture, visibilityOptions(3, delays)),
133
+ /GitHub release creation succeeded but release was not visible after 3 GitHub read attempts/,
134
+ );
135
+ assert.deepEqual(delays, [10, 20]);
136
+ assert.equal(fixture.events.filter((event) => event === 'read github').length, 4);
137
+ assert.ok(!fixture.events.includes('publish npm'));
138
+ });
139
+
140
+ test('a rejected publish is not reported as a post-publish visibility timeout', async () => {
141
+ const rejected = new Error('registry rejected publish');
142
+ const fixture = adapter({ publishNpmError: rejected });
143
+ await assert.rejects(reconcileRelease(fixture, visibilityOptions(3)), (error) => error === rejected);
144
+ assert.deepEqual(fixture.events, ['read npm', 'read github', 'publish npm']);
145
+ });
146
+
147
+ test('a rejected GitHub create is not reported as a post-create visibility timeout', async () => {
148
+ const rejected = new Error('GitHub rejected release create');
149
+ const fixture = adapter({ npmPublished: true, createGithubError: rejected });
150
+ await assert.rejects(reconcileRelease(fixture, visibilityOptions(3)), (error) => error === rejected);
151
+ assert.deepEqual(fixture.events, ['read npm', 'read github', 'create github']);
152
+ });
153
+
43
154
  test('an npm-published release resumes at GitHub without a second npm publish', async () => {
44
155
  const fixture = adapter({ npmPublished: true });
45
156
  await reconcileRelease(fixture);
@@ -47,6 +158,12 @@ test('an npm-published release resumes at GitHub without a second npm publish',
47
158
  assert.ok(!fixture.events.includes('publish npm'));
48
159
  });
49
160
 
161
+ test('an already-published release at parity is a read-only no-op', async () => {
162
+ const fixture = adapter({ npmPublished: true, githubReleased: true });
163
+ assert.deepEqual(await reconcileRelease(fixture), { status: 'released', identity });
164
+ assert.deepEqual(fixture.events, ['read npm', 'read github']);
165
+ });
166
+
50
167
  test('post-merge status inspection is read-only and reports the reconstructable phase', async () => {
51
168
  const fixture = adapter({ npmPublished: true });
52
169
  assert.deepEqual(await inspectRelease(fixture), { status: 'awaiting-github', identity });
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
 
9
9
  REPO_ROOT = Path(__file__).resolve().parent.parent
10
10
  ADAPTER = REPO_ROOT / ".agents/skills/codex-adapter-sync/SKILL.md"
11
+ AGENTS_README = REPO_ROOT / ".codex/agents/README.md"
11
12
  _FRONTMATTER_SPEC = importlib.util.spec_from_file_location(
12
13
  "skill_frontmatter_lint", REPO_ROOT / "scripts/test_skill_frontmatter_lint.py")
13
14
  frontmatter = importlib.util.module_from_spec(_FRONTMATTER_SPEC)
@@ -39,27 +40,24 @@ class AdapterModesContract(unittest.TestCase):
39
40
  self.assertNotIn("before inventory or edits", self.body)
40
41
 
41
42
 
42
- class AdapterModelContract(unittest.TestCase):
43
+ class AdapterRoutingContract(unittest.TestCase):
43
44
  @classmethod
44
45
  def setUpClass(cls):
45
46
  cls.body = ADAPTER.read_text(encoding="utf-8")
46
47
 
47
- def test_current_models_inherit_by_default_and_route_by_task_shape(self):
48
- routing = " ".join(section(self.body, "Model routing").split())
49
-
50
- self.assertIn("inherited parent model configuration", routing)
51
- self.assertIn("`gpt-5.6-sol`", routing)
52
- self.assertIn("complex, open-ended judgment work", routing)
53
- self.assertIn("`gpt-5.6-terra`", routing)
54
- self.assertIn("everyday tool-using development", routing)
55
- self.assertIn("`gpt-5.6-luna`", routing)
56
- self.assertIn("clear, repeatable, high-volume work", routing)
57
- self.assertIn("`model_reasoning_effort`", routing)
48
+ def test_durable_routing_is_provider_neutral_and_resolved_at_dispatch(self):
49
+ routing = " ".join(section(self.body, "Routing intent").split())
50
+
51
+ self.assertIn("`routing-intent`", routing)
52
+ self.assertIn("`reasoning-intent`", routing)
53
+ self.assertIn("Evidence catalog", routing)
54
+ self.assertIn("Access graph", routing)
55
+ self.assertIn("Routing policy", routing)
56
+ self.assertIn("dispatch time", routing)
57
+ self.assertIn("explicit `inherit`", routing)
58
+ self.assertIn("Dispatch receipt", routing)
58
59
  self.assertIn("`default`, `worker`, and `explorer`", routing)
59
- self.assertIn("not a fourth concrete variant", routing)
60
60
 
61
- for stale in ("gpt-5.4-mini", "gpt-5.5"):
62
- self.assertNotIn(stale, self.body)
63
61
  self.assertNotRegex(self.body, r"(?<!model_)\breasoning_effort\b")
64
62
  self.assertNotRegex(self.body, r"\bagent_type\b")
65
63
 
@@ -104,6 +102,23 @@ class AdapterAgentContract(unittest.TestCase):
104
102
  self.assertIn("Parse every `.codex/agents/*.toml`", agents)
105
103
  self.assertIn("Reject a file", agents)
106
104
 
105
+ def test_codex_host_inventory_is_dated_and_does_not_invent_spawn_selectors(self):
106
+ inventory = " ".join(
107
+ AGENTS_README.read_text(encoding="utf-8").split()
108
+ )
109
+ for fragment in (
110
+ "2026-07-23",
111
+ "`task_name`",
112
+ "`message`",
113
+ "`fork_turns`",
114
+ "no per-spawn `model`",
115
+ "no per-spawn effort selector",
116
+ "`src/lib/routingAdapters/codex.mjs`",
117
+ "blocks differentiated AFK",
118
+ "Dispatch receipt v2",
119
+ ):
120
+ self.assertIn(fragment, inventory)
121
+
107
122
 
108
123
  class AdapterValidationContract(unittest.TestCase):
109
124
  @classmethod
@@ -34,6 +34,25 @@ CODEX_SUBAGENTS = (
34
34
  REPO / ".agents/skills/orchestrate-wave/references/dispatch-subagents.md"
35
35
  )
36
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
+ )
37
56
 
38
57
 
39
58
  # Outcome -> fragments whose conjunction proves that portable behavior.
@@ -86,6 +105,15 @@ def markdown_body(text: str) -> str:
86
105
  return text.split("\n---\n", 1)[1]
87
106
 
88
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
+
89
117
  class OrchestrateWaveContract(unittest.TestCase):
90
118
  @classmethod
91
119
  def setUpClass(cls):
@@ -176,6 +204,47 @@ class OrchestrateWaveContract(unittest.TestCase):
176
204
  self.assertIn("(b) tier + effort", prose)
177
205
  self.assertIn("Standing rules", prose)
178
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
+
179
248
  def test_path_a_reference_locks_the_two_run_dispatch_contract(self):
180
249
  workflow = CLAUDE_WORKFLOW.read_text(encoding="utf-8")
181
250
  for fragment in (
@@ -214,6 +283,10 @@ class OrchestrateWaveContract(unittest.TestCase):
214
283
  "spawn_agents_on_csv",
215
284
  "output_schema",
216
285
  "dormant",
286
+ "task_name`, `message`, and `fork_turns",
287
+ "no model or effort selector",
288
+ "blocks differentiated AFK",
289
+ "routingAdapters/codex.mjs",
217
290
  ):
218
291
  self.assertIn(" ".join(fragment.split()), prose)
219
292
 
@@ -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)."""