@ikon85/agent-workflow-kit 0.29.0 → 0.30.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/codex-adapter-sync/SKILL.md +134 -72
- package/.claude/skills/skill-manifest.json +46 -0
- package/README.md +13 -0
- package/agent-workflow-kit.package.json +42 -2
- package/package.json +1 -1
- package/scripts/readiness.mjs +215 -0
- package/scripts/release-state.test.mjs +53 -1
- package/scripts/test_codex_adapter_sync_contract.py +154 -0
- package/scripts/test_skill_portability_lint.py +57 -18
- package/scripts/test_skill_readiness_contract.py +139 -0
- package/src/commands/init.mjs +3 -1
- package/src/commands/uninstall.mjs +5 -1
- package/src/lib/bundle.mjs +7 -0
- package/src/lib/manifest.mjs +26 -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()
|
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
|
}
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -4,6 +4,13 @@ import { join, relative } from 'node:path';
|
|
|
4
4
|
// The planning skills are not usable without their helper ecosystem (Codex R2#1).
|
|
5
5
|
// These ship alongside the skills. Paths are relative to the bundle/consumer root.
|
|
6
6
|
export const HELPER_FILES = [
|
|
7
|
+
// Readiness declarations and their deterministic consumer-side command ship
|
|
8
|
+
// together; the manifest remains the only capability/dependency registry.
|
|
9
|
+
{ path: '.claude/skills/skill-manifest.json', kind: 'doc', mode: 0o644 },
|
|
10
|
+
{ path: 'scripts/readiness.mjs', kind: 'script', mode: 0o755 },
|
|
11
|
+
{ path: 'src/lib/sentinel.mjs', kind: 'script', mode: 0o644 },
|
|
12
|
+
{ path: 'src/lib/manifest.mjs', kind: 'script', mode: 0o644 },
|
|
13
|
+
{ path: 'src/lib/atomicWrite.mjs', kind: 'script', mode: 0o644 },
|
|
7
14
|
// Shared profile loader imported by the three planning scripts — they read
|
|
8
15
|
// every board-specific value from docs/agents/board-sync.md through it, so it
|
|
9
16
|
// MUST ship or they are broken-on-arrival. Library (imported, not run) → 0o644.
|
package/src/lib/manifest.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import { writeAtomic } from './atomicWrite.mjs';
|
|
3
4
|
|
|
4
5
|
// Two manifests (Codex R1#9 / R3#1):
|
|
@@ -15,6 +16,8 @@ export const PACKAGE_MANIFEST_NAME = 'agent-workflow-kit.package.json';
|
|
|
15
16
|
export const CONSUMER_INSTALL_ROLE = 'consumer';
|
|
16
17
|
export const KIT_ORIGIN = 'kit';
|
|
17
18
|
export const CONSUMER_ORIGIN = 'consumer';
|
|
19
|
+
export const READINESS_CONTRACT_VERSION = 1;
|
|
20
|
+
export const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Parse a JSON manifest, or null if the file does not exist. A corrupt file
|
|
@@ -51,8 +54,29 @@ export async function writeManifest(path, obj) {
|
|
|
51
54
|
await writeAtomic(path, JSON.stringify(obj, null, 2) + '\n');
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
export function
|
|
55
|
-
|
|
57
|
+
export async function readReadinessContract(root) {
|
|
58
|
+
const manifest = await readManifest(join(root, READINESS_MANIFEST_PATH));
|
|
59
|
+
return manifest?.readiness ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function retainedDecisions(prior, readiness) {
|
|
63
|
+
const allowed = readiness?.capabilities ? new Set(Object.keys(readiness.capabilities)) : null;
|
|
64
|
+
return Object.fromEntries(Object.entries(prior.readinessDecisions ?? {}).filter(
|
|
65
|
+
([name, value]) => (!allowed || allowed.has(name)) && (value === 'pending'
|
|
66
|
+
|| (value === 'not-applicable' && readiness?.capabilities?.[name]?.allowNotApplicable)),
|
|
67
|
+
));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function emptyConsumerManifest(kitVersion, prior = {}, readiness = null) {
|
|
71
|
+
prior ??= {};
|
|
72
|
+
return {
|
|
73
|
+
...prior,
|
|
74
|
+
kitVersion,
|
|
75
|
+
installRole: CONSUMER_INSTALL_ROLE,
|
|
76
|
+
readinessContractVersion: readiness?.contractVersion ?? READINESS_CONTRACT_VERSION,
|
|
77
|
+
readinessDecisions: retainedDecisions(prior, readiness),
|
|
78
|
+
installed: [],
|
|
79
|
+
};
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
/** Package entries without a role predate role-aware installs and remain consumer-owned. */
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, emptyConsumerManifest,
|
|
9
9
|
CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, KIT_ORIGIN, filesForInstallRole,
|
|
10
10
|
indexByPath, readManifest, writeManifest,
|
|
11
|
+
readReadinessContract,
|
|
11
12
|
} from './manifest.mjs';
|
|
12
13
|
|
|
13
14
|
const exists = (path) => access(path).then(() => true, () => false);
|
|
@@ -18,6 +19,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
|
|
|
18
19
|
if (!pkg) throw new Error('kit package manifest not found');
|
|
19
20
|
const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
20
21
|
if (!consumer) throw new Error('not initialised — run `init` first');
|
|
22
|
+
const readiness = await readReadinessContract(kitRoot);
|
|
21
23
|
|
|
22
24
|
const installedIdx = indexByPath(consumer, 'installed');
|
|
23
25
|
const packageIdx = indexByPath(pkg, 'files');
|
|
@@ -117,7 +119,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
|
|
|
117
119
|
|
|
118
120
|
if (!dryRun) {
|
|
119
121
|
await writeManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME), {
|
|
120
|
-
...emptyConsumerManifest(pkg.kitVersion), installed: nextInstalled,
|
|
122
|
+
...emptyConsumerManifest(pkg.kitVersion, consumer, readiness), installed: nextInstalled,
|
|
121
123
|
});
|
|
122
124
|
}
|
|
123
125
|
return result;
|