@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.
- package/.agents/skills/grill-me/SKILL.md +3 -1
- package/.agents/skills/grill-with-docs/SKILL.md +3 -1
- package/.agents/skills/kit-update/SKILL.md +9 -11
- package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
- package/.agents/skills/setup-workflow/SKILL.md +20 -18
- package/.agents/skills/setup-workflow/board-sync.md +4 -1
- package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.agents/skills/to-issues/SKILL.md +43 -0
- package/.agents/skills/wrapup/SKILL.md +98 -54
- package/.claude/skills/grill-me/SKILL.md +3 -1
- package/.claude/skills/grill-me-codex/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
- package/.claude/skills/kit-update/SKILL.md +9 -11
- package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
- package/.claude/skills/setup-workflow/SKILL.md +20 -18
- package/.claude/skills/setup-workflow/board-sync.md +4 -1
- package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.claude/skills/to-issues/SKILL.md +43 -0
- package/.claude/skills/wrapup/SKILL.md +98 -54
- package/README.md +249 -7
- package/agent-workflow-kit.package.json +42 -42
- package/package.json +16 -12
- package/scripts/marker_lib.py +3 -0
- package/scripts/profile_globs.py +26 -45
- package/scripts/release-delta-guard.mjs +26 -9
- package/scripts/worktree-lifecycle/README.md +57 -160
- package/scripts/worktree-lifecycle/capabilities.json +2 -3
- package/scripts/worktree-lifecycle/classify.py +480 -0
- package/scripts/worktree-lifecycle/cleanup.py +9 -29
- package/scripts/worktree-lifecycle/core.py +68 -1429
- package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
- package/scripts/worktree-lifecycle/profile.py +46 -29
- package/scripts/worktree-lifecycle/setup.py +7 -19
- package/scripts/wrapup-land.py +1292 -545
- package/src/consumer-migrations.json +1 -16
- package/src/lib/bundle.mjs +31 -1
- package/docs/adr/0001-consumer-divergence-policy.md +0 -53
- package/docs/adr/0002-capability-gated-orchestration.md +0 -70
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
- package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
- package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
- package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
- package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
- package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
- package/docs/agents/board-sync.md +0 -74
- package/docs/agents/code-review.md +0 -14
- package/docs/agents/workflow-capabilities.json +0 -28
- package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
- package/docs/research/benchlm-routing-source.md +0 -198
- package/docs/research/consumer-owned-protocol-files.md +0 -238
- package/docs/research/frontend-agent-benchmarks.md +0 -282
- package/docs/research/model-effort-routing-benchmarks.md +0 -261
- package/docs/research/provider-neutral-agent-routing.md +0 -207
- package/docs/research/wave-152-consumer-acceptance.md +0 -98
- package/docs/research/wave-43-script-hook-census.md +0 -128
- package/scripts/build-kit.mjs +0 -117
- package/scripts/build-kit.test.mjs +0 -207
- package/scripts/census/census.test.mjs +0 -252
- package/scripts/census/state.test.mjs +0 -15
- package/scripts/census/transaction.test.mjs +0 -103
- package/scripts/census-contract.test.mjs +0 -227
- package/scripts/check-kit-staleness.mjs +0 -45
- package/scripts/check-kit-staleness.test.mjs +0 -19
- package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
- package/scripts/codex-exec.test.mjs +0 -871
- package/scripts/grill-census-wiring-guard.mjs +0 -87
- package/scripts/grill-census-wiring-guard.test.mjs +0 -107
- package/scripts/kit-release.test.mjs +0 -273
- package/scripts/kit-update-pr.test.mjs +0 -151
- package/scripts/lib/audit-refs.mjs +0 -146
- package/scripts/lib/scrub.mjs +0 -113
- package/scripts/lib/scrub.test.mjs +0 -187
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
- package/scripts/portability_profile_scan.py +0 -53
- package/scripts/release-delta-guard.test.mjs +0 -174
- package/scripts/release-parity.test.mjs +0 -90
- package/scripts/release-state.test.mjs +0 -360
- package/scripts/test_anchor_table.py +0 -69
- package/scripts/test_board_bootstrap.py +0 -348
- package/scripts/test_board_sync.py +0 -208
- package/scripts/test_board_sync_create_idempotency.py +0 -117
- package/scripts/test_board_sync_wave_title.py +0 -120
- package/scripts/test_census_backstop.py +0 -712
- package/scripts/test_census_forward_contract.py +0 -91
- package/scripts/test_census_update_contract.test.mjs +0 -454
- package/scripts/test_codex_adapter_sync_contract.py +0 -169
- package/scripts/test_dist_kit_smoke.py +0 -220
- package/scripts/test_drift_guard_diagnostics.py +0 -295
- package/scripts/test_issue_claim_contract.py +0 -169
- package/scripts/test_kit_docs_language_census.py +0 -178
- package/scripts/test_marker_lib.py +0 -162
- package/scripts/test_orchestrate_wave_contract.py +0 -381
- package/scripts/test_pr_body_check.py +0 -264
- package/scripts/test_profile_globs.py +0 -280
- package/scripts/test_program_planning_contract.py +0 -133
- package/scripts/test_release_authorization_contract.py +0 -101
- package/scripts/test_render_anchor.py +0 -267
- package/scripts/test_retro_wrapup_contract.py +0 -153
- package/scripts/test_skill_code_review_seed.py +0 -89
- package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
- package/scripts/test_skill_frontmatter_lint.py +0 -234
- package/scripts/test_skill_gh_lint.py +0 -124
- package/scripts/test_skill_language_census.py +0 -254
- package/scripts/test_skill_optional_readiness.py +0 -171
- package/scripts/test_skill_portability_lint.py +0 -866
- package/scripts/test_skill_precommit_template.py +0 -30
- package/scripts/test_skill_publish_audit.py +0 -358
- package/scripts/test_skill_readiness_contract.py +0 -264
- package/scripts/test_skill_readiness_preflight.py +0 -180
- package/scripts/test_skill_required_readiness.py +0 -233
- package/scripts/test_skill_selfcontainment_lint.py +0 -255
- package/scripts/test_skill_setup_workflow_seeds.py +0 -691
- package/scripts/test_skill_stale_name_lint.py +0 -126
- package/scripts/test_skill_surface_refs.py +0 -300
- package/scripts/test_skill_trailing_artifact_lint.py +0 -71
- package/scripts/test_tdd_contract.py +0 -78
- package/scripts/test_worktree_ignore_seed.py +0 -320
- package/scripts/test_worktree_setup_base_guard.py +0 -140
- package/scripts/test_worktree_wrapup_contract.py +0 -1737
- package/scripts/test_wrapup_land.py +0 -428
- 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)
|