@ikon85/agent-workflow-kit 0.34.3 → 0.34.5

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 (35) hide show
  1. package/.agents/skills/kit-release/SKILL.md +12 -0
  2. package/.agents/skills/kit-update/SKILL.md +6 -3
  3. package/.agents/skills/setup-workflow/SKILL.md +13 -5
  4. package/.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
  5. package/.claude/skills/kit-release/SKILL.md +12 -0
  6. package/.claude/skills/kit-update/SKILL.md +6 -3
  7. package/.claude/skills/setup-workflow/SKILL.md +13 -5
  8. package/.claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
  9. package/README.md +17 -0
  10. package/agent-workflow-kit.package.json +12 -12
  11. package/docs/adr/0001-consumer-divergence-policy.md +4 -0
  12. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +63 -0
  13. package/docs/agents/board-sync.md +1 -1
  14. package/docs/agents/workflow-capabilities.json +17 -0
  15. package/docs/research/benchlm-routing-source.md +198 -0
  16. package/docs/research/consumer-owned-protocol-files.md +238 -0
  17. package/docs/research/frontend-agent-benchmarks.md +282 -0
  18. package/docs/research/model-effort-routing-benchmarks.md +261 -0
  19. package/docs/research/provider-neutral-agent-routing.md +207 -0
  20. package/package.json +1 -1
  21. package/scripts/kit-update-pr.mjs +1 -1
  22. package/scripts/kit-update-pr.test.mjs +2 -0
  23. package/scripts/release-delta-guard.mjs +28 -1
  24. package/scripts/release-delta-guard.test.mjs +45 -0
  25. package/scripts/release-state.mjs +7 -3
  26. package/scripts/release-state.test.mjs +22 -0
  27. package/scripts/test_skill_readiness_contract.py +6 -1
  28. package/scripts/test_skill_setup_workflow_seeds.py +18 -17
  29. package/src/commands/update.mjs +52 -20
  30. package/src/lib/bundle.mjs +1 -1
  31. package/src/lib/updateCandidate.mjs +278 -50
  32. package/src/lib/verifyUpdateCandidate.mjs +220 -0
  33. package/src/lib/verifyUpdateCandidateArtifacts.mjs +78 -0
  34. package/src/lib/verifyUpdateCandidateProtocol.mjs +221 -0
  35. package/src/lib/verifyUpdateCandidateTransaction.mjs +152 -0
@@ -56,7 +56,12 @@ for (const skill of Object.keys(fixture.readinessFixture.skills)) {
56
56
  const kit = await makeKit({ '.claude/skills/to-prd/SKILL.md': 'fixture\n' });
57
57
  const consumer = await makeEmptyDir();
58
58
  async function setKitReadiness(manifest) {
59
- const content = `${JSON.stringify(manifest, null, 2)}\n`;
59
+ const candidateManifest = structuredClone(manifest);
60
+ for (const [name, declaration] of Object.entries(candidateManifest.skills)) {
61
+ declaration.publish = name === 'to-prd';
62
+ if (name === 'to-prd') declaration.surfaces = ['claude'];
63
+ }
64
+ const content = `${JSON.stringify(candidateManifest, null, 2)}\n`;
60
65
  const path = join(kit, readinessPath);
61
66
  await mkdir(join(kit, '.claude/skills'), { recursive: true });
62
67
  await writeFile(path, content);
@@ -49,13 +49,12 @@ def classify(first_line, is_empty):
49
49
 
50
50
 
51
51
  def update_workflow_action(
52
- provider, choice, destination_exists, prerequisites=True,
53
- pull_requests_allowed=True,
52
+ provider, choice, destination_exists, pull_requests_allowed=True,
54
53
  ):
55
54
  """Reference decision table for the prompt-driven setup contract."""
56
55
  if provider != "github" or destination_exists or choice != "enable":
57
56
  return "skip"
58
- return "create" if prerequisites and pull_requests_allowed else "skip"
57
+ return "create" if pull_requests_allowed else "skip"
59
58
 
60
59
 
61
60
  def load_census_setup_effects():
@@ -421,19 +420,18 @@ class SeedTemplatesValid(unittest.TestCase):
421
420
 
422
421
  def test_update_workflow_provider_and_choice_fixtures(self):
423
422
  fixtures = [
424
- ("github", "enable", False, True, True, "create"),
425
- ("github", "opt-out", False, True, True, "skip"),
426
- ("github", "later", False, True, True, "skip"),
427
- ("github", "enable", True, True, True, "skip"),
428
- ("github", "enable", False, False, True, "skip"),
429
- ("github", "enable", False, True, False, "skip"),
430
- ("gitlab", "enable", False, True, True, "skip"),
431
- ("local", "enable", False, True, True, "skip"),
423
+ ("github", "enable", False, True, "create"),
424
+ ("github", "opt-out", False, True, "skip"),
425
+ ("github", "later", False, True, "skip"),
426
+ ("github", "enable", True, True, "skip"),
427
+ ("github", "enable", False, False, "skip"),
428
+ ("gitlab", "enable", False, True, "skip"),
429
+ ("local", "enable", False, True, "skip"),
432
430
  ]
433
- for provider, choice, exists, prerequisites, allowed, expected in fixtures:
431
+ for provider, choice, exists, allowed, expected in fixtures:
434
432
  self.assertEqual(
435
433
  update_workflow_action(
436
- provider, choice, exists, prerequisites, allowed,
434
+ provider, choice, exists, allowed,
437
435
  ), expected,
438
436
  )
439
437
 
@@ -446,8 +444,8 @@ class SeedTemplatesValid(unittest.TestCase):
446
444
  "Ask later",
447
445
  "GitHub tracker",
448
446
  "skipped (already present)",
449
- "package-lock.json",
450
- "npm test",
447
+ "built-in Kit invariant validator",
448
+ "no Consumer package script",
451
449
  "can_approve_pull_request_reviews",
452
450
  "Allow GitHub Actions to create and approve pull requests",
453
451
  "explicit confirmation",
@@ -461,10 +459,13 @@ class SeedTemplatesValid(unittest.TestCase):
461
459
  "schedule:", "workflow_dispatch:", "contents: write",
462
460
  "pull-requests: write", "agent-workflow-kit-update-pr",
463
461
  "@ikon85/agent-workflow-kit@latest", "fetch-depth: 0",
464
- "node-version: 22.14", "npm ci --ignore-scripts",
462
+ "node-version: 22.14", "Verify and upsert the Kit update pull request",
465
463
  ):
466
464
  self.assertIn(token, workflow)
467
- for forbidden in ("npm_token", "NPM_TOKEN", "auto-merge", "gh pr merge"):
465
+ for forbidden in (
466
+ "npm_token", "NPM_TOKEN", "auto-merge", "gh pr merge",
467
+ "npm ci", "npm test", "package-lock.json",
468
+ ):
468
469
  self.assertNotIn(forbidden, workflow)
469
470
 
470
471
  mirror = (REPO / ".agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml")
@@ -2,8 +2,11 @@ import { readFile, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { assertConsumerReleaseParity } from '../../scripts/release-parity.mjs';
4
4
  import {
5
- activateCandidate, adoptReadinessCandidate, readReadinessManifest, stageConsumer, verifyCandidate,
5
+ activateCandidate, adoptReadinessCandidate, materializeUpdateCandidate, readReadinessManifest,
6
6
  } from '../lib/updateCandidate.mjs';
7
+ import {
8
+ verifyCandidateSchema, verifyUpdateCandidate,
9
+ } from '../lib/verifyUpdateCandidate.mjs';
7
10
  import { reconcile } from '../lib/updateReconcile.mjs';
8
11
  import {
9
12
  CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, readManifest,
@@ -46,7 +49,7 @@ export async function update(options) {
46
49
  async function updatePackage(options) {
47
50
  const {
48
51
  kitRoot, consumerRoot, decide = () => false, dryRun = false,
49
- releaseIdentities, verify = verifyCandidate, activate = activateCandidate,
52
+ releaseIdentities, verify = verifyUpdateCandidate, activate = activateCandidate,
50
53
  signal, onState = () => {}, resumeFrom,
51
54
  } = options;
52
55
  const history = [];
@@ -57,7 +60,8 @@ async function updatePackage(options) {
57
60
  if (!pkg) throw new Error('kit package manifest not found');
58
61
  if (!dryRun) verifyRelease(releaseIdentities, pkg.kitVersion);
59
62
  const consumerManifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
60
- if (!await readManifest(consumerManifestPath)) {
63
+ const priorConsumerManifest = await readManifest(consumerManifestPath);
64
+ if (!priorConsumerManifest) {
61
65
  throw new Error('not initialised — run `init` first');
62
66
  }
63
67
  const consumerManifestBefore = await readFile(consumerManifestPath);
@@ -75,7 +79,7 @@ async function updatePackage(options) {
75
79
  let previewFailure;
76
80
  try {
77
81
  Object.assign(preview, await previewReadinessAdoption({
78
- kitRoot, consumerRoot, priorReadinessManifest, nextReadinessManifest,
82
+ kitRoot, consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
79
83
  }));
80
84
  preview.conflicts.push(...(preview.migrationConflicts ?? []).map((path) => ({
81
85
  path,
@@ -108,18 +112,26 @@ async function updatePackage(options) {
108
112
  }
109
113
  return applyTransaction({
110
114
  kitRoot, consumerRoot, pkg, preview: resolvedPreview, decisions, verify, activate, signal, resumeFrom,
111
- consumerManifestBefore, priorReadinessManifest, nextReadinessManifest, history, transition,
115
+ consumerManifestBefore, priorConsumerManifest,
116
+ priorReadinessManifest, nextReadinessManifest, history, transition,
112
117
  });
113
118
  }
114
119
 
115
120
  async function previewReadinessAdoption(context) {
116
- const { kitRoot, consumerRoot, priorReadinessManifest, nextReadinessManifest } = context;
117
- const candidateRoot = await stageConsumer(consumerRoot);
121
+ const {
122
+ kitRoot, consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
123
+ } = context;
124
+ const candidateRoot = await materializeUpdateCandidate({
125
+ consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
126
+ });
118
127
  try {
119
- await reconcile({
128
+ const candidatePreview = await reconcile({
120
129
  kitRoot, consumerRoot: candidateRoot,
121
130
  decide: (action) => action === 'collision' ? 'keep-as-owned' : false,
122
131
  });
132
+ await verifyCandidateSchema(candidateRoot, {
133
+ pkg, preview: candidatePreview, priorReadinessManifest, nextReadinessManifest,
134
+ });
123
135
  return await adoptReadinessCandidate({
124
136
  candidateRoot, consumerRoot, priorManifest: priorReadinessManifest,
125
137
  nextManifest: nextReadinessManifest,
@@ -146,7 +158,8 @@ async function resolvePreview({ kitRoot, consumerRoot, preview, decisions, decid
146
158
  async function applyTransaction(context) {
147
159
  const {
148
160
  kitRoot, consumerRoot, pkg, preview, decisions, verify, activate, signal, resumeFrom,
149
- consumerManifestBefore, priorReadinessManifest, nextReadinessManifest, history, transition,
161
+ consumerManifestBefore, priorConsumerManifest,
162
+ priorReadinessManifest, nextReadinessManifest, history, transition,
150
163
  } = context;
151
164
  let candidateRoot = resumeFrom;
152
165
  let keepCandidate = false;
@@ -157,12 +170,29 @@ async function applyTransaction(context) {
157
170
  throw new Error('collision-bearing candidate cannot be resumed safely');
158
171
  }
159
172
  if (!candidateRoot) {
160
- candidateRoot = await stageConsumer(consumerRoot);
173
+ candidateRoot = await materializeUpdateCandidate({
174
+ consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
175
+ });
161
176
  await reconcile({
162
177
  kitRoot, consumerRoot: candidateRoot,
163
178
  decide: (action, path) => decisions.get(decisionKey(action, path)),
164
179
  });
165
180
  }
181
+ phase = 'verification';
182
+ await transition('verifying');
183
+ const abort = async () => {
184
+ keepCandidate = true;
185
+ return { ...await terminal(preview, 'aborted', history, transition), candidateRoot };
186
+ };
187
+ if (signal?.aborted) return abort();
188
+ const canonicalContext = {
189
+ pkg: structuredClone(pkg),
190
+ preview: structuredClone(preview),
191
+ priorConsumerManifest: structuredClone(priorConsumerManifest),
192
+ priorReadinessManifest: structuredClone(priorReadinessManifest),
193
+ nextReadinessManifest: structuredClone(nextReadinessManifest),
194
+ };
195
+ await verifyCandidateSchema(candidateRoot, canonicalContext);
166
196
  const readiness = await adoptReadinessCandidate({
167
197
  candidateRoot, consumerRoot, priorManifest: priorReadinessManifest,
168
198
  nextManifest: nextReadinessManifest,
@@ -178,18 +208,20 @@ async function applyTransaction(context) {
178
208
  if (readiness.incompatible.length) {
179
209
  throw new Error(`monotonic compatibility would block existing skill core: ${readiness.incompatible.join(', ')}`);
180
210
  }
181
- phase = 'verification';
182
- await transition('verifying');
183
- const abort = async () => {
184
- keepCandidate = true;
185
- return { ...await terminal(preview, 'aborted', history, transition), candidateRoot };
186
- };
187
- if (signal?.aborted) return abort();
188
- await verify(candidateRoot);
211
+ canonicalContext.preview = structuredClone(preview);
212
+ await verifyUpdateCandidate(candidateRoot, canonicalContext);
213
+ if (verify !== verifyUpdateCandidate) {
214
+ const extensionContext = structuredClone(canonicalContext);
215
+ await verify(candidateRoot, extensionContext);
216
+ await verifyUpdateCandidate(candidateRoot, canonicalContext);
217
+ }
189
218
  if (signal?.aborted) return abort();
190
219
  phase = 'activation';
191
220
  await activate({
192
- candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
221
+ candidateRoot, consumerRoot,
222
+ pkg: canonicalContext.pkg,
223
+ preview: canonicalContext.preview,
224
+ consumerManifestBefore,
193
225
  });
194
226
  return { ...await terminal(preview, 'applied', history, transition), status: 'updated' };
195
227
  } catch (error) {
@@ -252,4 +284,4 @@ async function terminal(result, state, history, transition) {
252
284
  };
253
285
  }
254
286
 
255
- export { verifyCandidate } from '../lib/updateCandidate.mjs';
287
+ export { verifyUpdateCandidate, verifyUpdateCandidate as verifyCandidate } from '../lib/verifyUpdateCandidate.mjs';
@@ -107,7 +107,7 @@ export const HELPER_FILES = [
107
107
  // wave. Library (imported by the Phase-0 claim protocol) → 0o644.
108
108
  { path: 'src/lib/waveClaim.mjs', kind: 'script', mode: 0o644 },
109
109
  // GitHub-consumer automation: invokes the existing update command, then owns
110
- // only the stable tested branch/pull-request upsert.
110
+ // only the stable Kit-verified branch/pull-request upsert.
111
111
  { path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
112
112
  // Stdlib-only project census foundation. index.mjs is the stable consumer
113
113
  // entrypoint; its five local modules must ship with it as one helper unit.
@@ -1,8 +1,11 @@
1
- import { execFile } from 'node:child_process';
2
- import { access, cp, lstat, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
1
+ import { constants } from 'node:fs';
2
+ import {
3
+ access, lstat, mkdtemp, open, readFile, realpath, rm, stat,
4
+ } from 'node:fs/promises';
3
5
  import { tmpdir } from 'node:os';
4
- import { join, relative } from 'node:path';
5
- import { promisify } from 'node:util';
6
+ import {
7
+ isAbsolute, join, normalize, posix, relative, sep,
8
+ } from 'node:path';
6
9
  import { writeAtomic } from './atomicWrite.mjs';
7
10
  import { validateConsumerFile } from './consumerPath.mjs';
8
11
  import { sha256File } from './hash.mjs';
@@ -10,38 +13,195 @@ import { stubSentinel } from './sentinel.mjs';
10
13
  import { STUB_TARGETS } from './bundle.mjs';
11
14
  import {
12
15
  CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
13
- indexByPath, readManifest, writeManifest,
16
+ filesForInstallRole, indexByPath, readManifest, writeManifest,
14
17
  } from './manifest.mjs';
15
18
  import { checkSkill, evaluateCapability, inspectProdSections } from '../../scripts/readiness.mjs';
16
19
 
17
- const run = promisify(execFile);
18
20
  const exists = (path) => access(path).then(() => true, () => false);
19
21
  const pathEntryExists = (path) => lstat(path).then(() => true, (error) => {
20
22
  if (error.code === 'ENOENT') return false;
21
23
  throw error;
22
24
  });
23
25
  const MIGRATABLE_INSTRUCTION_PATHS = new Set(['CLAUDE.md', 'AGENTS.md']);
26
+ const INTEGRATION_INPUTS = [
27
+ 'package.json', '.claude/settings.json', '.claude/settings.local.json',
28
+ ];
29
+ const FORBIDDEN_CANDIDATE_ROOTS = new Set(['.git', '.worktrees', 'node_modules']);
30
+ const PLATFORM_PATH_SEMANTICS = { isAbsolute, normalize, sep };
24
31
 
25
- /** Copy a verification candidate without duplicating git metadata or dependencies. */
26
- export async function stageConsumer(consumerRoot) {
32
+ /** Materialize only manifest state and declared Consumer inputs for verification. */
33
+ export async function materializeUpdateCandidate({
34
+ consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
35
+ afterInputValidation = async () => {},
36
+ }) {
27
37
  const candidateRoot = await mkdtemp(join(tmpdir(), 'agent-workflow-kit-stage-'));
28
- const nodeModules = join(consumerRoot, 'node_modules');
29
- await cp(consumerRoot, candidateRoot, {
30
- recursive: true,
31
- filter: (source) => {
32
- const rel = relative(consumerRoot, source);
33
- return rel !== '.git' && !rel.startsWith('.git/') &&
34
- rel !== 'node_modules' && !rel.startsWith('node_modules/');
35
- },
36
- });
37
- if (await exists(nodeModules)) await symlink(nodeModules, join(candidateRoot, 'node_modules'), 'dir');
38
- return candidateRoot;
38
+ try {
39
+ const paths = candidateInputPaths({
40
+ pkg, manifests: [priorReadinessManifest, nextReadinessManifest],
41
+ });
42
+ for (const path of paths) {
43
+ await copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation);
44
+ }
45
+ await copyDeclaredRunbooks({
46
+ consumerRoot, candidateRoot, manifests: [priorReadinessManifest, nextReadinessManifest],
47
+ afterInputValidation,
48
+ });
49
+ return candidateRoot;
50
+ } catch (error) {
51
+ await rm(candidateRoot, { recursive: true, force: true });
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ export function candidateInputPaths({ pkg, manifests }) {
57
+ const candidates = [
58
+ CONSUMER_MANIFEST_NAME,
59
+ ...INTEGRATION_INPUTS,
60
+ ...filesForInstallRole(pkg).map(({ path }) => path),
61
+ ];
62
+ for (const manifest of manifests) {
63
+ for (const capability of Object.values(manifest?.readiness?.capabilities ?? {})) {
64
+ candidates.push(...(capability.evidence?.paths ?? []));
65
+ }
66
+ }
67
+ const paths = new Set(candidates.filter((path) => !isForbiddenCandidatePath(path)));
68
+ return [...paths].sort();
69
+ }
70
+
71
+ async function copyDeclaredRunbooks({
72
+ consumerRoot, candidateRoot, manifests, afterInputValidation,
73
+ }) {
74
+ const runbooks = await declaredCandidateRunbookPaths({ candidateRoot, manifests });
75
+ for (const path of runbooks) {
76
+ await copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation);
77
+ }
78
+ }
79
+
80
+ export async function declaredCandidateRunbookPaths({ candidateRoot, manifests }) {
81
+ const runbooks = new Set();
82
+ for (const manifest of manifests) {
83
+ for (const capability of Object.values(manifest?.readiness?.capabilities ?? {})) {
84
+ const evidence = capability.evidence;
85
+ if (evidence?.type !== 'runbook-reference') continue;
86
+ const declaration = await readCandidateText(candidateRoot, evidence.paths?.[0]);
87
+ for (const match of declaration?.matchAll(/`([^`\n]+\.md)`/g) ?? []) {
88
+ if (!match[1].includes('template')) runbooks.add(match[1]);
89
+ }
90
+ }
91
+ }
92
+ return [...runbooks].sort();
93
+ }
94
+
95
+ async function readCandidateText(candidateRoot, path) {
96
+ if (!path) return null;
97
+ try {
98
+ return await readFile(join(candidateRoot, path), 'utf8');
99
+ } catch (error) {
100
+ if (error.code === 'ENOENT') return null;
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ async function copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation) {
106
+ if (isForbiddenCandidatePath(path)) return;
107
+ const consumerPath = validateCandidateManifestPath(path);
108
+ let source;
109
+ try {
110
+ source = await validateConsumerFile(consumerRoot, consumerPath);
111
+ } catch (error) {
112
+ if (!error.message.startsWith('unsafe consumer path (not a regular file):')) throw error;
113
+ if (!await pathEntryExists(join(consumerRoot, path))) return;
114
+ throw error;
115
+ }
116
+ const root = await realpath(consumerRoot);
117
+ const resolved = await realpath(source);
118
+ assertResolvedConsumerPath(root, resolved, path);
119
+ const validated = await stat(resolved, { bigint: true });
120
+ const pathname = await lstat(source, { bigint: true });
121
+ if (!sameFile(validated, pathname)) {
122
+ throw new Error(`consumer input changed while staging: ${path}`);
123
+ }
124
+ await afterInputValidation(path);
125
+
126
+ let handle;
127
+ try {
128
+ handle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
129
+ const opened = await handle.stat({ bigint: true });
130
+ if (!opened.isFile() || !sameFile(validated, opened)) {
131
+ throw new Error(`consumer input changed while staging: ${path}`);
132
+ }
133
+ const bytes = await handle.readFile();
134
+ const finished = await handle.stat({ bigint: true });
135
+ if (!sameFileSnapshot(opened, finished)) {
136
+ throw new Error(`consumer input changed while staging: ${path}`);
137
+ }
138
+ const resolvedAfter = await realpath(source);
139
+ assertResolvedConsumerPath(root, resolvedAfter, path);
140
+ const current = await stat(resolvedAfter, { bigint: true });
141
+ if (!sameFile(opened, current)) {
142
+ throw new Error(`consumer input changed while staging: ${path}`);
143
+ }
144
+ await writeAtomic(join(candidateRoot, path), bytes, Number(opened.mode));
145
+ } catch (error) {
146
+ if (error.code === 'ELOOP') {
147
+ throw new Error(`unsafe consumer path (not a regular file): ${path}`, { cause: error });
148
+ }
149
+ throw error;
150
+ } finally {
151
+ await handle?.close();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Accept the package manifest's canonical slash-separated path and translate it
157
+ * only after platform-independent lexical validation.
158
+ */
159
+ export function validateCandidateManifestPath(path, pathSemantics = PLATFORM_PATH_SEMANTICS) {
160
+ if (typeof path !== 'string' || !path || path === '.' || path.includes('\\')
161
+ || path.split('/').includes('..')
162
+ || posix.isAbsolute(path) || posix.normalize(path) !== path) {
163
+ throw new Error(`unsafe candidate manifest path: ${path}`);
164
+ }
165
+ const platformPath = path.split('/').join(pathSemantics.sep);
166
+ if (pathSemantics.isAbsolute(platformPath)
167
+ || pathSemantics.normalize(platformPath) !== platformPath) {
168
+ throw new Error(`unsafe candidate manifest path: ${path}`);
169
+ }
170
+ return platformPath;
171
+ }
172
+
173
+ function isForbiddenCandidatePath(path) {
174
+ if (typeof path !== 'string') return false;
175
+ const [root] = path.split(/[\\/]/);
176
+ return FORBIDDEN_CANDIDATE_ROOTS.has(root);
177
+ }
178
+
179
+ function assertResolvedConsumerPath(root, resolved, path) {
180
+ const fromRoot = relative(root, resolved);
181
+ if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${pathSeparator()}`)
182
+ || isAbsolute(fromRoot)) {
183
+ throw new Error(`unsafe consumer path (resolved outside root): ${path}`);
184
+ }
185
+ }
186
+
187
+ function pathSeparator() {
188
+ return process.platform === 'win32' ? '\\' : '/';
189
+ }
190
+
191
+ function sameFile(left, right) {
192
+ return left.dev === right.dev && left.ino === right.ino;
193
+ }
194
+
195
+ function sameFileSnapshot(left, right) {
196
+ return sameFile(left, right) && left.size === right.size &&
197
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
39
198
  }
40
199
 
41
200
  /** Activate only verified kit-owned deltas, rolling every touched path back on failure. */
42
201
  export async function activateCandidate({
43
202
  candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
44
- afterGenerated = async () => {},
203
+ afterSnapshot = async () => {}, afterGenerated = async () => {},
204
+ beforeTargetRevalidation = async () => {},
45
205
  }) {
46
206
  const changed = [...preview.added, ...preview.updated];
47
207
  const generated = preview.generated ?? [];
@@ -50,10 +210,6 @@ export async function activateCandidate({
50
210
  ...changed, ...generated, ...migrations.map(({ path }) => path),
51
211
  ...preview.deleted, CONSUMER_MANIFEST_NAME,
52
212
  ];
53
- const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
54
- if (!currentManifest.equals(consumerManifestBefore)) {
55
- throw new Error('consumer manifest changed during verification');
56
- }
57
213
  const pkgIdx = indexByPath(pkg, 'files');
58
214
  for (const path of changed) {
59
215
  if (await sha256File(join(candidateRoot, path)) !== pkgIdx.get(path)?.sha256) {
@@ -72,28 +228,77 @@ export async function activateCandidate({
72
228
  throw new Error(`migrated candidate hash mismatch: ${migration.path}`);
73
229
  }
74
230
  }
231
+ const manifestBeforeSnapshot = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
232
+ if (!manifestBeforeSnapshot.equals(consumerManifestBefore)) {
233
+ throw new Error('consumer manifest changed during verification');
234
+ }
75
235
  await assertConsumerStillMatchesPreview(consumerRoot, preview);
76
236
  const rollback = new Map();
77
- for (const path of touched) rollback.set(path, await snapshot(join(consumerRoot, path)));
237
+ for (const path of touched) {
238
+ rollback.set(path, await snapshot(join(consumerRoot, path), path));
239
+ }
240
+ await afterSnapshot();
241
+ const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
242
+ if (!currentManifest.equals(consumerManifestBefore)) {
243
+ throw new Error('consumer manifest changed during verification');
244
+ }
245
+ await assertConsumerStillMatchesPreview(consumerRoot, preview);
246
+ const applied = [];
247
+ const applyTarget = async (path, action) => {
248
+ await beforeTargetRevalidation(path);
249
+ await assertTargetStillMatchesSnapshot(
250
+ join(consumerRoot, path), rollback.get(path), path,
251
+ );
252
+ // Optimistic revalidation cannot remove the filesystem check-to-rename
253
+ // micro-window; it does keep every later destination behind a fresh check.
254
+ await action();
255
+ const record = { path, snapshot: null, captured: false };
256
+ applied.push(record);
257
+ record.snapshot = await snapshot(join(consumerRoot, path), path);
258
+ record.captured = true;
259
+ };
78
260
  try {
79
261
  for (const path of changed) {
80
- await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)), pkgIdx.get(path)?.mode);
262
+ const bytes = await readFile(join(candidateRoot, path));
263
+ await applyTarget(path, () => writeAtomic(
264
+ join(consumerRoot, path), bytes, pkgIdx.get(path)?.mode,
265
+ ));
81
266
  }
82
267
  for (const path of generated) {
83
- await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)));
268
+ const bytes = await readFile(join(candidateRoot, path));
269
+ await applyTarget(path, () => writeAtomic(join(consumerRoot, path), bytes));
84
270
  }
85
271
  for (const { path } of migrations) {
86
- await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)));
272
+ const bytes = await readFile(join(candidateRoot, path));
273
+ await applyTarget(path, () => writeAtomic(join(consumerRoot, path), bytes));
87
274
  }
88
275
  await afterGenerated();
89
- for (const path of preview.deleted) await rm(join(consumerRoot, path), { force: true });
90
- await writeAtomic(
91
- join(consumerRoot, CONSUMER_MANIFEST_NAME),
92
- await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME)),
93
- );
276
+ for (const path of preview.deleted) {
277
+ await applyTarget(path, () => rm(join(consumerRoot, path), { force: true }));
278
+ }
279
+ const manifestBytes = await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME));
280
+ await applyTarget(CONSUMER_MANIFEST_NAME, () => writeAtomic(
281
+ join(consumerRoot, CONSUMER_MANIFEST_NAME), manifestBytes,
282
+ ));
94
283
  } catch (error) {
95
- for (const path of touched.reverse()) await restore(join(consumerRoot, path), rollback.get(path));
96
- error.consumerState = 'rolled-back';
284
+ const rollbackConflicts = [];
285
+ for (const record of applied.reverse()) {
286
+ const target = join(consumerRoot, record.path);
287
+ if (!record.captured
288
+ || !await targetStillMatchesSnapshot(target, record.snapshot, record.path)) {
289
+ rollbackConflicts.push(record.path);
290
+ continue;
291
+ }
292
+ await restore(target, rollback.get(record.path));
293
+ }
294
+ if (rollbackConflicts.length) {
295
+ rollbackConflicts.sort();
296
+ error.message = `${error.message}; rollback preserved concurrent edits: ` +
297
+ rollbackConflicts.join(', ');
298
+ }
299
+ error.consumerState = rollbackConflicts.length
300
+ ? 'rollback-conflicted'
301
+ : (applied.length ? 'rolled-back' : 'unchanged');
97
302
  throw error;
98
303
  }
99
304
  }
@@ -278,26 +483,49 @@ export async function readReadinessManifest(root) {
278
483
  return readManifest(join(root, READINESS_MANIFEST_PATH));
279
484
  }
280
485
 
281
- async function snapshot(path) {
282
- if (!await exists(path)) return null;
283
- const info = await stat(path);
284
- return { bytes: await readFile(path), mode: info.mode };
486
+ async function snapshot(path, displayPath = path) {
487
+ let before;
488
+ try {
489
+ before = await lstat(path, { bigint: true });
490
+ } catch (error) {
491
+ if (error.code === 'ENOENT') return null;
492
+ throw error;
493
+ }
494
+ if (!before.isFile()) {
495
+ throw new Error(`unsafe consumer activation path: ${displayPath}`);
496
+ }
497
+ const bytes = await readFile(path);
498
+ const after = await lstat(path, { bigint: true });
499
+ if (!sameFileSnapshot(before, after)) {
500
+ throw new Error(`consumer changed during activation: ${displayPath}`);
501
+ }
502
+ return { bytes, mode: Number(before.mode), identity: before };
285
503
  }
286
504
 
287
- async function restore(path, saved) {
288
- if (!saved) return rm(path, { force: true });
289
- await writeAtomic(path, saved.bytes, saved.mode);
505
+ async function assertTargetStillMatchesSnapshot(path, expected, displayPath) {
506
+ if (!await targetStillMatchesSnapshot(path, expected, displayPath)) {
507
+ throw new Error(`consumer changed during activation: ${displayPath}`);
508
+ }
290
509
  }
291
510
 
292
- /** Default candidate gate: run the consumer's existing npm test command. */
293
- export async function verifyCandidate(candidateRoot) {
294
- let pkg;
511
+ async function targetStillMatchesSnapshot(path, expected, displayPath) {
512
+ let current;
295
513
  try {
296
- pkg = JSON.parse(await readFile(join(candidateRoot, 'package.json'), 'utf8'));
514
+ current = await snapshot(path, displayPath);
297
515
  } catch (error) {
298
- if (error.code === 'ENOENT') throw new Error('candidate has no package.json test command');
299
- throw error;
516
+ return false;
300
517
  }
301
- if (!pkg.scripts?.test) throw new Error('candidate has no package.json test command');
302
- await run('npm', ['test'], { cwd: candidateRoot });
518
+ return sameActivationSnapshot(expected, current);
519
+ }
520
+
521
+ function sameActivationSnapshot(expected, current) {
522
+ if (!expected || !current) return expected === current;
523
+ return expected.mode === current.mode
524
+ && expected.bytes.equals(current.bytes)
525
+ && sameFileSnapshot(expected.identity, current.identity);
526
+ }
527
+
528
+ async function restore(path, saved) {
529
+ if (!saved) return rm(path, { force: true });
530
+ await writeAtomic(path, saved.bytes, saved.mode);
303
531
  }