@ikon85/agent-workflow-kit 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.agents/skills/board-to-waves/SKILL.md +14 -0
  2. package/.agents/skills/code-review/SKILL.md +22 -3
  3. package/.agents/skills/kit-update/SKILL.md +21 -1
  4. package/.agents/skills/local-ci/SKILL.md +16 -0
  5. package/.agents/skills/project-release/SKILL.md +18 -0
  6. package/.agents/skills/security-audit/SKILL.md +18 -0
  7. package/.agents/skills/setup-workflow/SKILL.md +39 -2
  8. package/.agents/skills/spec-self-critique/SKILL.md +16 -7
  9. package/.agents/skills/to-issues/SKILL.md +13 -1
  10. package/.agents/skills/to-prd/SKILL.md +14 -0
  11. package/.agents/skills/to-waves/SKILL.md +16 -2
  12. package/.agents/skills/triage/SKILL.md +24 -0
  13. package/.agents/skills/verify-spike/SKILL.md +20 -1
  14. package/.agents/skills/wrapup/SKILL.md +26 -4
  15. package/.claude/skills/board-to-waves/SKILL.md +14 -0
  16. package/.claude/skills/code-review/SKILL.md +22 -3
  17. package/.claude/skills/kit-update/SKILL.md +21 -1
  18. package/.claude/skills/local-ci/SKILL.md +16 -0
  19. package/.claude/skills/project-release/SKILL.md +18 -0
  20. package/.claude/skills/security-audit/SKILL.md +18 -0
  21. package/.claude/skills/setup-workflow/SKILL.md +39 -2
  22. package/.claude/skills/spec-self-critique/SKILL.md +16 -7
  23. package/.claude/skills/to-issues/SKILL.md +13 -1
  24. package/.claude/skills/to-prd/SKILL.md +14 -0
  25. package/.claude/skills/to-waves/SKILL.md +16 -2
  26. package/.claude/skills/triage/SKILL.md +24 -0
  27. package/.claude/skills/verify-spike/SKILL.md +20 -1
  28. package/.claude/skills/wrapup/SKILL.md +26 -4
  29. package/README.md +32 -0
  30. package/agent-workflow-kit.package.json +30 -30
  31. package/package.json +1 -1
  32. package/scripts/kit-update-pr.mjs +14 -1
  33. package/scripts/kit-update-pr.test.mjs +21 -0
  34. package/scripts/test_retro_wrapup_contract.py +17 -0
  35. package/scripts/test_skill_optional_readiness.py +171 -0
  36. package/scripts/test_skill_readiness_preflight.py +180 -0
  37. package/scripts/test_skill_required_readiness.py +233 -0
  38. package/scripts/test_skill_setup_workflow_seeds.py +22 -0
  39. package/src/cli.mjs +10 -2
  40. package/src/commands/update.mjs +65 -8
  41. package/src/lib/updateCandidate.mjs +116 -2
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """Behavior contract for required-readiness planning preflights."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ READINESS = ROOT / "scripts/readiness.mjs"
13
+ MANIFEST = ROOT / ".claude/skills/skill-manifest.json"
14
+ SKILLS = ("to-prd", "to-issues", "to-waves", "board-to-waves")
15
+ SPEC_SKILLS = ("to-issues", "to-waves")
16
+ PREFLIGHT_START = "<!-- readiness:required-preflight:start -->"
17
+ PREFLIGHT_END = "<!-- readiness:required-preflight:end -->"
18
+
19
+
20
+ def write(root: Path, relative: str, body: str) -> None:
21
+ path = root / relative
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ path.write_text(body, encoding="utf-8")
24
+
25
+
26
+ def configure_issue_tracker(root: Path) -> None:
27
+ write(
28
+ root,
29
+ "docs/agents/issue-tracker.md",
30
+ "<!-- setup-workflow: state=filled -->\nGitHub Issues.\n",
31
+ )
32
+
33
+
34
+ def configure_board(root: Path) -> None:
35
+ profile = {
36
+ "repo": "owner/repo",
37
+ "project": {"owner": "owner", "number": 1, "nodeId": "project"},
38
+ "fields": {
39
+ "status": {
40
+ "id": "status",
41
+ "options": {"Todo": "todo"},
42
+ "roles": {"triaged": "Todo"},
43
+ },
44
+ "wave": "wave",
45
+ "cluster": "cluster",
46
+ },
47
+ "labels": {"readyForAgent": "ready-for-agent"},
48
+ }
49
+ write(
50
+ root,
51
+ "docs/agents/board-sync.md",
52
+ "<!-- board-sync:profile -->\n```json\n"
53
+ f"{json.dumps(profile)}\n```\n",
54
+ )
55
+
56
+
57
+ def configure_spec_completeness(root: Path) -> None:
58
+ write(
59
+ root,
60
+ "docs/conventions/spec-completeness.md",
61
+ "<!-- setup-workflow: state=filled -->\nVertical slice rules.\n",
62
+ )
63
+
64
+
65
+ class PlanningReadinessPreflightTests(unittest.TestCase):
66
+ def setUp(self) -> None:
67
+ self.temp = tempfile.TemporaryDirectory()
68
+ self.root = Path(self.temp.name)
69
+ write(
70
+ self.root,
71
+ ".claude/skills/skill-manifest.json",
72
+ MANIFEST.read_text(encoding="utf-8"),
73
+ )
74
+ write(
75
+ self.root,
76
+ "agent-workflow-kit.json",
77
+ json.dumps({
78
+ "kitVersion": "0.0.0-test",
79
+ "readinessContractVersion": 1,
80
+ "readinessDecisions": {},
81
+ "installed": [],
82
+ }),
83
+ )
84
+
85
+ def tearDown(self) -> None:
86
+ self.temp.cleanup()
87
+
88
+ def check(self, skill: str) -> dict:
89
+ completed = subprocess.run(
90
+ ["node", str(READINESS), "check", "--skill", skill, "--json", "--root", str(self.root)],
91
+ check=True,
92
+ capture_output=True,
93
+ text=True,
94
+ )
95
+ return json.loads(completed.stdout)
96
+
97
+ def snapshot(self) -> dict[Path, bytes]:
98
+ return {
99
+ path.relative_to(self.root): path.read_bytes()
100
+ for path in self.root.rglob("*") if path.is_file()
101
+ }
102
+
103
+ def test_missing_requirements_block_without_writing_fixture(self) -> None:
104
+ before = self.snapshot()
105
+ for skill in SKILLS:
106
+ with self.subTest(skill=skill):
107
+ result = self.check(skill)
108
+ self.assertEqual(result["verdict"], "blocked")
109
+ self.assertEqual(result["capabilities"]["issueTracker"]["state"], "missing")
110
+ self.assertEqual(result["capabilities"]["managedBoard"]["state"], "missing")
111
+ self.assertEqual(self.snapshot(), before)
112
+
113
+ def test_invalid_requirement_is_visible_and_check_remains_read_only(self) -> None:
114
+ configure_board(self.root)
115
+ configure_spec_completeness(self.root)
116
+ write(
117
+ self.root,
118
+ "docs/agents/issue-tracker.md",
119
+ "<!-- setup-workflow: state=stub -->\npartial\n",
120
+ )
121
+ before = self.snapshot()
122
+ result = self.check("to-prd")
123
+ self.assertEqual(result["verdict"], "blocked")
124
+ self.assertEqual(result["capabilities"]["issueTracker"]["state"], "invalid")
125
+ self.assertEqual(self.snapshot(), before)
126
+
127
+ def test_managed_board_not_applicable_makes_every_board_writer_inapplicable(self) -> None:
128
+ configure_issue_tracker(self.root)
129
+ configure_spec_completeness(self.root)
130
+ manifest = json.loads((self.root / "agent-workflow-kit.json").read_text(encoding="utf-8"))
131
+ manifest["readinessDecisions"]["managedBoard"] = "not-applicable"
132
+ write(self.root, "agent-workflow-kit.json", json.dumps(manifest))
133
+ for skill in SKILLS:
134
+ with self.subTest(skill=skill):
135
+ result = self.check(skill)
136
+ self.assertEqual(result["verdict"], "blocked")
137
+ self.assertEqual(
138
+ result["capabilities"]["managedBoard"]["state"],
139
+ "not-applicable",
140
+ )
141
+
142
+ def test_spec_completeness_blocks_only_the_slicing_flows(self) -> None:
143
+ configure_issue_tracker(self.root)
144
+ configure_board(self.root)
145
+ for skill in SKILLS:
146
+ with self.subTest(skill=skill):
147
+ result = self.check(skill)
148
+ expected = "blocked" if skill in SPEC_SKILLS else "ready"
149
+ self.assertEqual(result["verdict"], expected)
150
+ if skill in SPEC_SKILLS:
151
+ self.assertEqual(
152
+ result["capabilities"]["specCompleteness"]["state"],
153
+ "missing",
154
+ )
155
+
156
+ def test_ready_state_and_skill_prose_share_one_silent_first_step(self) -> None:
157
+ configure_issue_tracker(self.root)
158
+ configure_board(self.root)
159
+ configure_spec_completeness(self.root)
160
+ for skill in SKILLS:
161
+ with self.subTest(skill=skill):
162
+ self.assertEqual(self.check(skill)["verdict"], "ready")
163
+ source = (ROOT / ".claude/skills" / skill / "SKILL.md").read_text(encoding="utf-8")
164
+ mirror = (ROOT / ".agents/skills" / skill / "SKILL.md").read_text(encoding="utf-8")
165
+ source_block = source.split(PREFLIGHT_START, 1)[1].split(PREFLIGHT_END, 1)[0]
166
+ mirror_block = mirror.split(PREFLIGHT_START, 1)[1].split(PREFLIGHT_END, 1)[0]
167
+ self.assertEqual(source_block, mirror_block)
168
+ self.assertIn(f"node scripts/readiness.mjs check --skill {skill} --json", source_block)
169
+ self.assertIn("before any remote write", source_block)
170
+ self.assertIn("Ready is silent", source_block)
171
+ self.assertIn("Run `/setup-workflow`, then rerun", source_block)
172
+ first_mutation = min(
173
+ position for token in ("board-sync.py create", "gh issue edit")
174
+ if (position := source.find(token)) >= 0
175
+ )
176
+ self.assertLess(source.find(PREFLIGHT_START), first_mutation)
177
+
178
+
179
+ if __name__ == "__main__":
180
+ unittest.main()
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python3
2
+ """Focused contract tests for required operational-skill readiness gates."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ SKILLS = {
15
+ "triage": ("issueTracker", "managedBoard", "triageLabels"),
16
+ "local-ci": ("localCiRecipe",),
17
+ "project-release": ("projectReleaseProfile",),
18
+ "security-audit": ("securityAuditRunbook",),
19
+ }
20
+
21
+
22
+ def write(root: Path, relative: str, body: str) -> None:
23
+ path = root / relative
24
+ path.parent.mkdir(parents=True, exist_ok=True)
25
+ path.write_text(body, encoding="utf-8")
26
+
27
+
28
+ def snapshot(root: Path) -> dict[str, bytes]:
29
+ return {
30
+ str(path.relative_to(root)): path.read_bytes()
31
+ for path in root.rglob("*")
32
+ if path.is_file()
33
+ }
34
+
35
+
36
+ def check(root: Path, skill: str) -> dict:
37
+ run = subprocess.run(
38
+ [
39
+ "node",
40
+ str(ROOT / "scripts/readiness.mjs"),
41
+ "check",
42
+ "--skill",
43
+ skill,
44
+ "--json",
45
+ "--root",
46
+ str(root),
47
+ ],
48
+ check=True,
49
+ capture_output=True,
50
+ text=True,
51
+ )
52
+ return json.loads(run.stdout)
53
+
54
+
55
+ def seed_manifest(root: Path, decisions: dict[str, str] | None = None) -> None:
56
+ write(
57
+ root,
58
+ ".claude/skills/skill-manifest.json",
59
+ (ROOT / ".claude/skills/skill-manifest.json").read_text(encoding="utf-8"),
60
+ )
61
+ write(
62
+ root,
63
+ "agent-workflow-kit.json",
64
+ json.dumps(
65
+ {
66
+ "kitVersion": "fixture",
67
+ "readinessContractVersion": 1,
68
+ "readinessDecisions": decisions or {},
69
+ "installed": [],
70
+ }
71
+ ),
72
+ )
73
+
74
+
75
+ def seed_valid_evidence(root: Path) -> None:
76
+ sentinel = "<!-- setup-workflow: state=filled -->\nconfigured\n"
77
+ write(root, "docs/agents/issue-tracker.md", sentinel)
78
+ write(root, "docs/agents/triage-labels.md", sentinel)
79
+ board = {
80
+ "repo": "owner/repo",
81
+ "project": {"owner": "owner", "number": 1, "nodeId": "project"},
82
+ "fields": {
83
+ "status": {
84
+ "id": "status",
85
+ "options": {"Done": "done"},
86
+ "roles": {"done": "Done"},
87
+ },
88
+ "wave": "wave",
89
+ "cluster": "cluster",
90
+ },
91
+ "labels": {"readyForAgent": "ready-for-agent"},
92
+ }
93
+ write(
94
+ root,
95
+ "docs/agents/board-sync.md",
96
+ f"<!-- board-sync:profile -->\n```json\n{json.dumps(board)}\n```\n",
97
+ )
98
+ write(root, "docs/agents/skills/local-ci.md", sentinel)
99
+ write(
100
+ root,
101
+ "docs/agents/workflow-capabilities.json",
102
+ json.dumps(
103
+ {
104
+ "schemaVersion": 1,
105
+ "projectRelease": {
106
+ "versionFiles": ["package.json"],
107
+ "tagPrefix": "v",
108
+ },
109
+ }
110
+ ),
111
+ )
112
+ write(
113
+ root,
114
+ "docs/agents/skills/security-audit.md",
115
+ "<!-- setup-workflow: state=filled -->\n"
116
+ "Use `docs/security/audit-runbook.md`.\n",
117
+ )
118
+ write(root, "docs/security/audit-runbook.md", "# Audit\nConcrete checks.\n")
119
+
120
+
121
+ def readiness_section(body: str) -> str:
122
+ match = re.search(
123
+ r"^## Required readiness preflight\n[\s\S]*?(?=^## |\Z)", body, re.MULTILINE
124
+ )
125
+ if not match:
126
+ raise AssertionError("required readiness preflight section is missing")
127
+ return match.group(0).strip()
128
+
129
+
130
+ class RequiredReadinessSkillTests(unittest.TestCase):
131
+ def test_every_operational_skill_gates_before_mutation(self) -> None:
132
+ first_mutation = {
133
+ "triage": "## Triage a specific issue",
134
+ "local-ci": "## The two profiles",
135
+ "project-release": "## Workflow",
136
+ "security-audit": "## Workflow — two-model run",
137
+ }
138
+ for skill, capabilities in SKILLS.items():
139
+ with self.subTest(skill=skill):
140
+ body = (ROOT / ".claude/skills" / skill / "SKILL.md").read_text(
141
+ encoding="utf-8"
142
+ )
143
+ heading = "## Required readiness preflight"
144
+ self.assertIn(heading, body)
145
+ self.assertLess(body.index(heading), body.index(first_mutation[skill]))
146
+ self.assertIn(
147
+ f"node scripts/readiness.mjs check --skill {skill} --json", body
148
+ )
149
+ for capability in capabilities:
150
+ self.assertIn(f"`{capability}`", body)
151
+ for state in ("missing", "pending", "not-applicable", "invalid"):
152
+ self.assertIn(f"`{state}`", body)
153
+ self.assertIn("verdict is silent", body)
154
+ self.assertIn("Never ", body)
155
+ mirror = (ROOT / ".agents/skills" / skill / "SKILL.md").read_text(
156
+ encoding="utf-8"
157
+ )
158
+ self.assertEqual(readiness_section(mirror), readiness_section(body))
159
+
160
+ project_release = (
161
+ ROOT / ".claude/skills/project-release/SKILL.md"
162
+ ).read_text(encoding="utf-8")
163
+ preflight = project_release.index(
164
+ "node scripts/readiness.mjs check --skill project-release --json"
165
+ )
166
+ helper = project_release.index("node scripts/project-release.mjs preview")
167
+ self.assertLess(preflight, helper)
168
+ self.assertIn("helper remains the authority", project_release)
169
+
170
+ def test_helper_blocks_missing_prerequisites_without_side_effects(self) -> None:
171
+ for skill, capabilities in SKILLS.items():
172
+ with self.subTest(skill=skill), tempfile.TemporaryDirectory() as temp:
173
+ root = Path(temp)
174
+ seed_manifest(root)
175
+ before = snapshot(root)
176
+ result = check(root, skill)
177
+ self.assertEqual(result["verdict"], "blocked")
178
+ self.assertEqual(
179
+ {name: result["capabilities"][name]["state"] for name in capabilities},
180
+ {name: "missing" for name in capabilities},
181
+ )
182
+ self.assertEqual(snapshot(root), before)
183
+
184
+ def test_helper_distinguishes_pending_not_applicable_invalid_and_ready(self) -> None:
185
+ invalid_evidence = {
186
+ "triage": ("docs/agents/issue-tracker.md", "<!-- setup-workflow: state=stub -->\n"),
187
+ "local-ci": ("docs/agents/skills/local-ci.md", "<!-- setup-workflow: state=stub -->\n"),
188
+ "project-release": ("docs/agents/workflow-capabilities.json", "{}\n"),
189
+ "security-audit": (
190
+ "docs/agents/skills/security-audit.md",
191
+ "<!-- setup-workflow: state=filled -->\nno project runbook\n",
192
+ ),
193
+ }
194
+ for skill, capabilities in SKILLS.items():
195
+ capability = capabilities[0]
196
+ with self.subTest(skill=skill, state="pending"), tempfile.TemporaryDirectory() as temp:
197
+ root = Path(temp)
198
+ seed_manifest(root, {capability: "pending"})
199
+ self.assertEqual(check(root, skill)["capabilities"][capability]["state"], "pending")
200
+
201
+ with self.subTest(skill=skill, state="invalid"), tempfile.TemporaryDirectory() as temp:
202
+ root = Path(temp)
203
+ seed_manifest(root)
204
+ seed_valid_evidence(root)
205
+ write(root, *invalid_evidence[skill])
206
+ result = check(root, skill)
207
+ self.assertEqual(result["verdict"], "blocked")
208
+ self.assertEqual(result["capabilities"][capability]["state"], "invalid")
209
+
210
+ with self.subTest(skill=skill, state="ready"), tempfile.TemporaryDirectory() as temp:
211
+ root = Path(temp)
212
+ seed_manifest(root)
213
+ seed_valid_evidence(root)
214
+ before = snapshot(root)
215
+ result = check(root, skill)
216
+ self.assertEqual(result["verdict"], "ready")
217
+ self.assertTrue(
218
+ all(item["state"] == "ready" for item in result["capabilities"].values())
219
+ )
220
+ self.assertEqual(snapshot(root), before)
221
+
222
+ with self.subTest(skill="triage", state="not-applicable"), tempfile.TemporaryDirectory() as temp:
223
+ root = Path(temp)
224
+ seed_manifest(root, {"managedBoard": "not-applicable"})
225
+ seed_valid_evidence(root)
226
+ (root / "docs/agents/board-sync.md").unlink()
227
+ result = check(root, "triage")
228
+ self.assertEqual(result["verdict"], "blocked")
229
+ self.assertEqual(result["capabilities"]["managedBoard"]["state"], "not-applicable")
230
+
231
+
232
+ if __name__ == "__main__":
233
+ unittest.main()
@@ -213,6 +213,28 @@ class IdempotencyRule(unittest.TestCase):
213
213
 
214
214
 
215
215
  class SeedTemplatesValid(unittest.TestCase):
216
+ def test_prod_readiness_choice_and_transaction_contract_is_complete(self):
217
+ for surface in (".claude", ".agents"):
218
+ text = (REPO / surface / "skills/setup-workflow/SKILL.md").read_text(
219
+ encoding="utf-8"
220
+ )
221
+ for token in (
222
+ "Configure now", "Configure later",
223
+ "decision set prodTarget pending",
224
+ "wrapup.deployReport omitted (prodTarget pending)",
225
+ "`prodTarget` does not permit this choice",
226
+ "Preview the exact local `## Prod` replacement",
227
+ "external mutation retains its own separate",
228
+ "activeBlocks` must be exactly `[\"deployReport\"]`",
229
+ ):
230
+ self.assertIn(token, text, f"{surface} missing {token!r}")
231
+ preserve = re.search(
232
+ r"Preserve each file's\s+`## Workflow` and `## Agent skills` "
233
+ r"blocks byte-for-byte",
234
+ text,
235
+ )
236
+ self.assertIsNotNone(preserve, surface)
237
+
216
238
  def test_census_effect_contract_executes_every_transition_and_repeats_without_writes(self):
217
239
  effects = load_census_setup_effects()
218
240
  self.assertEqual(
package/src/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import * as p from '@clack/prompts';
5
5
  import { init } from './commands/init.mjs';
6
- import { update } from './commands/update.mjs';
6
+ import { renderUpdateFailure, update } from './commands/update.mjs';
7
7
  import { diff } from './commands/diff.mjs';
8
8
  import { uninstall } from './commands/uninstall.mjs';
9
9
  import { setOwnership } from './commands/own.mjs';
@@ -49,7 +49,7 @@ try {
49
49
  });
50
50
  printPlan(r);
51
51
  for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
52
- if (r.state === 'failed') throw new Error(`candidate update failed: ${r.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) {
@@ -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 { activateCandidate, stageConsumer, verifyCandidate } from '../lib/updateCandidate.mjs';
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, signal, onState = () => {}, resumeFrom,
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
- await activateCandidate({
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 { ...await terminal(preview, 'failed', history, transition), error: error.message };
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
  }