@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,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/update.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readFile, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { assertConsumerReleaseParity } from '../../scripts/release-parity.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
activateCandidate, adoptReadinessCandidate, readReadinessManifest, stageConsumer, verifyCandidate,
|
|
6
|
+
} from '../lib/updateCandidate.mjs';
|
|
5
7
|
import { reconcile } from '../lib/updateReconcile.mjs';
|
|
6
8
|
import {
|
|
7
9
|
CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, readManifest,
|
|
@@ -9,6 +11,12 @@ import {
|
|
|
9
11
|
|
|
10
12
|
const RELEASE_NAME = '@ikon85/agent-workflow-kit';
|
|
11
13
|
|
|
14
|
+
export function renderUpdateFailure(result) {
|
|
15
|
+
const failure = result.failure ?? { phase: 'unknown', consumerState: 'unknown' };
|
|
16
|
+
return `candidate update failed · phase: ${failure.phase} · ` +
|
|
17
|
+
`consumerState: ${failure.consumerState} · ${result.error}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
/**
|
|
13
21
|
* Transactionally reconcile a consumer with a parity-proven kit release.
|
|
14
22
|
* checking -> preview/awaiting_decision -> staging -> verifying -> terminal state.
|
|
@@ -16,7 +24,8 @@ const RELEASE_NAME = '@ikon85/agent-workflow-kit';
|
|
|
16
24
|
export async function update(options) {
|
|
17
25
|
const {
|
|
18
26
|
kitRoot, consumerRoot, decide = () => false, dryRun = false,
|
|
19
|
-
releaseIdentities, verify = verifyCandidate,
|
|
27
|
+
releaseIdentities, verify = verifyCandidate, activate = activateCandidate,
|
|
28
|
+
signal, onState = () => {}, resumeFrom,
|
|
20
29
|
} = options;
|
|
21
30
|
const history = [];
|
|
22
31
|
const transition = async (state) => { history.push(state); await onState(state); };
|
|
@@ -30,6 +39,8 @@ export async function update(options) {
|
|
|
30
39
|
throw new Error('not initialised — run `init` first');
|
|
31
40
|
}
|
|
32
41
|
const consumerManifestBefore = await readFile(consumerManifestPath);
|
|
42
|
+
const priorReadinessManifest = await readReadinessManifest(consumerRoot);
|
|
43
|
+
const nextReadinessManifest = await readReadinessManifest(kitRoot);
|
|
33
44
|
|
|
34
45
|
const decisions = new Map();
|
|
35
46
|
const choosePreview = async (action, path) => {
|
|
@@ -39,7 +50,21 @@ export async function update(options) {
|
|
|
39
50
|
return decisions.get(key);
|
|
40
51
|
};
|
|
41
52
|
const preview = await reconcile({ kitRoot, consumerRoot, decide: choosePreview, dryRun: true });
|
|
53
|
+
let previewFailure;
|
|
54
|
+
try {
|
|
55
|
+
Object.assign(preview, await previewReadinessAdoption({
|
|
56
|
+
kitRoot, consumerRoot, priorReadinessManifest, nextReadinessManifest,
|
|
57
|
+
}));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
previewFailure = error;
|
|
60
|
+
}
|
|
42
61
|
await transition('preview');
|
|
62
|
+
if (previewFailure) {
|
|
63
|
+
return {
|
|
64
|
+
...await terminal(preview, 'failed', history, transition), error: previewFailure.message,
|
|
65
|
+
failure: { phase: 'staging', consumerState: 'unchanged' },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
43
68
|
if (dryRun) return { ...preview, state: 'preview', history };
|
|
44
69
|
if (preview.conflicts.length) return terminal(preview, 'conflicted', history, transition);
|
|
45
70
|
const resolvedPreview = await resolvePreview({
|
|
@@ -52,11 +77,28 @@ export async function update(options) {
|
|
|
52
77
|
return { ...await terminal(resolvedPreview, 'applied', history, transition), status: 'current' };
|
|
53
78
|
}
|
|
54
79
|
return applyTransaction({
|
|
55
|
-
kitRoot, consumerRoot, pkg, preview: resolvedPreview, decisions, verify, signal, resumeFrom,
|
|
56
|
-
consumerManifestBefore, history, transition,
|
|
80
|
+
kitRoot, consumerRoot, pkg, preview: resolvedPreview, decisions, verify, activate, signal, resumeFrom,
|
|
81
|
+
consumerManifestBefore, priorReadinessManifest, nextReadinessManifest, history, transition,
|
|
57
82
|
});
|
|
58
83
|
}
|
|
59
84
|
|
|
85
|
+
async function previewReadinessAdoption(context) {
|
|
86
|
+
const { kitRoot, consumerRoot, priorReadinessManifest, nextReadinessManifest } = context;
|
|
87
|
+
const candidateRoot = await stageConsumer(consumerRoot);
|
|
88
|
+
try {
|
|
89
|
+
await reconcile({
|
|
90
|
+
kitRoot, consumerRoot: candidateRoot,
|
|
91
|
+
decide: (action) => action === 'collision' ? 'keep-as-owned' : false,
|
|
92
|
+
});
|
|
93
|
+
return await adoptReadinessCandidate({
|
|
94
|
+
candidateRoot, consumerRoot, priorManifest: priorReadinessManifest,
|
|
95
|
+
nextManifest: nextReadinessManifest,
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
await rm(candidateRoot, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
60
102
|
async function resolvePreview({ kitRoot, consumerRoot, preview, decisions, decide, transition }) {
|
|
61
103
|
if (preview.deleted.length || preview.keptDeleted.length || preview.collisions.length) {
|
|
62
104
|
await transition('awaiting_decision');
|
|
@@ -73,11 +115,12 @@ async function resolvePreview({ kitRoot, consumerRoot, preview, decisions, decid
|
|
|
73
115
|
|
|
74
116
|
async function applyTransaction(context) {
|
|
75
117
|
const {
|
|
76
|
-
kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
|
|
77
|
-
consumerManifestBefore, history, transition,
|
|
118
|
+
kitRoot, consumerRoot, pkg, preview, decisions, verify, activate, signal, resumeFrom,
|
|
119
|
+
consumerManifestBefore, priorReadinessManifest, nextReadinessManifest, history, transition,
|
|
78
120
|
} = context;
|
|
79
121
|
let candidateRoot = resumeFrom;
|
|
80
122
|
let keepCandidate = false;
|
|
123
|
+
let phase = 'staging';
|
|
81
124
|
try {
|
|
82
125
|
await transition('staging');
|
|
83
126
|
if (candidateRoot && preview.collisionResolutions.length) {
|
|
@@ -90,6 +133,16 @@ async function applyTransaction(context) {
|
|
|
90
133
|
decide: (action, path) => decisions.get(decisionKey(action, path)),
|
|
91
134
|
});
|
|
92
135
|
}
|
|
136
|
+
const readiness = await adoptReadinessCandidate({
|
|
137
|
+
candidateRoot, consumerRoot, priorManifest: priorReadinessManifest,
|
|
138
|
+
nextManifest: nextReadinessManifest,
|
|
139
|
+
});
|
|
140
|
+
preview.generated = readiness.generated;
|
|
141
|
+
preview.availability = readiness.availability;
|
|
142
|
+
if (readiness.incompatible.length) {
|
|
143
|
+
throw new Error(`monotonic compatibility would block existing skill core: ${readiness.incompatible.join(', ')}`);
|
|
144
|
+
}
|
|
145
|
+
phase = 'verification';
|
|
93
146
|
await transition('verifying');
|
|
94
147
|
const abort = async () => {
|
|
95
148
|
keepCandidate = true;
|
|
@@ -98,12 +151,16 @@ async function applyTransaction(context) {
|
|
|
98
151
|
if (signal?.aborted) return abort();
|
|
99
152
|
await verify(candidateRoot);
|
|
100
153
|
if (signal?.aborted) return abort();
|
|
101
|
-
|
|
154
|
+
phase = 'activation';
|
|
155
|
+
await activate({
|
|
102
156
|
candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
|
|
103
157
|
});
|
|
104
158
|
return { ...await terminal(preview, 'applied', history, transition), status: 'updated' };
|
|
105
159
|
} catch (error) {
|
|
106
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
...await terminal(preview, 'failed', history, transition), error: error.message,
|
|
162
|
+
failure: { phase, consumerState: error.consumerState ?? 'unchanged' },
|
|
163
|
+
};
|
|
107
164
|
} finally {
|
|
108
165
|
if (candidateRoot && !keepCandidate) await rm(candidateRoot, { recursive: true, force: true });
|
|
109
166
|
}
|
|
@@ -6,7 +6,13 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import { writeAtomic } from './atomicWrite.mjs';
|
|
7
7
|
import { validateConsumerFile } from './consumerPath.mjs';
|
|
8
8
|
import { sha256File } from './hash.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { stubSentinel } from './sentinel.mjs';
|
|
10
|
+
import { STUB_TARGETS } from './bundle.mjs';
|
|
11
|
+
import {
|
|
12
|
+
CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
|
|
13
|
+
indexByPath, readManifest, writeManifest,
|
|
14
|
+
} from './manifest.mjs';
|
|
15
|
+
import { checkSkill, evaluateCapability } from '../../scripts/readiness.mjs';
|
|
10
16
|
|
|
11
17
|
const run = promisify(execFile);
|
|
12
18
|
const exists = (path) => access(path).then(() => true, () => false);
|
|
@@ -30,9 +36,11 @@ export async function stageConsumer(consumerRoot) {
|
|
|
30
36
|
/** Activate only verified kit-owned deltas, rolling every touched path back on failure. */
|
|
31
37
|
export async function activateCandidate({
|
|
32
38
|
candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
|
|
39
|
+
afterGenerated = async () => {},
|
|
33
40
|
}) {
|
|
34
41
|
const changed = [...preview.added, ...preview.updated];
|
|
35
|
-
const
|
|
42
|
+
const generated = preview.generated ?? [];
|
|
43
|
+
const touched = [...changed, ...generated, ...preview.deleted, CONSUMER_MANIFEST_NAME];
|
|
36
44
|
const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
37
45
|
if (!currentManifest.equals(consumerManifestBefore)) {
|
|
38
46
|
throw new Error('consumer manifest changed during verification');
|
|
@@ -43,6 +51,13 @@ export async function activateCandidate({
|
|
|
43
51
|
throw new Error(`candidate hash mismatch: ${path}`);
|
|
44
52
|
}
|
|
45
53
|
}
|
|
54
|
+
const candidateManifest = await readManifest(join(candidateRoot, CONSUMER_MANIFEST_NAME));
|
|
55
|
+
const candidateInstalled = indexByPath(candidateManifest, 'installed');
|
|
56
|
+
for (const path of generated) {
|
|
57
|
+
if (await sha256File(join(candidateRoot, path)) !== candidateInstalled.get(path)?.installedSha256) {
|
|
58
|
+
throw new Error(`generated candidate hash mismatch: ${path}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
46
61
|
await assertConsumerStillMatchesPreview(consumerRoot, preview);
|
|
47
62
|
const rollback = new Map();
|
|
48
63
|
for (const path of touched) rollback.set(path, await snapshot(join(consumerRoot, path)));
|
|
@@ -50,6 +65,10 @@ export async function activateCandidate({
|
|
|
50
65
|
for (const path of changed) {
|
|
51
66
|
await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)), pkgIdx.get(path)?.mode);
|
|
52
67
|
}
|
|
68
|
+
for (const path of generated) {
|
|
69
|
+
await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)));
|
|
70
|
+
}
|
|
71
|
+
await afterGenerated();
|
|
53
72
|
for (const path of preview.deleted) await rm(join(consumerRoot, path), { force: true });
|
|
54
73
|
await writeAtomic(
|
|
55
74
|
join(consumerRoot, CONSUMER_MANIFEST_NAME),
|
|
@@ -57,6 +76,7 @@ export async function activateCandidate({
|
|
|
57
76
|
);
|
|
58
77
|
} catch (error) {
|
|
59
78
|
for (const path of touched.reverse()) await restore(join(consumerRoot, path), rollback.get(path));
|
|
79
|
+
error.consumerState = 'rolled-back';
|
|
60
80
|
throw error;
|
|
61
81
|
}
|
|
62
82
|
}
|
|
@@ -80,6 +100,11 @@ async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
|
|
|
80
100
|
if (replacements.has(path)) continue;
|
|
81
101
|
if (await exists(join(consumerRoot, path))) throw new Error(`consumer changed during verification: ${path}`);
|
|
82
102
|
}
|
|
103
|
+
for (const path of preview.generated ?? []) {
|
|
104
|
+
if (await exists(join(consumerRoot, path))) {
|
|
105
|
+
throw new Error(`consumer changed during verification: ${path}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
83
108
|
for (const path of [...preview.updated, ...preview.deleted]) {
|
|
84
109
|
const prior = installed.get(path);
|
|
85
110
|
const current = await exists(join(consumerRoot, path))
|
|
@@ -90,6 +115,95 @@ async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
|
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
118
|
+
/** Seed only newly declared, decision-free project-layer stubs in a staged candidate. */
|
|
119
|
+
export async function adoptReadinessCandidate({ candidateRoot, consumerRoot, priorManifest, nextManifest }) {
|
|
120
|
+
const priorPaths = readinessStubPaths(priorManifest);
|
|
121
|
+
const manifestPath = join(candidateRoot, CONSUMER_MANIFEST_NAME);
|
|
122
|
+
const manifest = await readManifest(manifestPath);
|
|
123
|
+
const candidateInstalled = indexByPath(manifest, 'installed');
|
|
124
|
+
const generated = [];
|
|
125
|
+
for (const path of readinessStubPaths(nextManifest)) {
|
|
126
|
+
if (priorPaths.has(path)) continue;
|
|
127
|
+
if (await exists(join(candidateRoot, path))) {
|
|
128
|
+
if (candidateInstalled.get(path)?.origin === CONSUMER_ORIGIN
|
|
129
|
+
&& !await exists(join(consumerRoot, path))) generated.push(path);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (await exists(join(consumerRoot, path))) continue;
|
|
133
|
+
await writeAtomic(join(candidateRoot, path), `${stubSentinel()}\n`);
|
|
134
|
+
generated.push(path);
|
|
135
|
+
}
|
|
136
|
+
if (generated.length) {
|
|
137
|
+
const installed = [...manifest.installed];
|
|
138
|
+
for (const generatedPath of generated) {
|
|
139
|
+
if (candidateInstalled.has(generatedPath)) continue;
|
|
140
|
+
installed.push({
|
|
141
|
+
path: generatedPath, kind: 'doc', installedSha256: await sha256File(join(candidateRoot, generatedPath)),
|
|
142
|
+
origin: CONSUMER_ORIGIN, installRole: 'consumer',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
await writeManifest(manifestPath, { ...manifest, installed });
|
|
146
|
+
}
|
|
147
|
+
const before = await readinessSnapshot(consumerRoot, priorManifest);
|
|
148
|
+
const after = await readinessSnapshot(candidateRoot, nextManifest);
|
|
149
|
+
const incompatible = Object.entries(after.skills)
|
|
150
|
+
.filter(([skill, current]) => before.skills[skill]?.verdict !== 'blocked'
|
|
151
|
+
&& before.skills[skill] && current.verdict === 'blocked')
|
|
152
|
+
.map(([skill]) => skill)
|
|
153
|
+
.sort();
|
|
154
|
+
return { generated, availability: readinessDiff(before, after), incompatible };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readinessStubPaths(manifest) {
|
|
158
|
+
const safe = new Set(STUB_TARGETS);
|
|
159
|
+
return new Set(Object.values(manifest?.readiness?.capabilities ?? {}).flatMap((capability) => {
|
|
160
|
+
if (capability.evidence?.type !== 'sentinel') return [];
|
|
161
|
+
return (capability.evidence.paths ?? []).filter((path) => safe.has(path));
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function readinessSnapshot(root, manifest) {
|
|
166
|
+
const skills = {};
|
|
167
|
+
for (const [name, declaration] of Object.entries(manifest?.skills ?? {})) {
|
|
168
|
+
if (!declaration.readiness) continue;
|
|
169
|
+
skills[name] = await checkSkill({ root, skill: name, manifest });
|
|
170
|
+
}
|
|
171
|
+
const consumer = await readManifest(join(root, CONSUMER_MANIFEST_NAME));
|
|
172
|
+
const capabilities = {};
|
|
173
|
+
for (const [name, capability] of Object.entries(manifest?.readiness?.capabilities ?? {})) {
|
|
174
|
+
capabilities[name] = await evaluateCapability({
|
|
175
|
+
root, capability, decision: consumer?.readinessDecisions?.[name],
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return { skills, capabilities };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readinessDiff(before, after) {
|
|
182
|
+
const newlyAvailable = [];
|
|
183
|
+
const newlyDegraded = [];
|
|
184
|
+
const newlyBlocked = [];
|
|
185
|
+
const unresolved = new Set();
|
|
186
|
+
for (const [skill, current] of Object.entries(after.skills)) {
|
|
187
|
+
const prior = before.skills[skill];
|
|
188
|
+
if (current.verdict === 'blocked' && prior?.verdict !== 'blocked') newlyBlocked.push(skill);
|
|
189
|
+
if (current.verdict !== 'blocked' && (!prior || prior.verdict === 'blocked')) newlyAvailable.push(skill);
|
|
190
|
+
for (const block of current.inactiveBlocks) {
|
|
191
|
+
if (!prior || !prior.inactiveBlocks.includes(block)) newlyDegraded.push(`${skill}.${block}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const [capability, result] of Object.entries(after.capabilities)) {
|
|
195
|
+
if (result.state !== 'ready') unresolved.add(`${capability}:${result.state}`);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
newlyAvailable: newlyAvailable.sort(), newlyDegraded: newlyDegraded.sort(),
|
|
199
|
+
newlyBlocked: newlyBlocked.sort(), stillUnresolved: [...unresolved].sort(),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function readReadinessManifest(root) {
|
|
204
|
+
return readManifest(join(root, READINESS_MANIFEST_PATH));
|
|
205
|
+
}
|
|
206
|
+
|
|
93
207
|
async function snapshot(path) {
|
|
94
208
|
if (!await exists(path)) return null;
|
|
95
209
|
const info = await stat(path);
|