@ikon85/agent-workflow-kit 0.29.1 → 0.31.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/board-to-waves/SKILL.md +14 -0
- package/.agents/skills/code-review/SKILL.md +22 -3
- package/.agents/skills/codex-adapter-sync/SKILL.md +134 -72
- package/.agents/skills/kit-update/SKILL.md +21 -1
- package/.agents/skills/local-ci/SKILL.md +16 -0
- package/.agents/skills/project-release/SKILL.md +18 -0
- package/.agents/skills/security-audit/SKILL.md +18 -0
- package/.agents/skills/setup-workflow/SKILL.md +39 -2
- package/.agents/skills/spec-self-critique/SKILL.md +16 -7
- package/.agents/skills/to-issues/SKILL.md +13 -1
- package/.agents/skills/to-prd/SKILL.md +14 -0
- package/.agents/skills/to-waves/SKILL.md +16 -2
- package/.agents/skills/triage/SKILL.md +24 -0
- package/.agents/skills/verify-spike/SKILL.md +20 -1
- package/.agents/skills/wrapup/SKILL.md +26 -4
- package/.claude/skills/board-to-waves/SKILL.md +14 -0
- package/.claude/skills/code-review/SKILL.md +22 -3
- package/.claude/skills/kit-update/SKILL.md +21 -1
- package/.claude/skills/local-ci/SKILL.md +16 -0
- package/.claude/skills/project-release/SKILL.md +18 -0
- package/.claude/skills/security-audit/SKILL.md +18 -0
- package/.claude/skills/setup-workflow/SKILL.md +39 -2
- package/.claude/skills/skill-manifest.json +46 -0
- package/.claude/skills/spec-self-critique/SKILL.md +16 -7
- package/.claude/skills/to-issues/SKILL.md +13 -1
- package/.claude/skills/to-prd/SKILL.md +14 -0
- package/.claude/skills/to-waves/SKILL.md +16 -2
- package/.claude/skills/triage/SKILL.md +24 -0
- package/.claude/skills/verify-spike/SKILL.md +20 -1
- package/.claude/skills/wrapup/SKILL.md +26 -4
- package/README.md +41 -0
- package/agent-workflow-kit.package.json +71 -31
- package/package.json +1 -1
- package/scripts/kit-update-pr.mjs +14 -1
- package/scripts/kit-update-pr.test.mjs +21 -0
- package/scripts/readiness.mjs +215 -0
- package/scripts/test_codex_adapter_sync_contract.py +154 -0
- package/scripts/test_retro_wrapup_contract.py +17 -0
- package/scripts/test_skill_optional_readiness.py +171 -0
- package/scripts/test_skill_portability_lint.py +57 -18
- package/scripts/test_skill_readiness_contract.py +139 -0
- package/scripts/test_skill_readiness_preflight.py +180 -0
- package/scripts/test_skill_required_readiness.py +233 -0
- package/scripts/test_skill_setup_workflow_seeds.py +22 -0
- package/src/cli.mjs +10 -2
- package/src/commands/init.mjs +3 -1
- package/src/commands/uninstall.mjs +5 -1
- package/src/commands/update.mjs +65 -8
- package/src/lib/bundle.mjs +7 -0
- package/src/lib/manifest.mjs +26 -2
- package/src/lib/updateCandidate.mjs +116 -2
- package/src/lib/updateReconcile.mjs +3 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manifest-derived readiness declaration and marker grammar guard."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
MANIFEST_PATH = ROOT / ".claude/skills/skill-manifest.json"
|
|
12
|
+
START = re.compile(r"^<!-- readiness:block ([a-z][A-Za-z0-9]*) -->$")
|
|
13
|
+
END = "<!-- readiness:end -->"
|
|
14
|
+
SURFACE = {"claude": ".claude/skills", "codex": ".agents/skills"}
|
|
15
|
+
REQUIRED = {
|
|
16
|
+
"to-prd": ["issueTracker", "managedBoard"],
|
|
17
|
+
"to-issues": ["issueTracker", "managedBoard", "specCompleteness"],
|
|
18
|
+
"to-waves": ["issueTracker", "managedBoard", "specCompleteness"],
|
|
19
|
+
"board-to-waves": ["issueTracker", "managedBoard"],
|
|
20
|
+
"triage": ["issueTracker", "managedBoard", "triageLabels"],
|
|
21
|
+
"orchestrate-wave": ["issueTracker", "managedBoard"],
|
|
22
|
+
"local-ci": ["localCiRecipe"],
|
|
23
|
+
"project-release": ["projectReleaseProfile"],
|
|
24
|
+
"security-audit": ["securityAuditRunbook"],
|
|
25
|
+
}
|
|
26
|
+
OPTIONAL = {
|
|
27
|
+
"wrapup": {"deployReport": "prodTarget"},
|
|
28
|
+
"orchestrate-wave": {"projectRecipe": "orchestrateWaveRecipe"},
|
|
29
|
+
"spec-self-critique": {"projectEnrichment": "specCritiqueLayer"},
|
|
30
|
+
"code-review": {"projectEnrichment": "codeReviewLayer"},
|
|
31
|
+
"verify-spike": {"projectPlacement": "verifySpikeLayer"},
|
|
32
|
+
"audit-skills": {"projectChecks": "auditSkillsLayer"},
|
|
33
|
+
"git-worktree-recover": {"projectRecovery": "worktreeRecoveryLayer"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_manifest() -> dict:
|
|
38
|
+
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def marker_sequence(body: str, declared: set[str]) -> list[str]:
|
|
42
|
+
sequence: list[str] = []
|
|
43
|
+
active: str | None = None
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
for number, line in enumerate(body.splitlines(), 1):
|
|
46
|
+
match = START.fullmatch(line)
|
|
47
|
+
if match:
|
|
48
|
+
block = match.group(1)
|
|
49
|
+
if active:
|
|
50
|
+
raise AssertionError(f"nested readiness block at line {number}")
|
|
51
|
+
if block in seen:
|
|
52
|
+
raise AssertionError(f"duplicate readiness block {block}")
|
|
53
|
+
if block not in declared:
|
|
54
|
+
raise AssertionError(f"unknown readiness block {block}")
|
|
55
|
+
active = block
|
|
56
|
+
seen.add(block)
|
|
57
|
+
sequence.append(block)
|
|
58
|
+
elif line == END:
|
|
59
|
+
if not active:
|
|
60
|
+
raise AssertionError(f"unbalanced readiness end at line {number}")
|
|
61
|
+
active = None
|
|
62
|
+
elif "readiness:block" in line or "readiness:end" in line:
|
|
63
|
+
raise AssertionError(f"invalid readiness marker grammar at line {number}")
|
|
64
|
+
if active:
|
|
65
|
+
raise AssertionError(f"unbalanced readiness block {active}")
|
|
66
|
+
return sequence
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_surface_sequences(sequences: list[list[str]]) -> None:
|
|
70
|
+
if any(sequence != sequences[0] for sequence in sequences[1:]):
|
|
71
|
+
raise AssertionError("cross-surface readiness marker drift")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ReadinessContractTests(unittest.TestCase):
|
|
75
|
+
def setUp(self) -> None:
|
|
76
|
+
self.manifest = load_manifest()
|
|
77
|
+
self.catalog = self.manifest["readiness"]["capabilities"]
|
|
78
|
+
self.published = {
|
|
79
|
+
name: entry for name, entry in self.manifest["skills"].items()
|
|
80
|
+
if entry.get("publish")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def test_contract_and_skill_denominator_come_from_publish_manifest(self) -> None:
|
|
84
|
+
self.assertEqual(self.manifest["readiness"]["contractVersion"], 1)
|
|
85
|
+
manifest_source = (ROOT / "src/lib/manifest.mjs").read_text(encoding="utf-8")
|
|
86
|
+
version = re.search(r"READINESS_CONTRACT_VERSION = (\d+);", manifest_source)
|
|
87
|
+
self.assertIsNotNone(version)
|
|
88
|
+
self.assertEqual(int(version.group(1)), self.manifest["readiness"]["contractVersion"])
|
|
89
|
+
self.assertGreater(len(self.published), 0)
|
|
90
|
+
for name, entry in self.published.items():
|
|
91
|
+
self.assertTrue(entry.get("surfaces"), name)
|
|
92
|
+
|
|
93
|
+
def test_every_declaration_references_the_single_capability_catalog(self) -> None:
|
|
94
|
+
for skill, entry in self.published.items():
|
|
95
|
+
declaration = entry.get("readiness", {})
|
|
96
|
+
references = list(declaration.get("required", []))
|
|
97
|
+
references += list(declaration.get("optionalBlocks", {}).values())
|
|
98
|
+
for capability in references:
|
|
99
|
+
self.assertIn(capability, self.catalog, f"{skill}: {capability}")
|
|
100
|
+
blocks = list(declaration.get("optionalBlocks", {}))
|
|
101
|
+
self.assertEqual(len(blocks), len(set(blocks)), skill)
|
|
102
|
+
|
|
103
|
+
def test_initial_enforcement_mapping_is_exact(self) -> None:
|
|
104
|
+
for skill, required in REQUIRED.items():
|
|
105
|
+
self.assertEqual(self.published[skill]["readiness"]["required"], required)
|
|
106
|
+
for skill, blocks in OPTIONAL.items():
|
|
107
|
+
self.assertEqual(self.published[skill]["readiness"]["optionalBlocks"], blocks)
|
|
108
|
+
|
|
109
|
+
def test_markers_are_balanced_declared_and_surface_equal_when_activated(self) -> None:
|
|
110
|
+
for skill, entry in self.published.items():
|
|
111
|
+
declared = set(entry.get("readiness", {}).get("optionalBlocks", {}))
|
|
112
|
+
sequences: list[tuple[str, list[str]]] = []
|
|
113
|
+
for surface in entry["surfaces"]:
|
|
114
|
+
path = ROOT / SURFACE[surface] / skill / "SKILL.md"
|
|
115
|
+
self.assertTrue(path.is_file(), f"missing published surface: {path}")
|
|
116
|
+
sequence = marker_sequence(path.read_text(encoding="utf-8"), declared)
|
|
117
|
+
sequences.append((surface, sequence))
|
|
118
|
+
if len(sequences) > 1:
|
|
119
|
+
validate_surface_sequences([sequence for _, sequence in sequences])
|
|
120
|
+
|
|
121
|
+
def test_marker_parser_rejects_each_structural_failure(self) -> None:
|
|
122
|
+
declared = {"projectEnrichment"}
|
|
123
|
+
bad = [
|
|
124
|
+
"<!-- readiness:block projectEnrichment -->\n<!-- readiness:block projectEnrichment -->",
|
|
125
|
+
"<!-- readiness:block projectEnrichment -->\n<!-- readiness:end -->\n<!-- readiness:block projectEnrichment -->",
|
|
126
|
+
"<!-- readiness:end -->",
|
|
127
|
+
"<!-- readiness:block projectEnrichment -->",
|
|
128
|
+
"<!-- readiness:block unknownBlock -->\n<!-- readiness:end -->",
|
|
129
|
+
" <!-- readiness:block projectEnrichment -->",
|
|
130
|
+
]
|
|
131
|
+
for body in bad:
|
|
132
|
+
with self.subTest(body=body), self.assertRaises(AssertionError):
|
|
133
|
+
marker_sequence(body, declared)
|
|
134
|
+
with self.assertRaises(AssertionError):
|
|
135
|
+
validate_surface_sequences([["projectEnrichment"], []])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
unittest.main()
|
|
@@ -0,0 +1,180 @@
|
|
|
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()
|
|
@@ -0,0 +1,233 @@
|
|
|
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()
|
|
@@ -213,6 +213,28 @@ class IdempotencyRule(unittest.TestCase):
|
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
class SeedTemplatesValid(unittest.TestCase):
|
|
216
|
+
def test_prod_readiness_choice_and_transaction_contract_is_complete(self):
|
|
217
|
+
for surface in (".claude", ".agents"):
|
|
218
|
+
text = (REPO / surface / "skills/setup-workflow/SKILL.md").read_text(
|
|
219
|
+
encoding="utf-8"
|
|
220
|
+
)
|
|
221
|
+
for token in (
|
|
222
|
+
"Configure now", "Configure later",
|
|
223
|
+
"decision set prodTarget pending",
|
|
224
|
+
"wrapup.deployReport omitted (prodTarget pending)",
|
|
225
|
+
"`prodTarget` does not permit this choice",
|
|
226
|
+
"Preview the exact local `## Prod` replacement",
|
|
227
|
+
"external mutation retains its own separate",
|
|
228
|
+
"activeBlocks` must be exactly `[\"deployReport\"]`",
|
|
229
|
+
):
|
|
230
|
+
self.assertIn(token, text, f"{surface} missing {token!r}")
|
|
231
|
+
preserve = re.search(
|
|
232
|
+
r"Preserve each file's\s+`## Workflow` and `## Agent skills` "
|
|
233
|
+
r"blocks byte-for-byte",
|
|
234
|
+
text,
|
|
235
|
+
)
|
|
236
|
+
self.assertIsNotNone(preserve, surface)
|
|
237
|
+
|
|
216
238
|
def test_census_effect_contract_executes_every_transition_and_repeats_without_writes(self):
|
|
217
239
|
effects = load_census_setup_effects()
|
|
218
240
|
self.assertEqual(
|
package/src/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
5
|
import { init } from './commands/init.mjs';
|
|
6
|
-
import { update } from './commands/update.mjs';
|
|
6
|
+
import { renderUpdateFailure, update } from './commands/update.mjs';
|
|
7
7
|
import { diff } from './commands/diff.mjs';
|
|
8
8
|
import { uninstall } from './commands/uninstall.mjs';
|
|
9
9
|
import { setOwnership } from './commands/own.mjs';
|
|
@@ -49,7 +49,7 @@ try {
|
|
|
49
49
|
});
|
|
50
50
|
printPlan(r);
|
|
51
51
|
for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
|
|
52
|
-
if (r.state === 'failed') throw new Error(
|
|
52
|
+
if (r.state === 'failed') throw new Error(renderUpdateFailure(r));
|
|
53
53
|
if (r.state === 'conflicted') {
|
|
54
54
|
p.note(r.report.recommendation, 'recommendation');
|
|
55
55
|
p.outro(`not applied · conflicts ${r.conflicts.length}`);
|
|
@@ -86,6 +86,14 @@ function printPlan(r) {
|
|
|
86
86
|
])
|
|
87
87
|
if (r[k]?.length) lines.push(`${k}: ${r[k].length}`);
|
|
88
88
|
if (r.conflicts?.length) lines.push(`conflicts: ${r.conflicts.length}`);
|
|
89
|
+
if (r.availability) {
|
|
90
|
+
for (const [key, label] of [
|
|
91
|
+
['newlyAvailable', 'newly available'], ['newlyDegraded', 'newly degraded'],
|
|
92
|
+
['newlyBlocked', 'newly blocked'], ['stillUnresolved', 'still unresolved'],
|
|
93
|
+
]) {
|
|
94
|
+
lines.push(`${label}: ${r.availability[key].join(', ') || 'none'}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
89
97
|
for (const owned of r.ownedDiffs ?? []) {
|
|
90
98
|
lines.push(`${owned.state} ${owned.path}`);
|
|
91
99
|
if (owned.binary) {
|
package/src/commands/init.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readManifest, writeManifest, emptyConsumerManifest,
|
|
9
9
|
filesForInstallRole, CONSUMER_INSTALL_ROLE,
|
|
10
10
|
indexByPath,
|
|
11
|
+
readReadinessContract,
|
|
11
12
|
CONSUMER_ORIGIN,
|
|
12
13
|
PACKAGE_MANIFEST_NAME, CONSUMER_MANIFEST_NAME,
|
|
13
14
|
} from '../lib/manifest.mjs';
|
|
@@ -27,6 +28,7 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
|
|
|
27
28
|
const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
|
|
28
29
|
if (!pkg) throw new Error('kit package manifest not found');
|
|
29
30
|
const prior = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
31
|
+
const readiness = await readReadinessContract(kitRoot);
|
|
30
32
|
const tracked = new Set((prior?.installed ?? []).map((e) => e.path));
|
|
31
33
|
const consumerOwned = new Set(
|
|
32
34
|
(prior?.installed ?? []).filter((e) => e.origin === CONSUMER_ORIGIN).map((e) => e.path),
|
|
@@ -68,7 +70,7 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
|
|
|
68
70
|
|
|
69
71
|
await writeManifest(
|
|
70
72
|
join(consumerRoot, CONSUMER_MANIFEST_NAME),
|
|
71
|
-
{ ...emptyConsumerManifest(pkg.kitVersion), installed }
|
|
73
|
+
{ ...emptyConsumerManifest(pkg.kitVersion, prior, readiness), installed }
|
|
72
74
|
);
|
|
73
75
|
|
|
74
76
|
for (const stub of STUB_TARGETS) {
|
|
@@ -5,6 +5,7 @@ import { hookReferenced } from '../lib/settings.mjs';
|
|
|
5
5
|
import {
|
|
6
6
|
writeManifest, readManifest, emptyConsumerManifest,
|
|
7
7
|
CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN,
|
|
8
|
+
readReadinessContract,
|
|
8
9
|
} from '../lib/manifest.mjs';
|
|
9
10
|
|
|
10
11
|
const exists = (p) => access(p).then(() => true, () => false);
|
|
@@ -18,6 +19,7 @@ const exists = (p) => access(p).then(() => true, () => false);
|
|
|
18
19
|
export async function uninstall({ consumerRoot }) {
|
|
19
20
|
const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
20
21
|
if (!consumer) throw new Error('not initialised — nothing to uninstall');
|
|
22
|
+
const readiness = await readReadinessContract(consumerRoot);
|
|
21
23
|
|
|
22
24
|
const res = { removed: [], retained: [] };
|
|
23
25
|
const retainedEntries = [];
|
|
@@ -42,7 +44,9 @@ export async function uninstall({ consumerRoot }) {
|
|
|
42
44
|
|
|
43
45
|
const manifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
|
|
44
46
|
if (retainedEntries.length) {
|
|
45
|
-
await writeManifest(manifestPath, {
|
|
47
|
+
await writeManifest(manifestPath, {
|
|
48
|
+
...emptyConsumerManifest(consumer.kitVersion, consumer, readiness), installed: retainedEntries,
|
|
49
|
+
});
|
|
46
50
|
} else {
|
|
47
51
|
await rm(manifestPath, { force: true });
|
|
48
52
|
}
|