@ikon85/agent-workflow-kit 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -1,180 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Behavior contract for required-readiness planning preflights."""
3
- from __future__ import annotations
4
-
5
- import json
6
- import subprocess
7
- import tempfile
8
- import unittest
9
- from pathlib import Path
10
-
11
- ROOT = Path(__file__).resolve().parents[1]
12
- READINESS = ROOT / "scripts/readiness.mjs"
13
- MANIFEST = ROOT / ".claude/skills/skill-manifest.json"
14
- SKILLS = ("to-prd", "to-issues", "to-waves", "board-to-waves")
15
- SPEC_SKILLS = ("to-issues", "to-waves")
16
- PREFLIGHT_START = "<!-- readiness:required-preflight:start -->"
17
- PREFLIGHT_END = "<!-- readiness:required-preflight:end -->"
18
-
19
-
20
- def write(root: Path, relative: str, body: str) -> None:
21
- path = root / relative
22
- path.parent.mkdir(parents=True, exist_ok=True)
23
- path.write_text(body, encoding="utf-8")
24
-
25
-
26
- def configure_issue_tracker(root: Path) -> None:
27
- write(
28
- root,
29
- "docs/agents/issue-tracker.md",
30
- "<!-- setup-workflow: state=filled -->\nGitHub Issues.\n",
31
- )
32
-
33
-
34
- def configure_board(root: Path) -> None:
35
- profile = {
36
- "repo": "owner/repo",
37
- "project": {"owner": "owner", "number": 1, "nodeId": "project"},
38
- "fields": {
39
- "status": {
40
- "id": "status",
41
- "options": {"Todo": "todo"},
42
- "roles": {"triaged": "Todo"},
43
- },
44
- "wave": "wave",
45
- "cluster": "cluster",
46
- },
47
- "labels": {"readyForAgent": "ready-for-agent"},
48
- }
49
- write(
50
- root,
51
- "docs/agents/board-sync.md",
52
- "<!-- board-sync:profile -->\n```json\n"
53
- f"{json.dumps(profile)}\n```\n",
54
- )
55
-
56
-
57
- def configure_spec_completeness(root: Path) -> None:
58
- write(
59
- root,
60
- "docs/conventions/spec-completeness.md",
61
- "<!-- setup-workflow: state=filled -->\nVertical slice rules.\n",
62
- )
63
-
64
-
65
- class PlanningReadinessPreflightTests(unittest.TestCase):
66
- def setUp(self) -> None:
67
- self.temp = tempfile.TemporaryDirectory()
68
- self.root = Path(self.temp.name)
69
- write(
70
- self.root,
71
- ".claude/skills/skill-manifest.json",
72
- MANIFEST.read_text(encoding="utf-8"),
73
- )
74
- write(
75
- self.root,
76
- "agent-workflow-kit.json",
77
- json.dumps({
78
- "kitVersion": "0.0.0-test",
79
- "readinessContractVersion": 1,
80
- "readinessDecisions": {},
81
- "installed": [],
82
- }),
83
- )
84
-
85
- def tearDown(self) -> None:
86
- self.temp.cleanup()
87
-
88
- def check(self, skill: str) -> dict:
89
- completed = subprocess.run(
90
- ["node", str(READINESS), "check", "--skill", skill, "--json", "--root", str(self.root)],
91
- check=True,
92
- capture_output=True,
93
- text=True,
94
- )
95
- return json.loads(completed.stdout)
96
-
97
- def snapshot(self) -> dict[Path, bytes]:
98
- return {
99
- path.relative_to(self.root): path.read_bytes()
100
- for path in self.root.rglob("*") if path.is_file()
101
- }
102
-
103
- def test_missing_requirements_block_without_writing_fixture(self) -> None:
104
- before = self.snapshot()
105
- for skill in SKILLS:
106
- with self.subTest(skill=skill):
107
- result = self.check(skill)
108
- self.assertEqual(result["verdict"], "blocked")
109
- self.assertEqual(result["capabilities"]["issueTracker"]["state"], "missing")
110
- self.assertEqual(result["capabilities"]["managedBoard"]["state"], "missing")
111
- self.assertEqual(self.snapshot(), before)
112
-
113
- def test_invalid_requirement_is_visible_and_check_remains_read_only(self) -> None:
114
- configure_board(self.root)
115
- configure_spec_completeness(self.root)
116
- write(
117
- self.root,
118
- "docs/agents/issue-tracker.md",
119
- "<!-- setup-workflow: state=stub -->\npartial\n",
120
- )
121
- before = self.snapshot()
122
- result = self.check("to-prd")
123
- self.assertEqual(result["verdict"], "blocked")
124
- self.assertEqual(result["capabilities"]["issueTracker"]["state"], "invalid")
125
- self.assertEqual(self.snapshot(), before)
126
-
127
- def test_managed_board_not_applicable_makes_every_board_writer_inapplicable(self) -> None:
128
- configure_issue_tracker(self.root)
129
- configure_spec_completeness(self.root)
130
- manifest = json.loads((self.root / "agent-workflow-kit.json").read_text(encoding="utf-8"))
131
- manifest["readinessDecisions"]["managedBoard"] = "not-applicable"
132
- write(self.root, "agent-workflow-kit.json", json.dumps(manifest))
133
- for skill in SKILLS:
134
- with self.subTest(skill=skill):
135
- result = self.check(skill)
136
- self.assertEqual(result["verdict"], "blocked")
137
- self.assertEqual(
138
- result["capabilities"]["managedBoard"]["state"],
139
- "not-applicable",
140
- )
141
-
142
- def test_spec_completeness_blocks_only_the_slicing_flows(self) -> None:
143
- configure_issue_tracker(self.root)
144
- configure_board(self.root)
145
- for skill in SKILLS:
146
- with self.subTest(skill=skill):
147
- result = self.check(skill)
148
- expected = "blocked" if skill in SPEC_SKILLS else "ready"
149
- self.assertEqual(result["verdict"], expected)
150
- if skill in SPEC_SKILLS:
151
- self.assertEqual(
152
- result["capabilities"]["specCompleteness"]["state"],
153
- "missing",
154
- )
155
-
156
- def test_ready_state_and_skill_prose_share_one_silent_first_step(self) -> None:
157
- configure_issue_tracker(self.root)
158
- configure_board(self.root)
159
- configure_spec_completeness(self.root)
160
- for skill in SKILLS:
161
- with self.subTest(skill=skill):
162
- self.assertEqual(self.check(skill)["verdict"], "ready")
163
- source = (ROOT / ".claude/skills" / skill / "SKILL.md").read_text(encoding="utf-8")
164
- mirror = (ROOT / ".agents/skills" / skill / "SKILL.md").read_text(encoding="utf-8")
165
- source_block = source.split(PREFLIGHT_START, 1)[1].split(PREFLIGHT_END, 1)[0]
166
- mirror_block = mirror.split(PREFLIGHT_START, 1)[1].split(PREFLIGHT_END, 1)[0]
167
- self.assertEqual(source_block, mirror_block)
168
- self.assertIn(f"node scripts/readiness.mjs check --skill {skill} --json", source_block)
169
- self.assertIn("before any remote write", source_block)
170
- self.assertIn("Ready is silent", source_block)
171
- self.assertIn("Run `/setup-workflow`, then rerun", source_block)
172
- first_mutation = min(
173
- position for token in ("board-sync.py create", "gh issue edit")
174
- if (position := source.find(token)) >= 0
175
- )
176
- self.assertLess(source.find(PREFLIGHT_START), first_mutation)
177
-
178
-
179
- if __name__ == "__main__":
180
- unittest.main()
@@ -1,233 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Focused contract tests for required operational-skill readiness gates."""
3
- from __future__ import annotations
4
-
5
- import json
6
- import re
7
- import subprocess
8
- import tempfile
9
- import unittest
10
- from pathlib import Path
11
-
12
-
13
- ROOT = Path(__file__).resolve().parents[1]
14
- SKILLS = {
15
- "triage": ("issueTracker", "managedBoard", "triageLabels"),
16
- "local-ci": ("localCiRecipe",),
17
- "project-release": ("projectReleaseProfile",),
18
- "security-audit": ("securityAuditRunbook",),
19
- }
20
-
21
-
22
- def write(root: Path, relative: str, body: str) -> None:
23
- path = root / relative
24
- path.parent.mkdir(parents=True, exist_ok=True)
25
- path.write_text(body, encoding="utf-8")
26
-
27
-
28
- def snapshot(root: Path) -> dict[str, bytes]:
29
- return {
30
- str(path.relative_to(root)): path.read_bytes()
31
- for path in root.rglob("*")
32
- if path.is_file()
33
- }
34
-
35
-
36
- def check(root: Path, skill: str) -> dict:
37
- run = subprocess.run(
38
- [
39
- "node",
40
- str(ROOT / "scripts/readiness.mjs"),
41
- "check",
42
- "--skill",
43
- skill,
44
- "--json",
45
- "--root",
46
- str(root),
47
- ],
48
- check=True,
49
- capture_output=True,
50
- text=True,
51
- )
52
- return json.loads(run.stdout)
53
-
54
-
55
- def seed_manifest(root: Path, decisions: dict[str, str] | None = None) -> None:
56
- write(
57
- root,
58
- ".claude/skills/skill-manifest.json",
59
- (ROOT / ".claude/skills/skill-manifest.json").read_text(encoding="utf-8"),
60
- )
61
- write(
62
- root,
63
- "agent-workflow-kit.json",
64
- json.dumps(
65
- {
66
- "kitVersion": "fixture",
67
- "readinessContractVersion": 1,
68
- "readinessDecisions": decisions or {},
69
- "installed": [],
70
- }
71
- ),
72
- )
73
-
74
-
75
- def seed_valid_evidence(root: Path) -> None:
76
- sentinel = "<!-- setup-workflow: state=filled -->\nconfigured\n"
77
- write(root, "docs/agents/issue-tracker.md", sentinel)
78
- write(root, "docs/agents/triage-labels.md", sentinel)
79
- board = {
80
- "repo": "owner/repo",
81
- "project": {"owner": "owner", "number": 1, "nodeId": "project"},
82
- "fields": {
83
- "status": {
84
- "id": "status",
85
- "options": {"Done": "done"},
86
- "roles": {"done": "Done"},
87
- },
88
- "wave": "wave",
89
- "cluster": "cluster",
90
- },
91
- "labels": {"readyForAgent": "ready-for-agent"},
92
- }
93
- write(
94
- root,
95
- "docs/agents/board-sync.md",
96
- f"<!-- board-sync:profile -->\n```json\n{json.dumps(board)}\n```\n",
97
- )
98
- write(root, "docs/agents/skills/local-ci.md", sentinel)
99
- write(
100
- root,
101
- "docs/agents/workflow-capabilities.json",
102
- json.dumps(
103
- {
104
- "schemaVersion": 1,
105
- "projectRelease": {
106
- "versionFiles": ["package.json"],
107
- "tagPrefix": "v",
108
- },
109
- }
110
- ),
111
- )
112
- write(
113
- root,
114
- "docs/agents/skills/security-audit.md",
115
- "<!-- setup-workflow: state=filled -->\n"
116
- "Use `docs/security/audit-runbook.md`.\n",
117
- )
118
- write(root, "docs/security/audit-runbook.md", "# Audit\nConcrete checks.\n")
119
-
120
-
121
- def readiness_section(body: str) -> str:
122
- match = re.search(
123
- r"^## Required readiness preflight\n[\s\S]*?(?=^## |\Z)", body, re.MULTILINE
124
- )
125
- if not match:
126
- raise AssertionError("required readiness preflight section is missing")
127
- return match.group(0).strip()
128
-
129
-
130
- class RequiredReadinessSkillTests(unittest.TestCase):
131
- def test_every_operational_skill_gates_before_mutation(self) -> None:
132
- first_mutation = {
133
- "triage": "## Triage a specific issue",
134
- "local-ci": "## The two profiles",
135
- "project-release": "## Workflow",
136
- "security-audit": "## Workflow — two-model run",
137
- }
138
- for skill, capabilities in SKILLS.items():
139
- with self.subTest(skill=skill):
140
- body = (ROOT / ".claude/skills" / skill / "SKILL.md").read_text(
141
- encoding="utf-8"
142
- )
143
- heading = "## Required readiness preflight"
144
- self.assertIn(heading, body)
145
- self.assertLess(body.index(heading), body.index(first_mutation[skill]))
146
- self.assertIn(
147
- f"node scripts/readiness.mjs check --skill {skill} --json", body
148
- )
149
- for capability in capabilities:
150
- self.assertIn(f"`{capability}`", body)
151
- for state in ("missing", "pending", "not-applicable", "invalid"):
152
- self.assertIn(f"`{state}`", body)
153
- self.assertIn("verdict is silent", body)
154
- self.assertIn("Never ", body)
155
- mirror = (ROOT / ".agents/skills" / skill / "SKILL.md").read_text(
156
- encoding="utf-8"
157
- )
158
- self.assertEqual(readiness_section(mirror), readiness_section(body))
159
-
160
- project_release = (
161
- ROOT / ".claude/skills/project-release/SKILL.md"
162
- ).read_text(encoding="utf-8")
163
- preflight = project_release.index(
164
- "node scripts/readiness.mjs check --skill project-release --json"
165
- )
166
- helper = project_release.index("node scripts/project-release.mjs preview")
167
- self.assertLess(preflight, helper)
168
- self.assertIn("helper remains the authority", project_release)
169
-
170
- def test_helper_blocks_missing_prerequisites_without_side_effects(self) -> None:
171
- for skill, capabilities in SKILLS.items():
172
- with self.subTest(skill=skill), tempfile.TemporaryDirectory() as temp:
173
- root = Path(temp)
174
- seed_manifest(root)
175
- before = snapshot(root)
176
- result = check(root, skill)
177
- self.assertEqual(result["verdict"], "blocked")
178
- self.assertEqual(
179
- {name: result["capabilities"][name]["state"] for name in capabilities},
180
- {name: "missing" for name in capabilities},
181
- )
182
- self.assertEqual(snapshot(root), before)
183
-
184
- def test_helper_distinguishes_pending_not_applicable_invalid_and_ready(self) -> None:
185
- invalid_evidence = {
186
- "triage": ("docs/agents/issue-tracker.md", "<!-- setup-workflow: state=stub -->\n"),
187
- "local-ci": ("docs/agents/skills/local-ci.md", "<!-- setup-workflow: state=stub -->\n"),
188
- "project-release": ("docs/agents/workflow-capabilities.json", "{}\n"),
189
- "security-audit": (
190
- "docs/agents/skills/security-audit.md",
191
- "<!-- setup-workflow: state=filled -->\nno project runbook\n",
192
- ),
193
- }
194
- for skill, capabilities in SKILLS.items():
195
- capability = capabilities[0]
196
- with self.subTest(skill=skill, state="pending"), tempfile.TemporaryDirectory() as temp:
197
- root = Path(temp)
198
- seed_manifest(root, {capability: "pending"})
199
- self.assertEqual(check(root, skill)["capabilities"][capability]["state"], "pending")
200
-
201
- with self.subTest(skill=skill, state="invalid"), tempfile.TemporaryDirectory() as temp:
202
- root = Path(temp)
203
- seed_manifest(root)
204
- seed_valid_evidence(root)
205
- write(root, *invalid_evidence[skill])
206
- result = check(root, skill)
207
- self.assertEqual(result["verdict"], "blocked")
208
- self.assertEqual(result["capabilities"][capability]["state"], "invalid")
209
-
210
- with self.subTest(skill=skill, state="ready"), tempfile.TemporaryDirectory() as temp:
211
- root = Path(temp)
212
- seed_manifest(root)
213
- seed_valid_evidence(root)
214
- before = snapshot(root)
215
- result = check(root, skill)
216
- self.assertEqual(result["verdict"], "ready")
217
- self.assertTrue(
218
- all(item["state"] == "ready" for item in result["capabilities"].values())
219
- )
220
- self.assertEqual(snapshot(root), before)
221
-
222
- with self.subTest(skill="triage", state="not-applicable"), tempfile.TemporaryDirectory() as temp:
223
- root = Path(temp)
224
- seed_manifest(root, {"managedBoard": "not-applicable"})
225
- seed_valid_evidence(root)
226
- (root / "docs/agents/board-sync.md").unlink()
227
- result = check(root, "triage")
228
- self.assertEqual(result["verdict"], "blocked")
229
- self.assertEqual(result["capabilities"]["managedBoard"]["state"], "not-applicable")
230
-
231
-
232
- if __name__ == "__main__":
233
- unittest.main()
@@ -1,255 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Self-containment lint (Welle 26 / Slice 2 / #986): generic & vendored skills
3
- must be portable.
4
-
5
- Why: a published skill (npx/git-clone into a foreign repo, or a plugin cache)
6
- cannot resolve `../../../docs/...` — the path does not exist there and `../`
7
- escapes the plugin root. Project-specific content is found by *runtime
8
- convention* (an inline project-root path like `docs/agents/board-sync.md` that
9
- the model resolves from the project root), never a hardcoded `../` link.
10
- project-private skills are exempt: they live in this repo forever and their
11
- `../` refs are correct and clickable.
12
-
13
- Scope: skills whose manifest class is `generic` or `vendored`. Every distributed
14
- `*.md` file in the skill dir is scanned (SKILL.md + support docs), in both the
15
- Claude source (`.claude/skills`) and the Codex mirror (`.agents/skills`).
16
-
17
- A line carrying `self-containment-lint: ok` is exempt — line-scoped, for
18
- deliberate "don't do this" doc examples.
19
-
20
- Run: python3 scripts/test_skill_selfcontainment_lint.py
21
- """
22
- import json
23
- import re
24
- import unittest
25
- from pathlib import Path
26
-
27
- REPO_ROOT = Path(__file__).resolve().parent.parent
28
- MANIFEST = REPO_ROOT / ".claude/skills/skill-manifest.json"
29
- SKILL_DIRS = [".claude/skills", ".agents/skills"]
30
- ENFORCED_CLASSES = {"generic", "vendored"}
31
- PUBLISH_CLASSES = {"generic", "vendored", "adapter"}
32
- VALID_CLASSES = {"generic", "vendored", "adapter", "project-private"}
33
- VALID_SURFACES = {"claude", "codex"}
34
-
35
- PARENT_REF = re.compile(r"\.\./")
36
- EXEMPT = "self-containment-lint: ok"
37
-
38
-
39
- def load_manifest() -> dict:
40
- return json.loads(MANIFEST.read_text(encoding="utf-8"))
41
-
42
-
43
- def find_parent_refs(text: str) -> list[tuple[int, str]]:
44
- """Return (line_no, line) for every line with a `../` reach, minus exempt lines."""
45
- out = []
46
- for n, line in enumerate(text.splitlines(), 1):
47
- if EXEMPT in line:
48
- continue
49
- if PARENT_REF.search(line):
50
- out.append((n, line.strip()))
51
- return out
52
-
53
-
54
- def skill_dirs_on_disk(tree: str) -> set[str]:
55
- base = REPO_ROOT / tree
56
- if not base.is_dir():
57
- return set()
58
- # dot-dirs are tool caches (e.g. .impeccable/hook.cache.json, gitignored), not skills
59
- return {p.name for p in base.iterdir() if p.is_dir() and not p.name.startswith(".")}
60
-
61
-
62
- class Detector(unittest.TestCase):
63
- """The `../` detector itself behaves."""
64
-
65
- def test_flags_parent_link(self):
66
- self.assertEqual(len(find_parent_refs("see [x](../../../docs/y.md)")), 1)
67
-
68
- def test_flags_parent_in_code(self):
69
- self.assertEqual(len(find_parent_refs("import x from '../lib/y'")), 1)
70
-
71
- def test_ignores_same_dir_relative(self):
72
- self.assertEqual(find_parent_refs("see [x](./sub/y.md)"), [])
73
-
74
- def test_ignores_root_relative_convention(self):
75
- # the portable form: an inline project-root path, no `../`
76
- self.assertEqual(find_parent_refs("read `docs/agents/board-sync.md`"), [])
77
-
78
- def test_exempt_marker_line_scoped(self):
79
- self.assertEqual(
80
- find_parent_refs("bad: [x](../y) <!-- self-containment-lint: ok -->"), [])
81
-
82
- def test_exempt_is_line_scoped_not_file_wide(self):
83
- text = "ok line <!-- self-containment-lint: ok -->\n[x](../../bad)"
84
- self.assertEqual(len(find_parent_refs(text)), 1)
85
-
86
-
87
- class ManifestValid(unittest.TestCase):
88
- def test_schema_and_classes(self):
89
- m = load_manifest()
90
- self.assertEqual(m.get("schema_version"), 1)
91
- for name, entry in m["skills"].items():
92
- self.assertIn(entry.get("class"), VALID_CLASSES, name)
93
- self.assertTrue(entry.get("surfaces"), f"{name}: no surfaces")
94
- for s in entry["surfaces"]:
95
- self.assertIn(s, VALID_SURFACES, f"{name}:{s}")
96
- # publish is a mandatory bool — build-kit + lints consume it, not class
97
- # (Slice 6 / #990 / Codex R2#3+#5).
98
- self.assertIsInstance(entry.get("publish"), bool,
99
- f"{name}: `publish` must be present and boolean")
100
-
101
-
102
- class ManifestCompleteness(unittest.TestCase):
103
- """Manifest <-> filesystem, both trees (Codex R1#4 / R2#1)."""
104
-
105
- def test_every_disk_skill_classified(self):
106
- m = load_manifest()["skills"]
107
- problems = [f"{tree}/{d} on disk but missing from manifest"
108
- for tree in SKILL_DIRS
109
- for d in sorted(skill_dirs_on_disk(tree)) if d not in m]
110
- self.assertEqual(problems, [], "\n".join(problems))
111
-
112
- def test_every_entry_matches_its_surfaces(self):
113
- m = load_manifest()["skills"]
114
- claude = skill_dirs_on_disk(".claude/skills")
115
- agents = skill_dirs_on_disk(".agents/skills")
116
- problems = []
117
- for name, entry in m.items():
118
- surfaces = set(entry.get("surfaces", []))
119
- if "claude" in surfaces and name not in claude:
120
- problems.append(f"{name}: surfaces claude but no .claude/skills/{name}")
121
- if "codex" in surfaces and name not in agents:
122
- problems.append(f"{name}: surfaces codex but no .agents/skills/{name}")
123
- if name in claude and "claude" not in surfaces:
124
- problems.append(f"{name}: in .claude/skills but surfaces lacks claude")
125
- if name in agents and "codex" not in surfaces:
126
- problems.append(f"{name}: in .agents/skills but surfaces lacks codex")
127
- self.assertEqual(problems, [], "\n".join(problems))
128
-
129
-
130
- class PublishExclusion(unittest.TestCase):
131
- """`publish` is authoritative; project-private skills never ship.
132
-
133
- Adapters MAY ship — `adapter` is in PUBLISH_CLASSES (above). A published
134
- skill can reference an adapter as a real workflow step (to-issues:59 +
135
- orchestrate-wave -> codex-adapter-sync mirror), in which case build-kit's
136
- shipped-deps-must-ship check (build-kit.mjs) *requires* the adapter to
137
- publish — that dependency check is the authoritative consistency guardrail.
138
- An adapter only ships when something published references it (spark-coordinator
139
- stays publish:false — nothing published references it). Narrowed from the
140
- original project-private+adapter rule (#990 R1#10/R2#3/#5) once a published
141
- skill was found to depend on codex-adapter-sync; the blanket adapter ban
142
- contradicted both PUBLISH_CLASSES and that dependency."""
143
-
144
- def test_project_private_not_published(self):
145
- m = load_manifest()["skills"]
146
- leaks = sorted(n for n, e in m.items()
147
- if e["class"] == "project-private" and e.get("publish"))
148
- self.assertEqual(leaks, [], f"project-private marked publish:true: {leaks}")
149
-
150
- def test_published_skill_dirs_exist_per_surface(self):
151
- """every publish:true skill exists on disk in each surface it claims."""
152
- m = load_manifest()["skills"]
153
- claude = skill_dirs_on_disk(".claude/skills")
154
- agents = skill_dirs_on_disk(".agents/skills")
155
- problems = []
156
- for name, entry in m.items():
157
- if not entry.get("publish"):
158
- continue
159
- if "claude" in entry["surfaces"] and name not in claude:
160
- problems.append(f"{name}: publish:true, surfaces claude, but missing on disk")
161
- if "codex" in entry["surfaces"] and name not in agents:
162
- problems.append(f"{name}: publish:true, surfaces codex, but missing on disk")
163
- self.assertEqual(problems, [], "\n".join(problems))
164
-
165
-
166
- class EnforcedSkillsSelfContained(unittest.TestCase):
167
- """generic & vendored skills carry no `../` reach, all .md files, both trees."""
168
-
169
- def test_no_parent_refs(self):
170
- m = load_manifest()["skills"]
171
- enforced = {n for n, e in m.items() if e["class"] in ENFORCED_CLASSES}
172
- problems = []
173
- for tree in SKILL_DIRS:
174
- for name in sorted(enforced):
175
- d = REPO_ROOT / tree / name
176
- if not d.is_dir():
177
- continue
178
- for md in sorted(d.rglob("*.md")):
179
- for ln, src in find_parent_refs(md.read_text(encoding="utf-8")):
180
- problems.append(f"{md.relative_to(REPO_ROOT)}:{ln}: {src}")
181
- self.assertEqual(
182
- problems, [],
183
- "`../` reach in a generic/vendored skill — replace with a project-root "
184
- "convention path (e.g. `docs/agents/x.md`) + runtime fallback:\n"
185
- + "\n".join(problems))
186
-
187
-
188
- class LookupContract(unittest.TestCase):
189
- """Skills migrated this slice reference project config by a project-root
190
- convention path (no `../`) AND state a runtime fallback (Codex R2#2)."""
191
-
192
- def test_migrated_skills_have_convention_path_and_fallback(self):
193
- for name in ("to-prd", "to-issues", "spec-self-critique"):
194
- txt = (REPO_ROOT / ".claude/skills" / name / "SKILL.md").read_text(encoding="utf-8")
195
- self.assertRegex(txt, r"docs/(agents|conventions)/",
196
- f"{name}: no project-root convention path")
197
- self.assertIn("/setup-workflow", txt, f"{name}: no fallback")
198
- # same scanner as the lint (honors the line-scoped exempt marker), not a
199
- # raw substring — keeps LookupContract consistent with the real check (R2#4)
200
- self.assertEqual(find_parent_refs(txt), [], f"{name}: still has a parent-dir reach")
201
-
202
-
203
- class MockRepoLookup(unittest.TestCase):
204
- """Runtime-lookup contract against a mock consumer repo. The lookup itself is
205
- a model convention (no resolver to unit-test), so we assert both branches:
206
- project file present → resolves from the project root; absent → the skill's
207
- stated fallback path applies."""
208
-
209
- def test_present_resolves_absent_falls_back(self):
210
- import tempfile
211
- to_prd = (REPO_ROOT / ".claude/skills/to-prd/SKILL.md").read_text(encoding="utf-8")
212
- with tempfile.TemporaryDirectory() as d:
213
- target = Path(d) / "docs/agents/board-sync.md"
214
- self.assertFalse(target.exists()) # absent → fallback branch
215
- self.assertIn("/setup-workflow", to_prd) # skill states the fallback
216
- target.parent.mkdir(parents=True)
217
- target.write_text("x", encoding="utf-8")
218
- self.assertTrue(target.exists()) # present → resolves from root
219
-
220
- def test_spec_self_critique_layer_present_absent(self):
221
- """spec-self-critique runs with the project layer (full pass) and without
222
- it (base pass + warning). Text/path contract — the skill names the layer
223
- path + a fallback; behavior is verified manually in the mock-repo run."""
224
- import tempfile
225
- skill = (REPO_ROOT / ".claude/skills/spec-self-critique/SKILL.md").read_text(encoding="utf-8")
226
- self.assertIn("docs/agents/skills/spec-self-critique.md", skill) # names the layer
227
- self.assertIn("/setup-workflow", skill) # states the fallback
228
- with tempfile.TemporaryDirectory() as d:
229
- target = Path(d) / "docs/agents/skills/spec-self-critique.md"
230
- self.assertFalse(target.exists()) # absent → base pass + warning
231
- target.parent.mkdir(parents=True)
232
- target.write_text("x", encoding="utf-8")
233
- self.assertTrue(target.exists()) # present → full pass
234
-
235
-
236
- class NegativeFixture(unittest.TestCase):
237
- """A real `../` in a scanned .md file is caught end-to-end (regression guard)."""
238
-
239
- def test_parent_ref_in_md_file_is_flagged(self):
240
- import tempfile
241
- with tempfile.TemporaryDirectory() as d:
242
- f = Path(d) / "SKILL.md"
243
- f.write_text("see [x](../../../docs/y.md)\n", encoding="utf-8")
244
- self.assertEqual(len(find_parent_refs(f.read_text(encoding="utf-8"))), 1)
245
-
246
- def test_exempt_line_in_md_file_is_not_flagged(self):
247
- import tempfile
248
- with tempfile.TemporaryDirectory() as d:
249
- f = Path(d) / "SKILL.md"
250
- f.write_text("anti: [x](../bad) <!-- self-containment-lint: ok -->\n", encoding="utf-8")
251
- self.assertEqual(find_parent_refs(f.read_text(encoding="utf-8")), [])
252
-
253
-
254
- if __name__ == "__main__":
255
- unittest.main(verbosity=2)