@ikon85/agent-workflow-kit 0.34.4 → 0.34.6

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 (34) hide show
  1. package/.agents/skills/kit-release/SKILL.md +29 -21
  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 +29 -21
  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 +15 -0
  10. package/agent-workflow-kit.package.json +10 -10
  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/adr/0004-release-intent-is-a-version-tag.md +18 -5
  14. package/docs/agents/board-sync.md +1 -1
  15. package/docs/agents/workflow-capabilities.json +17 -0
  16. package/docs/research/benchlm-routing-source.md +198 -0
  17. package/docs/research/consumer-owned-protocol-files.md +238 -0
  18. package/docs/research/frontend-agent-benchmarks.md +282 -0
  19. package/docs/research/model-effort-routing-benchmarks.md +261 -0
  20. package/docs/research/provider-neutral-agent-routing.md +207 -0
  21. package/package.json +1 -1
  22. package/scripts/kit-release.test.mjs +10 -4
  23. package/scripts/kit-update-pr.mjs +1 -1
  24. package/scripts/kit-update-pr.test.mjs +2 -0
  25. package/scripts/test_release_authorization_contract.py +101 -0
  26. package/scripts/test_skill_readiness_contract.py +6 -1
  27. package/scripts/test_skill_setup_workflow_seeds.py +18 -17
  28. package/src/commands/update.mjs +52 -20
  29. package/src/lib/bundle.mjs +1 -1
  30. package/src/lib/updateCandidate.mjs +278 -50
  31. package/src/lib/verifyUpdateCandidate.mjs +220 -0
  32. package/src/lib/verifyUpdateCandidateArtifacts.mjs +78 -0
  33. package/src/lib/verifyUpdateCandidateProtocol.mjs +221 -0
  34. package/src/lib/verifyUpdateCandidateTransaction.mjs +152 -0
@@ -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
  }
@@ -0,0 +1,220 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { sha256File } from './hash.mjs';
5
+ import {
6
+ CONSUMER_INSTALL_ROLE, CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, KIT_ORIGIN,
7
+ emptyConsumerManifest, filesForInstallRole,
8
+ } from './manifest.mjs';
9
+ import { validateCandidateManifestPath } from './updateCandidate.mjs';
10
+ import {
11
+ verifyCandidateMembership, verifyChangedSyntax,
12
+ } from './verifyUpdateCandidateArtifacts.mjs';
13
+ import { verifyCandidateProtocol } from './verifyUpdateCandidateProtocol.mjs';
14
+ import {
15
+ verifyDeletionState, verifyDerivedArtifacts, verifyTransactionPreview,
16
+ } from './verifyUpdateCandidateTransaction.mjs';
17
+
18
+ const HASH = /^[a-f0-9]{64}$/;
19
+ const PACKAGE_KINDS = new Set(['skill', 'hook', 'doc', 'template', 'script']);
20
+
21
+ /**
22
+ * Verify the Kit-owned staged end state without executing Consumer behavior.
23
+ *
24
+ * The trusted package manifest and transaction preview are supplied by the
25
+ * updater; candidate-owned metadata never establishes its own authority.
26
+ */
27
+ export async function verifyUpdateCandidate(candidateRoot, context) {
28
+ await verifyCandidateMetadata(candidateRoot, context);
29
+ await verifyChangedSyntax(candidateRoot, context.preview);
30
+ }
31
+
32
+ export async function verifyCandidateMetadata(candidateRoot, context) {
33
+ if (!candidateRoot || !context?.pkg) {
34
+ throw new Error('candidate invariant transaction: trusted package manifest is required');
35
+ }
36
+ const { installable, installed, ledger } = await verifyCandidateSchema(candidateRoot, context);
37
+ if (!Array.isArray(context.priorConsumerManifest?.installed)) {
38
+ throw new Error('candidate invariant ownership: trusted prior Consumer ledger is required');
39
+ }
40
+ verifyLedgerMetadata(ledger, context);
41
+ const priorInstalled = uniqueEntries(context.priorConsumerManifest.installed, 'prior ledger');
42
+ verifyTransactionPreview(context.preview, installable, installed);
43
+ verifyDeletionState(installed, context.pkg, installable, context.preview);
44
+ await verifyCandidateMembership(candidateRoot, context);
45
+ for (const entry of installable) {
46
+ const tracked = installed.get(entry.path);
47
+ if (!tracked) {
48
+ throw new Error(`candidate invariant ownership: untracked package path ${entry.path}`);
49
+ }
50
+ validateInstalledEntry(tracked, entry, expectedOrigin(entry.path, priorInstalled, context.preview));
51
+ let state;
52
+ try {
53
+ state = await lstat(join(candidateRoot, entry.path));
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT') {
56
+ throw new Error(`candidate invariant artifact: missing ${entry.path}`);
57
+ }
58
+ throw error;
59
+ }
60
+ if (!state.isFile()) {
61
+ throw new Error(`candidate invariant artifact: not a regular file ${entry.path}`);
62
+ }
63
+ if (tracked.origin === KIT_ORIGIN && (state.mode & 0o777) !== entry.mode) {
64
+ throw new Error(`candidate invariant artifact: mode mismatch ${entry.path}`);
65
+ }
66
+ const expectedHash = tracked.origin === CONSUMER_ORIGIN
67
+ ? tracked.installedSha256 : entry.sha256;
68
+ if (await sha256File(join(candidateRoot, entry.path)) !== expectedHash) {
69
+ throw new Error(`candidate invariant artifact: hash mismatch ${entry.path}`);
70
+ }
71
+ }
72
+ await verifyDerivedArtifacts(candidateRoot, installed, context.preview);
73
+ }
74
+
75
+ export async function verifyCandidateSchema(candidateRoot, context) {
76
+ if (!candidateRoot || !context?.pkg) {
77
+ throw new Error('candidate invariant transaction: trusted package manifest is required');
78
+ }
79
+ const installable = packageArtifacts(context.pkg);
80
+ const ledger = await candidateLedger(candidateRoot, context);
81
+ const installed = uniqueEntries(ledger.installed, 'ledger');
82
+ await verifyCandidateProtocol(candidateRoot, context.pkg, installed);
83
+ return { installable, installed, ledger };
84
+ }
85
+
86
+ export { verifyUpdateCandidate as verifyCandidate };
87
+
88
+ function packageArtifacts(pkg) {
89
+ if (typeof pkg.kitVersion !== 'string' || !pkg.kitVersion) {
90
+ throw new Error('candidate invariant manifest: package kitVersion is required');
91
+ }
92
+ if (!Array.isArray(pkg.files)) {
93
+ throw new Error('candidate invariant manifest: package files must be an array');
94
+ }
95
+ const seen = new Set();
96
+ for (const entry of pkg.files) {
97
+ if (!entry || typeof entry.path !== 'string') {
98
+ throw new Error('candidate invariant manifest: package entry path is required');
99
+ }
100
+ try {
101
+ validateCandidateManifestPath(entry.path);
102
+ } catch {
103
+ throw new Error(`candidate invariant manifest: unsafe path ${entry.path}`);
104
+ }
105
+ if (seen.has(entry.path)) {
106
+ throw new Error(`candidate invariant manifest: duplicate path ${entry.path}`);
107
+ }
108
+ if (!HASH.test(entry.sha256 ?? '')) {
109
+ throw new Error(`candidate invariant manifest: invalid hash ${entry.path}`);
110
+ }
111
+ if (!Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777) {
112
+ throw new Error(`candidate invariant manifest: invalid mode ${entry.path}`);
113
+ }
114
+ if (!PACKAGE_KINDS.has(entry.kind)) {
115
+ throw new Error(`candidate invariant manifest: invalid kind ${entry.path}`);
116
+ }
117
+ if ((entry.origin ?? KIT_ORIGIN) !== KIT_ORIGIN) {
118
+ throw new Error(`candidate invariant manifest: invalid origin ${entry.path}`);
119
+ }
120
+ if (![CONSUMER_INSTALL_ROLE, 'maintainer'].includes(
121
+ entry.installRole ?? CONSUMER_INSTALL_ROLE,
122
+ )) {
123
+ throw new Error(`candidate invariant manifest: invalid role ${entry.path}`);
124
+ }
125
+ seen.add(entry.path);
126
+ }
127
+ return filesForInstallRole(pkg);
128
+ }
129
+
130
+ async function candidateLedger(candidateRoot, context) {
131
+ const { pkg, nextReadinessManifest } = context;
132
+ let ledger;
133
+ try {
134
+ ledger = JSON.parse(await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME), 'utf8'));
135
+ } catch {
136
+ throw new Error('candidate invariant schema: Consumer ledger is missing or invalid JSON');
137
+ }
138
+ if (!ledger || ledger.kitVersion !== pkg.kitVersion
139
+ || ledger.installRole !== CONSUMER_INSTALL_ROLE
140
+ || !Array.isArray(ledger.installed)
141
+ || !Number.isInteger(ledger.readinessContractVersion)
142
+ || !isRecord(ledger.readinessDecisions)
143
+ || Object.values(ledger.readinessDecisions).some(
144
+ (value) => !['pending', 'not-applicable'].includes(value),
145
+ )) {
146
+ throw new Error('candidate invariant schema: Consumer ledger identity is invalid');
147
+ }
148
+ const readiness = nextReadinessManifest?.readiness;
149
+ if (readiness && (ledger.readinessContractVersion !== readiness.contractVersion
150
+ || Object.entries(ledger.readinessDecisions).some(([name, value]) => (
151
+ !readiness.capabilities?.[name]
152
+ || (value === 'not-applicable' && !readiness.capabilities[name].allowNotApplicable)
153
+ )))) {
154
+ throw new Error('candidate invariant schema: Consumer ledger readiness references are invalid');
155
+ }
156
+ return ledger;
157
+ }
158
+
159
+ function uniqueEntries(entries, source) {
160
+ const indexed = new Map();
161
+ for (const entry of entries) {
162
+ if (!entry || typeof entry.path !== 'string') {
163
+ throw new Error(`candidate invariant schema: ${source} entry path is required`);
164
+ }
165
+ try {
166
+ validateCandidateManifestPath(entry.path);
167
+ } catch {
168
+ throw new Error(`candidate invariant schema: unsafe ${source} path ${entry.path}`);
169
+ }
170
+ if (indexed.has(entry.path)) {
171
+ throw new Error(`candidate invariant schema: duplicate ${source} path ${entry.path}`);
172
+ }
173
+ indexed.set(entry.path, entry);
174
+ }
175
+ return indexed;
176
+ }
177
+
178
+ function validateInstalledEntry(tracked, desired, origin) {
179
+ if (tracked.origin !== origin
180
+ || (tracked.installRole ?? CONSUMER_INSTALL_ROLE) !== CONSUMER_INSTALL_ROLE
181
+ || !HASH.test(tracked.installedSha256 ?? '')) {
182
+ throw new Error(`candidate invariant ownership: invalid ledger entry ${tracked.path}`);
183
+ }
184
+ for (const key of ['kind', 'ownerSkill', 'surface']) {
185
+ if ((tracked[key] ?? null) !== (desired[key] ?? null)) {
186
+ throw new Error(`candidate invariant ownership: ${key} mismatch ${tracked.path}`);
187
+ }
188
+ }
189
+ if (tracked.origin === KIT_ORIGIN && tracked.installedSha256 !== desired.sha256) {
190
+ throw new Error(`candidate invariant ownership: Kit hash identity mismatch ${tracked.path}`);
191
+ }
192
+ }
193
+
194
+ function verifyLedgerMetadata(ledger, context) {
195
+ const expected = emptyConsumerManifest(
196
+ context.pkg.kitVersion,
197
+ context.priorConsumerManifest,
198
+ context.nextReadinessManifest?.readiness ?? null,
199
+ );
200
+ const { installed: _actualInstalled, ...actualMetadata } = ledger;
201
+ const { installed: _expectedInstalled, ...expectedMetadata } = expected;
202
+ if (!isDeepStrictEqual(actualMetadata, expectedMetadata)) {
203
+ throw new Error(
204
+ 'candidate invariant schema: Consumer ledger metadata differs from trusted prior state',
205
+ );
206
+ }
207
+ }
208
+
209
+ function expectedOrigin(path, priorInstalled, preview) {
210
+ const keptOwned = (preview.collisionResolutions ?? []).some(
211
+ (resolution) => resolution.path === path && resolution.outcome === 'keep-as-owned',
212
+ );
213
+ return keptOwned || (priorInstalled.get(path)?.origin ?? KIT_ORIGIN) === CONSUMER_ORIGIN
214
+ ? CONSUMER_ORIGIN
215
+ : KIT_ORIGIN;
216
+ }
217
+
218
+ function isRecord(value) {
219
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
220
+ }