@ikon85/agent-workflow-kit 0.30.0 → 0.32.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 (48) hide show
  1. package/.agents/skills/audit-skills/SKILL.md +34 -5
  2. package/.agents/skills/board-to-waves/SKILL.md +14 -0
  3. package/.agents/skills/code-review/SKILL.md +22 -3
  4. package/.agents/skills/git-worktree-recover/SKILL.md +31 -10
  5. package/.agents/skills/kit-update/SKILL.md +21 -1
  6. package/.agents/skills/local-ci/SKILL.md +16 -0
  7. package/.agents/skills/orchestrate-wave/SKILL.md +43 -14
  8. package/.agents/skills/project-release/SKILL.md +18 -0
  9. package/.agents/skills/security-audit/SKILL.md +18 -0
  10. package/.agents/skills/setup-workflow/SKILL.md +39 -2
  11. package/.agents/skills/spec-self-critique/SKILL.md +16 -7
  12. package/.agents/skills/to-issues/SKILL.md +13 -1
  13. package/.agents/skills/to-prd/SKILL.md +14 -0
  14. package/.agents/skills/to-waves/SKILL.md +16 -2
  15. package/.agents/skills/triage/SKILL.md +24 -0
  16. package/.agents/skills/verify-spike/SKILL.md +20 -1
  17. package/.agents/skills/wrapup/SKILL.md +26 -4
  18. package/.claude/skills/audit-skills/SKILL.md +34 -5
  19. package/.claude/skills/board-to-waves/SKILL.md +14 -0
  20. package/.claude/skills/code-review/SKILL.md +22 -3
  21. package/.claude/skills/git-worktree-recover/SKILL.md +31 -10
  22. package/.claude/skills/kit-update/SKILL.md +21 -1
  23. package/.claude/skills/local-ci/SKILL.md +16 -0
  24. package/.claude/skills/orchestrate-wave/SKILL.md +43 -14
  25. package/.claude/skills/project-release/SKILL.md +18 -0
  26. package/.claude/skills/security-audit/SKILL.md +18 -0
  27. package/.claude/skills/setup-workflow/SKILL.md +39 -2
  28. package/.claude/skills/spec-self-critique/SKILL.md +16 -7
  29. package/.claude/skills/to-issues/SKILL.md +13 -1
  30. package/.claude/skills/to-prd/SKILL.md +14 -0
  31. package/.claude/skills/to-waves/SKILL.md +16 -2
  32. package/.claude/skills/triage/SKILL.md +24 -0
  33. package/.claude/skills/verify-spike/SKILL.md +20 -1
  34. package/.claude/skills/wrapup/SKILL.md +26 -4
  35. package/README.md +41 -0
  36. package/agent-workflow-kit.package.json +36 -36
  37. package/package.json +1 -1
  38. package/scripts/kit-update-pr.mjs +14 -1
  39. package/scripts/kit-update-pr.test.mjs +21 -0
  40. package/scripts/test_retro_wrapup_contract.py +17 -0
  41. package/scripts/test_skill_optional_readiness.py +171 -0
  42. package/scripts/test_skill_readiness_contract.py +127 -7
  43. package/scripts/test_skill_readiness_preflight.py +180 -0
  44. package/scripts/test_skill_required_readiness.py +233 -0
  45. package/scripts/test_skill_setup_workflow_seeds.py +22 -0
  46. package/src/cli.mjs +10 -2
  47. package/src/commands/update.mjs +65 -8
  48. package/src/lib/updateCandidate.mjs +116 -2
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """Behavior contract for optional review and spike readiness layers."""
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
+ SURFACES = (".claude/skills", ".agents/skills")
15
+ SKILLS = {
16
+ "spec-self-critique": {
17
+ "block": "projectEnrichment",
18
+ "capability": "specCritiqueLayer",
19
+ "path": "docs/agents/skills/spec-self-critique.md",
20
+ "core": ("## The 12-point checklist", "Self-Critique complete"),
21
+ "enrichment": ("per-point enrichment", "project-specific incidents"),
22
+ },
23
+ "code-review": {
24
+ "block": "projectEnrichment",
25
+ "capability": "codeReviewLayer",
26
+ "path": "docs/agents/code-review.md",
27
+ "core": ("## Axis 1 — Standards", "## Axis 2 — Spec"),
28
+ "enrichment": ("which sources count", "adjacent tools"),
29
+ },
30
+ "verify-spike": {
31
+ "block": "projectPlacement",
32
+ "capability": "verifySpikeLayer",
33
+ "path": "docs/agents/skills/verify-spike.md",
34
+ "core": ("Frame one falsifiable question", "Delete the harness"),
35
+ "enrichment": ("harness-placement", "import rules"),
36
+ },
37
+ }
38
+ PREFLIGHT_START = "<!-- readiness:optional-preflight:start -->"
39
+ PREFLIGHT_END = "<!-- readiness:optional-preflight:end -->"
40
+
41
+
42
+ def write(root: Path, relative: str, body: str) -> None:
43
+ path = root / relative
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ path.write_text(body, encoding="utf-8")
46
+
47
+
48
+ class OptionalReadinessTests(unittest.TestCase):
49
+ def setUp(self) -> None:
50
+ self.temp = tempfile.TemporaryDirectory()
51
+ self.root = Path(self.temp.name)
52
+ write(
53
+ self.root,
54
+ ".claude/skills/skill-manifest.json",
55
+ MANIFEST.read_text(encoding="utf-8"),
56
+ )
57
+ self.consumer = {
58
+ "kitVersion": "0.0.0-test",
59
+ "readinessContractVersion": 1,
60
+ "readinessDecisions": {},
61
+ "installed": [],
62
+ }
63
+ self.write_consumer()
64
+
65
+ def tearDown(self) -> None:
66
+ self.temp.cleanup()
67
+
68
+ def write_consumer(self) -> None:
69
+ write(self.root, "agent-workflow-kit.json", json.dumps(self.consumer))
70
+
71
+ def check(self, skill: str) -> dict:
72
+ completed = subprocess.run(
73
+ [
74
+ "node",
75
+ str(READINESS),
76
+ "check",
77
+ "--skill",
78
+ skill,
79
+ "--json",
80
+ "--root",
81
+ str(self.root),
82
+ ],
83
+ check=True,
84
+ capture_output=True,
85
+ text=True,
86
+ )
87
+ return json.loads(completed.stdout)
88
+
89
+ def test_missing_and_pending_subtract_exactly_one_optional_block(self) -> None:
90
+ for decision in (None, "pending"):
91
+ for skill, contract in SKILLS.items():
92
+ with self.subTest(skill=skill, decision=decision):
93
+ self.consumer["readinessDecisions"] = {}
94
+ if decision:
95
+ self.consumer["readinessDecisions"][contract["capability"]] = decision
96
+ self.write_consumer()
97
+ result = self.check(skill)
98
+ self.assertEqual(result["verdict"], "degraded")
99
+ self.assertEqual(
100
+ result["capabilities"][contract["capability"]]["state"],
101
+ decision or "missing",
102
+ )
103
+ self.assertEqual(result["inactiveBlocks"], [contract["block"]])
104
+ self.assertEqual(result["activeBlocks"], [])
105
+
106
+ def test_invalid_enrichment_is_visible_and_never_an_opt_out(self) -> None:
107
+ for skill, contract in SKILLS.items():
108
+ with self.subTest(skill=skill):
109
+ write(
110
+ self.root,
111
+ contract["path"],
112
+ "<!-- setup-workflow: state=stub -->\npartial configuration\n",
113
+ )
114
+ self.consumer["readinessDecisions"][contract["capability"]] = "pending"
115
+ self.write_consumer()
116
+ result = self.check(skill)
117
+ self.assertEqual(result["verdict"], "degraded")
118
+ self.assertEqual(
119
+ result["capabilities"][contract["capability"]]["state"],
120
+ "invalid",
121
+ )
122
+ self.assertEqual(result["inactiveBlocks"], [contract["block"]])
123
+
124
+ def test_ready_enrichment_activates_the_block(self) -> None:
125
+ for skill, contract in SKILLS.items():
126
+ with self.subTest(skill=skill):
127
+ write(
128
+ self.root,
129
+ contract["path"],
130
+ "<!-- setup-workflow: state=filled -->\nProject guidance.\n",
131
+ )
132
+ self.consumer["readinessDecisions"] = {}
133
+ self.write_consumer()
134
+ result = self.check(skill)
135
+ self.assertEqual(result["verdict"], "ready")
136
+ self.assertEqual(result["activeBlocks"], [contract["block"]])
137
+ self.assertEqual(result["inactiveBlocks"], [])
138
+
139
+ def test_preflight_and_exact_subtraction_match_across_both_surfaces(self) -> None:
140
+ for skill, contract in SKILLS.items():
141
+ blocks = []
142
+ preflights = []
143
+ for surface in SURFACES:
144
+ body = (ROOT / surface / skill / "SKILL.md").read_text(encoding="utf-8")
145
+ start = f"<!-- readiness:block {contract['block']} -->"
146
+ self.assertEqual(body.count(start), 1)
147
+ self.assertEqual(body.count("<!-- readiness:end -->"), 1)
148
+ block = body.split(start, 1)[1].split("<!-- readiness:end -->", 1)[0]
149
+ preflight = body.split(PREFLIGHT_START, 1)[1].split(PREFLIGHT_END, 1)[0]
150
+ generic = body.replace(start + block + "<!-- readiness:end -->", "")
151
+ for anchor in contract["core"]:
152
+ self.assertIn(anchor, generic)
153
+ for anchor in contract["enrichment"]:
154
+ self.assertIn(anchor, block)
155
+ self.assertLess(body.index(PREFLIGHT_START), body.index(start))
156
+ self.assertIn(
157
+ f"node scripts/readiness.mjs check --skill {skill} --json",
158
+ preflight,
159
+ )
160
+ self.assertIn(f"inactive block `{contract['block']}`", preflight)
161
+ self.assertIn(contract["capability"], preflight)
162
+ self.assertIn("Ready is silent", preflight)
163
+ self.assertIn("Invalid is always visible", preflight)
164
+ blocks.append(block)
165
+ preflights.append(preflight)
166
+ self.assertEqual(blocks[0], blocks[1])
167
+ self.assertEqual(preflights[0], preflights[1])
168
+
169
+
170
+ if __name__ == "__main__":
171
+ unittest.main()
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
+ import subprocess
7
8
  import unittest
8
9
  from pathlib import Path
9
10
 
@@ -12,7 +13,8 @@ MANIFEST_PATH = ROOT / ".claude/skills/skill-manifest.json"
12
13
  START = re.compile(r"^<!-- readiness:block ([a-z][A-Za-z0-9]*) -->$")
13
14
  END = "<!-- readiness:end -->"
14
15
  SURFACE = {"claude": ".claude/skills", "codex": ".agents/skills"}
15
- REQUIRED = {
16
+ FIXTURES = ROOT / "test/fixtures/readiness"
17
+ REQUIRED_V1 = {
16
18
  "to-prd": ["issueTracker", "managedBoard"],
17
19
  "to-issues": ["issueTracker", "managedBoard", "specCompleteness"],
18
20
  "to-waves": ["issueTracker", "managedBoard", "specCompleteness"],
@@ -23,7 +25,7 @@ REQUIRED = {
23
25
  "project-release": ["projectReleaseProfile"],
24
26
  "security-audit": ["securityAuditRunbook"],
25
27
  }
26
- OPTIONAL = {
28
+ OPTIONAL_V1 = {
27
29
  "wrapup": {"deployReport": "prodTarget"},
28
30
  "orchestrate-wave": {"projectRecipe": "orchestrateWaveRecipe"},
29
31
  "spec-self-critique": {"projectEnrichment": "specCritiqueLayer"},
@@ -32,6 +34,74 @@ OPTIONAL = {
32
34
  "audit-skills": {"projectChecks": "auditSkillsLayer"},
33
35
  "git-worktree-recover": {"projectRecovery": "worktreeRecoveryLayer"},
34
36
  }
37
+ COMPAT_UPDATE_SCRIPT = r"""
38
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
39
+ import { join } from 'node:path';
40
+ import { pathToFileURL } from 'node:url';
41
+ const [repo, fixturePath] = process.argv.slice(-2);
42
+ const load = async (path) => import(pathToFileURL(join(repo, path)));
43
+ const { init } = await load('src/commands/init.mjs');
44
+ const { update } = await load('src/commands/update.mjs');
45
+ const { checkSkill } = await load('scripts/readiness.mjs');
46
+ const { makeKit, makeEmptyDir, cleanup } = await load('test/helpers.mjs');
47
+ const { PACKAGE_MANIFEST_NAME, readManifest, writeManifest } = await load('src/lib/manifest.mjs');
48
+ const { sha256 } = await load('src/lib/hash.mjs');
49
+ const readinessPath = '.claude/skills/skill-manifest.json';
50
+ const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
51
+ const current = JSON.parse(await readFile(join(repo, readinessPath), 'utf8'));
52
+ const previous = structuredClone(current);
53
+ for (const skill of Object.keys(fixture.readinessFixture.skills)) {
54
+ previous.skills[skill].readiness = {};
55
+ }
56
+ const kit = await makeKit({ '.claude/skills/to-prd/SKILL.md': 'fixture\n' });
57
+ const consumer = await makeEmptyDir();
58
+ async function setKitReadiness(manifest) {
59
+ const content = `${JSON.stringify(manifest, null, 2)}\n`;
60
+ const path = join(kit, readinessPath);
61
+ await mkdir(join(kit, '.claude/skills'), { recursive: true });
62
+ await writeFile(path, content);
63
+ const pkg = await readManifest(join(kit, PACKAGE_MANIFEST_NAME));
64
+ const entry = pkg.files.find(({ path: candidate }) => candidate === readinessPath);
65
+ if (entry) entry.sha256 = sha256(content);
66
+ else pkg.files.push({ path: readinessPath, kind: 'doc', sha256: sha256(content), mode: 0o644, origin: 'kit' });
67
+ await writeManifest(join(kit, PACKAGE_MANIFEST_NAME), pkg);
68
+ }
69
+ try {
70
+ await setKitReadiness(previous);
71
+ await init({ kitRoot: kit, consumerRoot: consumer });
72
+ for (const [relative, body] of Object.entries(fixture.readinessFixture.evidence)) {
73
+ const path = join(consumer, relative);
74
+ await mkdir(join(path, '..'), { recursive: true });
75
+ await writeFile(path, body);
76
+ }
77
+ const manifestPath = join(consumer, 'agent-workflow-kit.json');
78
+ const installed = await readManifest(manifestPath);
79
+ await writeManifest(manifestPath, {
80
+ ...installed,
81
+ readinessDecisions: fixture.readinessDecisions ?? {},
82
+ readinessFixture: fixture.readinessFixture,
83
+ });
84
+ const before = {};
85
+ for (const skill of Object.keys(fixture.readinessFixture.skills)) {
86
+ before[skill] = await checkSkill({ root: consumer, skill, manifest: previous });
87
+ }
88
+ await setKitReadiness(current);
89
+ const identity = { name: '@ikon85/agent-workflow-kit', version: '0.1.0', tarballIntegrity: 'sha512-fixture', manifestSha256: 'fixture-manifest' };
90
+ const result = await update({
91
+ kitRoot: kit, consumerRoot: consumer,
92
+ releaseIdentities: { installed: { name: identity.name, version: identity.version, manifestSha256: identity.manifestSha256 }, npm: identity, github: identity },
93
+ verify: async () => {},
94
+ });
95
+ const afterManifest = await readManifest(manifestPath);
96
+ const after = {};
97
+ for (const skill of Object.keys(fixture.readinessFixture.skills)) {
98
+ after[skill] = await checkSkill({ root: consumer, skill, manifest: current });
99
+ }
100
+ console.log(JSON.stringify({ before, result, afterManifest, after }));
101
+ } finally {
102
+ await cleanup(kit, consumer);
103
+ }
104
+ """
35
105
 
36
106
 
37
107
  def load_manifest() -> dict:
@@ -100,11 +170,32 @@ class ReadinessContractTests(unittest.TestCase):
100
170
  blocks = list(declaration.get("optionalBlocks", {}))
101
171
  self.assertEqual(len(blocks), len(set(blocks)), skill)
102
172
 
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)
173
+ def test_locked_v1_policy_cannot_disappear_from_the_manifest(self) -> None:
174
+ actual_required = {
175
+ skill: entry["readiness"]["required"]
176
+ for skill, entry in self.published.items()
177
+ if entry.get("readiness", {}).get("required")
178
+ }
179
+ actual_optional = {
180
+ skill: entry["readiness"]["optionalBlocks"]
181
+ for skill, entry in self.published.items()
182
+ if entry.get("readiness", {}).get("optionalBlocks")
183
+ }
184
+ self.assertEqual(actual_required, REQUIRED_V1)
185
+ self.assertEqual(actual_optional, OPTIONAL_V1)
186
+
187
+ def test_every_declared_consumer_has_the_standard_preflight(self) -> None:
188
+ consumers = {
189
+ skill: entry for skill, entry in self.published.items()
190
+ if entry.get("readiness")
191
+ }
192
+ self.assertGreater(len(consumers), 0)
193
+ for skill, entry in consumers.items():
194
+ expected = f"node scripts/readiness.mjs check --skill {skill} --json"
195
+ for surface in entry["surfaces"]:
196
+ path = ROOT / SURFACE[surface] / skill / "SKILL.md"
197
+ body = path.read_text(encoding="utf-8")
198
+ self.assertEqual(body.count(expected), 1, f"{surface}:{skill}")
108
199
 
109
200
  def test_markers_are_balanced_declared_and_surface_equal_when_activated(self) -> None:
110
201
  for skill, entry in self.published.items():
@@ -114,6 +205,7 @@ class ReadinessContractTests(unittest.TestCase):
114
205
  path = ROOT / SURFACE[surface] / skill / "SKILL.md"
115
206
  self.assertTrue(path.is_file(), f"missing published surface: {path}")
116
207
  sequence = marker_sequence(path.read_text(encoding="utf-8"), declared)
208
+ self.assertEqual(set(sequence), declared, f"undeclared marker coverage: {surface}:{skill}")
117
209
  sequences.append((surface, sequence))
118
210
  if len(sequences) > 1:
119
211
  validate_surface_sequences([sequence for _, sequence in sequences])
@@ -134,6 +226,34 @@ class ReadinessContractTests(unittest.TestCase):
134
226
  with self.assertRaises(AssertionError):
135
227
  validate_surface_sequences([["projectEnrichment"], []])
136
228
 
229
+ def test_legacy_and_current_consumers_keep_previously_ready_skill_cores(self) -> None:
230
+ fixtures = sorted(FIXTURES.glob("*/agent-workflow-kit.json"))
231
+ self.assertEqual([path.parent.name for path in fixtures], ["current", "legacy"])
232
+ for fixture_path in fixtures:
233
+ fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
234
+ scenario = fixture["readinessFixture"]
235
+ with self.subTest(fixture=fixture_path.parent.name):
236
+ run = subprocess.run(
237
+ ["node", "--input-type=module", "-e", COMPAT_UPDATE_SCRIPT,
238
+ str(ROOT), str(fixture_path)],
239
+ check=True, capture_output=True, text=True,
240
+ )
241
+ proof = json.loads(run.stdout)
242
+ self.assertEqual(proof["result"]["state"], "applied")
243
+ self.assertEqual(proof["result"]["availability"]["newlyBlocked"], [])
244
+ for skill, expected in scenario["skills"].items():
245
+ self.assertEqual(proof["before"][skill]["verdict"], "ready", skill)
246
+ verdict = proof["after"][skill]
247
+ self.assertNotEqual(verdict["verdict"], "blocked", skill)
248
+ self.assertEqual(verdict["verdict"], expected["verdict"], skill)
249
+ self.assertEqual(verdict["inactiveBlocks"], expected["inactiveBlocks"], skill)
250
+ self.assertEqual(
251
+ proof["afterManifest"].get("readinessDecisions", {}),
252
+ scenario["expectedDecisions"],
253
+ "unavailable behavior must not manufacture a decision",
254
+ )
255
+ self.assertEqual(proof["afterManifest"]["readinessFixture"], scenario)
256
+
137
257
 
138
258
  if __name__ == "__main__":
139
259
  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()