@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.
- package/.agents/skills/audit-skills/SKILL.md +34 -5
- package/.agents/skills/board-to-waves/SKILL.md +14 -0
- package/.agents/skills/code-review/SKILL.md +22 -3
- package/.agents/skills/git-worktree-recover/SKILL.md +31 -10
- package/.agents/skills/kit-update/SKILL.md +21 -1
- package/.agents/skills/local-ci/SKILL.md +16 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +43 -14
- 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/audit-skills/SKILL.md +34 -5
- package/.claude/skills/board-to-waves/SKILL.md +14 -0
- package/.claude/skills/code-review/SKILL.md +22 -3
- package/.claude/skills/git-worktree-recover/SKILL.md +31 -10
- package/.claude/skills/kit-update/SKILL.md +21 -1
- package/.claude/skills/local-ci/SKILL.md +16 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +43 -14
- 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/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 +36 -36
- package/package.json +1 -1
- package/scripts/kit-update-pr.mjs +14 -1
- package/scripts/kit-update-pr.test.mjs +21 -0
- package/scripts/test_retro_wrapup_contract.py +17 -0
- package/scripts/test_skill_optional_readiness.py +171 -0
- package/scripts/test_skill_readiness_contract.py +127 -7
- 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/update.mjs +65 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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()
|