@ikon85/agent-workflow-kit 0.36.4 → 0.37.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 (43) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +15 -7
  2. package/.agents/skills/setup-workflow/SKILL.md +16 -2
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
  5. package/.agents/skills/wrapup/SKILL.md +20 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
  8. package/.claude/skills/setup-workflow/SKILL.md +16 -2
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +11 -10
  13. package/README.md +59 -1
  14. package/agent-workflow-kit.package.json +26 -26
  15. package/docs/agents/workflow-capabilities.json +1 -0
  16. package/package.json +1 -1
  17. package/scripts/anchor_table.py +14 -8
  18. package/scripts/project-skill-extension.mjs +21 -2
  19. package/scripts/readiness.mjs +32 -4
  20. package/scripts/release-state.mjs +19 -9
  21. package/scripts/release-state.test.mjs +71 -8
  22. package/scripts/test_anchor_table.py +69 -0
  23. package/scripts/test_board_sync_create_idempotency.py +15 -2
  24. package/scripts/test_board_sync_wave_title.py +14 -2
  25. package/scripts/test_census_backstop.py +33 -0
  26. package/scripts/test_orchestrate_wave_contract.py +35 -0
  27. package/scripts/test_retro_wrapup_contract.py +19 -2
  28. package/scripts/test_wrapup_land.py +428 -0
  29. package/scripts/workflow-advisories/core.py +44 -2
  30. package/scripts/worktree-lifecycle/README.md +18 -4
  31. package/scripts/worktree-lifecycle/cleanup.py +173 -6
  32. package/scripts/worktree-lifecycle/core.py +331 -18
  33. package/scripts/worktree-lifecycle/profile.py +2 -0
  34. package/scripts/wrapup-land.py +336 -15
  35. package/src/cli.mjs +60 -27
  36. package/src/lib/manifest.mjs +173 -3
  37. package/src/lib/projectSkillExtension.mjs +78 -1
  38. package/src/lib/updateCandidate.mjs +11 -1
  39. package/src/lib/updateDecisions.mjs +2 -2
  40. package/src/lib/updateReconcile.mjs +6 -0
  41. package/src/lib/verifyUpdateCandidate.mjs +6 -3
  42. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
  43. package/src/lib/verifyUpdateCandidateTransaction.mjs +23 -1
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { basename, join, posix } from 'node:path';
3
3
  import { writeAtomic } from './atomicWrite.mjs';
4
4
 
5
5
  // Two manifests (Codex R1#9 / R3#1):
@@ -20,6 +20,16 @@ export const READINESS_CONTRACT_VERSION = 1;
20
20
  export const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
21
21
  export { PROJECT_SKILL_REGISTRY_PATH } from './skillRegistry.mjs';
22
22
 
23
+ const HASH = /^[a-f0-9]{64}$/;
24
+ const KINDS = new Set(['skill', 'script', 'hook', 'template', 'doc']);
25
+ const SURFACES = new Set(['claude', 'codex']);
26
+ const INSTALL_ROLES = new Set([CONSUMER_INSTALL_ROLE, 'maintainer']);
27
+ const ORIGINS = new Set([KIT_ORIGIN, CONSUMER_ORIGIN]);
28
+ const OWNERSHIP_STATES = new Set([
29
+ 'project-extension', 'contribution-bridge', 'explicit-fork',
30
+ ]);
31
+ const READINESS_DECISIONS = new Set(['pending', 'not-applicable']);
32
+
23
33
  /**
24
34
  * Parse a JSON manifest, or null if the file does not exist. A corrupt file
25
35
  * (invalid JSON — e.g. from an aborted write before writeManifest went atomic)
@@ -35,8 +45,9 @@ export async function readManifest(path) {
35
45
  if (err.code === 'ENOENT') return null;
36
46
  throw err;
37
47
  }
48
+ let manifest;
38
49
  try {
39
- return JSON.parse(raw);
50
+ manifest = JSON.parse(raw);
40
51
  } catch (err) {
41
52
  throw new Error(
42
53
  `${path} is corrupt (invalid JSON) and can't be read. ` +
@@ -44,6 +55,162 @@ export async function readManifest(path) {
44
55
  { cause: err }
45
56
  );
46
57
  }
58
+ const name = basename(path);
59
+ if (name === PACKAGE_MANIFEST_NAME) {
60
+ return validateManifest(manifest, { kind: 'package', path });
61
+ }
62
+ if (name === CONSUMER_MANIFEST_NAME) {
63
+ return validateManifest(manifest, { kind: 'consumer', path });
64
+ }
65
+ return manifest;
66
+ }
67
+
68
+ /**
69
+ * Validate the two persisted lifecycle manifests before their entries are
70
+ * indexed or trusted. Unknown extension keys remain untouched; known fields
71
+ * are deliberately strict. Compatibility is explicit:
72
+ * - package manifests before role-aware installs may omit `installRole`;
73
+ * - consumer ledgers before role-aware installs may omit `installRole`;
74
+ * - readiness fields introduced after the first consumer ledger are optional.
75
+ */
76
+ export function validateManifest(manifest, { kind, path }) {
77
+ if (kind !== 'package' && kind !== 'consumer') {
78
+ throw new Error(`unknown manifest class: ${kind}`);
79
+ }
80
+ const recovery = kind === 'package'
81
+ ? 'Reinstall the Kit or regenerate its package manifest from a trusted checkout.'
82
+ : 'Restore the consumer manifest from its ".bak" backup, or delete it and re-run `init`.';
83
+ const fail = (detail) => {
84
+ throw new Error(`${path} is an invalid ${kind} manifest: ${detail} ${recovery}`);
85
+ };
86
+ if (!plainObject(manifest)) fail('the root must be a JSON object.');
87
+ if (typeof manifest.kitVersion !== 'string' || manifest.kitVersion.length === 0) {
88
+ fail('kitVersion must be a non-empty string.');
89
+ }
90
+ const key = kind === 'package' ? 'files' : 'installed';
91
+ if (!Array.isArray(manifest[key])) fail(`${key} must be an array.`);
92
+ if (kind === 'consumer') validateConsumerRoot(manifest, fail);
93
+
94
+ const seen = new Set();
95
+ for (const [offset, entry] of manifest[key].entries()) {
96
+ const ordinal = `entry #${offset + 1}`;
97
+ if (!plainObject(entry)) fail(`${ordinal} must be a JSON object.`);
98
+ if (!safeManifestPath(entry.path)) {
99
+ fail(`${ordinal} has an unsafe consumer path (${display(entry.path)}); use a relative POSIX path without "." or ".." segments.`);
100
+ }
101
+ const label = `${ordinal} (${entry.path})`;
102
+ if (seen.has(entry.path)) fail(`${label} duplicates path ${entry.path}.`);
103
+ seen.add(entry.path);
104
+ validateEnum(entry, 'kind', KINDS, label, fail, true);
105
+ validateOptionalString(entry, 'ownerSkill', label, fail);
106
+ validateEnum(entry, 'surface', SURFACES, label, fail);
107
+ validateEnum(entry, 'installRole', INSTALL_ROLES, label, fail);
108
+ validateEnum(entry, 'origin', ORIGINS, label, fail, true);
109
+ if (kind === 'package') {
110
+ validateHash(entry.sha256, `${label} sha256`, fail);
111
+ if (!Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777) {
112
+ fail(`${label} mode must be an integer from 0 through 511.`);
113
+ }
114
+ if (entry.origin !== KIT_ORIGIN) fail(`${label} origin must be "kit".`);
115
+ } else {
116
+ validateHash(entry.installedSha256, `${label} installedSha256`, fail);
117
+ if ('sha256' in entry) validateHash(entry.sha256, `${label} sha256`, fail);
118
+ if ('mode' in entry
119
+ && (!Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777)) {
120
+ fail(`${label} mode must be an integer from 0 through 511.`);
121
+ }
122
+ validateConsumerEntry(entry, label, fail);
123
+ }
124
+ }
125
+ return manifest;
126
+ }
127
+
128
+ function validateConsumerRoot(manifest, fail) {
129
+ if ('installRole' in manifest && manifest.installRole !== CONSUMER_INSTALL_ROLE) {
130
+ fail('installRole must be "consumer".');
131
+ }
132
+ if ('readinessContractVersion' in manifest
133
+ && (!Number.isInteger(manifest.readinessContractVersion)
134
+ || manifest.readinessContractVersion < 1)) {
135
+ fail('readinessContractVersion must be a positive integer.');
136
+ }
137
+ if ('readinessDecisions' in manifest) {
138
+ if (!plainObject(manifest.readinessDecisions)) {
139
+ fail('readinessDecisions must be a JSON object.');
140
+ }
141
+ for (const [capability, decision] of Object.entries(manifest.readinessDecisions)) {
142
+ if (!capability || !READINESS_DECISIONS.has(decision)) {
143
+ fail(`readinessDecisions.${capability || '<empty>'} has unsupported value ${display(decision)}.`);
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ function validateConsumerEntry(entry, label, fail) {
150
+ if ('orphanedByUninstall' in entry && typeof entry.orphanedByUninstall !== 'boolean') {
151
+ fail(`${label} orphanedByUninstall must be a boolean.`);
152
+ }
153
+ validateEnum(entry, 'ownershipState', OWNERSHIP_STATES, label, fail);
154
+ if ('ownershipState' in entry && entry.origin !== CONSUMER_ORIGIN) {
155
+ fail(`${label} ownershipState requires origin "consumer".`);
156
+ }
157
+ if (!('contributionBridge' in entry)) return;
158
+ const bridge = entry.contributionBridge;
159
+ if (!plainObject(bridge)
160
+ || bridge.schemaVersion !== 1
161
+ || typeof bridge.baseKitVersion !== 'string'
162
+ || bridge.baseKitVersion.length === 0) {
163
+ fail(`${label} contributionBridge must be a schemaVersion 1 object with baseKitVersion.`);
164
+ }
165
+ validateHash(bridge.baseSha256, `${label} contributionBridge.baseSha256`, fail);
166
+ validateHash(bridge.localSha256, `${label} contributionBridge.localSha256`, fail);
167
+ if (entry.origin !== CONSUMER_ORIGIN
168
+ || entry.ownershipState !== 'contribution-bridge'
169
+ || entry.installedSha256 !== bridge.localSha256) {
170
+ fail(`${label} contributionBridge must match consumer ownership and installedSha256.`);
171
+ }
172
+ }
173
+
174
+ function validateEnum(entry, field, values, label, fail, required = false) {
175
+ if (!(field in entry)) {
176
+ if (required) fail(`${label} ${field} is required.`);
177
+ return;
178
+ }
179
+ if (typeof entry[field] !== 'string' || !values.has(entry[field])) {
180
+ fail(`${label} ${field} has unsupported value ${display(entry[field])}.`);
181
+ }
182
+ }
183
+
184
+ function validateOptionalString(entry, field, label, fail) {
185
+ if (field in entry && (typeof entry[field] !== 'string' || entry[field].length === 0)) {
186
+ fail(`${label} ${field} must be a non-empty string when present.`);
187
+ }
188
+ }
189
+
190
+ function validateHash(value, field, fail) {
191
+ if (typeof value !== 'string' || !HASH.test(value)) {
192
+ fail(`${field} must be a lowercase 64-hex SHA-256.`);
193
+ }
194
+ }
195
+
196
+ function safeManifestPath(path) {
197
+ return typeof path === 'string'
198
+ && path.length > 0
199
+ && path !== '.'
200
+ && !path.includes('\\')
201
+ && !path.includes('//')
202
+ && !path.split('/').some((part) => part === '' || part === '.' || part === '..')
203
+ && !posix.isAbsolute(path)
204
+ && !/^[a-zA-Z]:/.test(path)
205
+ && posix.normalize(path) === path;
206
+ }
207
+
208
+ function plainObject(value) {
209
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
210
+ }
211
+
212
+ function display(value) {
213
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
47
214
  }
48
215
 
49
216
  /**
@@ -90,7 +257,10 @@ export function filesForInstallRole(manifest, installRole = CONSUMER_INSTALL_ROL
90
257
  /** Map a manifest's file list (under `key`) by `path` for quick lookup. */
91
258
  export function indexByPath(manifest, key) {
92
259
  const idx = new Map();
93
- for (const entry of (manifest?.[key] ?? [])) idx.set(entry.path, entry);
260
+ for (const entry of (manifest?.[key] ?? [])) {
261
+ if (idx.has(entry.path)) throw new Error(`duplicate manifest path: ${entry.path}`);
262
+ idx.set(entry.path, entry);
263
+ }
94
264
  return idx;
95
265
  }
96
266
 
@@ -6,6 +6,70 @@ const SKILL_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6
6
  const MARKER_PREFIX = '<!-- agent-workflow-kit: project-extension/';
7
7
  const MARKER = /^<!-- agent-workflow-kit: project-extension\/([^;]+); skill=([a-z0-9-]+) -->$/;
8
8
 
9
+ function meaningfulSectionBody(lines) {
10
+ const body = lines.join('\n').replace(/<!--[\s\S]*?-->/g, '');
11
+ const content = [];
12
+ let fence = null;
13
+ for (const rawLine of body.split('\n')) {
14
+ const line = rawLine.trim();
15
+ const delimiter = /^(`{3,}|~{3,})/.exec(line)?.[1];
16
+ if (delimiter && !fence) {
17
+ fence = { character: delimiter[0], length: delimiter.length };
18
+ continue;
19
+ }
20
+ if (fence && new RegExp(`^\\${fence.character}{${fence.length},}\\s*$`).test(line)) {
21
+ fence = null;
22
+ continue;
23
+ }
24
+ if (line && !/^#{1,6}\s+/.test(line)) content.push(line);
25
+ }
26
+ const meaningful = content
27
+ .filter((line) =>
28
+ !/^(?:(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?)?(?:TODO|TBD)(?:[.!:]*)$/i.test(line)
29
+ && !/^<\/?[^>\n]+>$/.test(line)
30
+ && !/^<(?:placeholder|fill|configure|add)(?:\s+[^>]*)?>[.!:]*$/i.test(line)
31
+ && !/^(?:Run|Use|Configure|Add|Replace)\s+`?<[^>]+>`?[.!:]*$/i.test(line))
32
+ .join('\n');
33
+ return meaningful.trim();
34
+ }
35
+
36
+ function sectionBodies(body, path) {
37
+ const sections = new Map();
38
+ let current = null;
39
+ for (const line of body.split('\n')) {
40
+ const heading = /^##\s+(.+?)\s*$/.exec(line);
41
+ if (heading) {
42
+ if (sections.has(heading[1])) {
43
+ throw new Error(`Project extension has duplicate section ${heading[1]} at ${path}`);
44
+ }
45
+ current = [];
46
+ sections.set(heading[1], current);
47
+ } else if (current) {
48
+ current.push(line);
49
+ }
50
+ }
51
+ return sections;
52
+ }
53
+
54
+ export function validateProjectSkillActivation(activation, path) {
55
+ if (activation === undefined) return null;
56
+ if (!activation || typeof activation !== 'object' || Array.isArray(activation)
57
+ || activation.mode !== 'all-sections-filled'
58
+ || !Array.isArray(activation.sections) || !activation.sections.length
59
+ || activation.sections.some((section) =>
60
+ typeof section !== 'string' || !section.trim())
61
+ || new Set(activation.sections).size !== activation.sections.length) {
62
+ throw new Error(`Project extension activation policy is invalid at ${path}`);
63
+ }
64
+ return activation.sections;
65
+ }
66
+
67
+ function unfilledSections(body, requiredSections, path) {
68
+ const sections = sectionBodies(body, path);
69
+ return requiredSections.filter((name) =>
70
+ !sections.has(name) || !meaningfulSectionBody(sections.get(name)));
71
+ }
72
+
9
73
  export function projectSkillExtensionPath(skill) {
10
74
  if (typeof skill !== 'string' || !SKILL_NAME.test(skill)) {
11
75
  throw new Error(`invalid Project extension skill identity: ${skill}`);
@@ -13,8 +77,9 @@ export function projectSkillExtensionPath(skill) {
13
77
  return `docs/agents/skills/${skill}.md`;
14
78
  }
15
79
 
16
- export async function inspectProjectSkillExtension({ root, skill }) {
80
+ export async function inspectProjectSkillExtension({ root, skill, activation }) {
17
81
  const path = projectSkillExtensionPath(skill);
82
+ const requiredSections = validateProjectSkillActivation(activation, path);
18
83
  const absolute = join(root, path);
19
84
  let state;
20
85
  try {
@@ -50,6 +115,18 @@ export async function inspectProjectSkillExtension({ root, skill }) {
50
115
  `found skill=${match[2]}`,
51
116
  );
52
117
  }
118
+ if (requiredSections) {
119
+ const missingSections = unfilledSections(body, requiredSections, path);
120
+ if (missingSections.length) {
121
+ return {
122
+ state: 'inactive',
123
+ reason: 'sections-unfilled',
124
+ schemaVersion: 1,
125
+ path,
126
+ missingSections,
127
+ };
128
+ }
129
+ }
53
130
  const content = lines.filter(
54
131
  (line) => line && line !== markerLine && !line.startsWith('<!-- setup-workflow:'),
55
132
  );
@@ -13,7 +13,7 @@ import { stubSentinel } from './sentinel.mjs';
13
13
  import { STUB_TARGETS } from './bundle.mjs';
14
14
  import {
15
15
  CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
16
- filesForInstallRole, indexByPath, readManifest, writeManifest,
16
+ filesForInstallRole, indexByPath, readManifest, validateManifest, writeManifest,
17
17
  } from './manifest.mjs';
18
18
  import {
19
19
  PROJECT_SKILL_REGISTRY_PATH, emptyProjectSkillRegistry, migrateLegacySkillRegistry,
@@ -38,6 +38,7 @@ export async function materializeUpdateCandidate({
38
38
  consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
39
39
  afterInputValidation = async () => {},
40
40
  }) {
41
+ validateManifest(pkg, { kind: 'package', path: 'update package manifest' });
41
42
  const candidateRoot = await mkdtemp(join(tmpdir(), 'agent-workflow-kit-stage-'));
42
43
  try {
43
44
  const paths = candidateInputPaths({
@@ -208,6 +209,7 @@ export async function activateCandidate({
208
209
  afterSnapshot = async () => {}, afterGenerated = async () => {},
209
210
  beforeTargetRevalidation = async () => {},
210
211
  }) {
212
+ validateManifest(pkg, { kind: 'package', path: 'update package manifest' });
211
213
  const changed = [...preview.added, ...preview.updated];
212
214
  const generated = preview.generated ?? [];
213
215
  const migrations = preview.migrations ?? [];
@@ -351,6 +353,14 @@ async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
351
353
  throw new Error(`consumer changed during verification: ${path}`);
352
354
  }
353
355
  }
356
+ for (const snapshot of preview.userModifiedSnapshots ?? []) {
357
+ const target = join(consumerRoot, snapshot.path);
358
+ const current = await exists(target) ? await sha256File(target) : null;
359
+ const mode = current === null ? null : (await lstat(target)).mode & 0o777;
360
+ if (current !== snapshot.sha256 || mode !== snapshot.mode) {
361
+ throw new Error(`consumer changed during verification: ${snapshot.path}`);
362
+ }
363
+ }
354
364
  }
355
365
 
356
366
  /** Seed only newly declared, decision-free project-layer stubs in a staged candidate. */
@@ -1,5 +1,5 @@
1
- export function nonInteractiveUpdateDecision(action) {
2
- if (action === 'delete') return true;
1
+ export function nonInteractiveUpdateDecision(action, choices = {}) {
2
+ if (action === 'delete') return choices.deleted !== 'restore';
3
3
  if (action === 'collision') return undefined;
4
4
  throw new Error(`unknown update decision action: ${action}`);
5
5
  }
@@ -156,6 +156,11 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
156
156
  } else if (userEdited) {
157
157
  nextInstalled.push(withInstallRole(prior));
158
158
  result.userModified.push(file.path);
159
+ result.userModifiedSnapshots.push({
160
+ path: file.path,
161
+ sha256: current,
162
+ mode: currentMode,
163
+ });
159
164
  } else {
160
165
  nextInstalled.push(withInstallRole(prior));
161
166
  result.unchanged.push(file.path);
@@ -202,6 +207,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
202
207
  function emptyResult() {
203
208
  return {
204
209
  unchanged: [], updated: [], conflicts: [], collisions: [], collisionResolutions: [], userModified: [],
210
+ userModifiedSnapshots: [],
205
211
  ownershipStates: [],
206
212
  bridgeRetired: [],
207
213
  added: [], deleted: [], keptDeleted: [], consumerOwned: [], manifestChanged: false,
@@ -61,11 +61,14 @@ export async function verifyCandidateMetadata(candidateRoot, context) {
61
61
  if (!state.isFile()) {
62
62
  throw new Error(`candidate invariant artifact: not a regular file ${entry.path}`);
63
63
  }
64
- if (tracked.origin === KIT_ORIGIN && (state.mode & 0o777) !== entry.mode) {
64
+ const localSnapshot = (context.preview.userModifiedSnapshots ?? [])
65
+ .find(({ path }) => path === entry.path);
66
+ const expectedMode = localSnapshot?.mode ?? entry.mode;
67
+ if (tracked.origin === KIT_ORIGIN && (state.mode & 0o777) !== expectedMode) {
65
68
  throw new Error(`candidate invariant artifact: mode mismatch ${entry.path}`);
66
69
  }
67
- const expectedHash = tracked.origin === CONSUMER_ORIGIN
68
- ? tracked.installedSha256 : entry.sha256;
70
+ const expectedHash = localSnapshot?.sha256 ?? (tracked.origin === CONSUMER_ORIGIN
71
+ ? tracked.installedSha256 : entry.sha256);
69
72
  if (await sha256File(join(candidateRoot, entry.path)) !== expectedHash) {
70
73
  throw new Error(`candidate invariant artifact: hash mismatch ${entry.path}`);
71
74
  }
@@ -2,6 +2,10 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, filesForInstallRole } from './manifest.mjs';
4
4
  import { validateCandidateManifestPath } from './updateCandidate.mjs';
5
+ import {
6
+ projectSkillExtensionPath,
7
+ validateProjectSkillActivation,
8
+ } from './projectSkillExtension.mjs';
5
9
  import { readComposedSkillRegistry } from './skillRegistry.mjs';
6
10
 
7
11
  const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
@@ -52,6 +56,17 @@ function validateReadinessManifest(manifest) {
52
56
  throw new Error(`candidate invariant schema: unsafe capability path ${name}`);
53
57
  }
54
58
  }
59
+ if (evidence.type === 'project-extension') {
60
+ try {
61
+ const expected = projectSkillExtensionPath(evidence.skill);
62
+ if (evidence.paths.length !== 1 || evidence.paths[0] !== expected) {
63
+ throw new Error('path mismatch');
64
+ }
65
+ validateProjectSkillActivation(evidence.activation, expected);
66
+ } catch {
67
+ throw new Error(`candidate invariant schema: invalid capability evidence ${name}`);
68
+ }
69
+ }
55
70
  }
56
71
  for (const [name, skill] of Object.entries(manifest.skills)) {
57
72
  const readiness = skill?.readiness ?? {};
@@ -44,7 +44,7 @@ export function verifyTransactionPreview(preview, installable, installed) {
44
44
  const installablePaths = new Set(installable.map(({ path }) => path));
45
45
  const owners = new Map();
46
46
  const actionSets = new Map();
47
- for (const key of ['added', 'updated', 'deleted', 'generated', 'keptDeleted']) {
47
+ for (const key of ['added', 'updated', 'deleted', 'generated', 'keptDeleted', 'userModified']) {
48
48
  if (!Array.isArray(preview[key] ?? [])) {
49
49
  throw new Error(`candidate invariant transaction: ${key} must be an array`);
50
50
  }
@@ -54,12 +54,34 @@ export function verifyTransactionPreview(preview, installable, installed) {
54
54
  if (['added', 'updated'].includes(key) && !installablePaths.has(path)) {
55
55
  throw new Error(`candidate invariant transaction: unmanaged ${key} path ${path}`);
56
56
  }
57
+ if (key === 'userModified' && !installablePaths.has(path)) {
58
+ throw new Error(`candidate invariant transaction: unmanaged userModified path ${path}`);
59
+ }
57
60
  if (key === 'deleted' && installablePaths.has(path)) {
58
61
  throw new Error(`candidate invariant transaction: deletes current package path ${path}`);
59
62
  }
60
63
  }
61
64
  actionSets.set(key, local);
62
65
  }
66
+ if (!Array.isArray(preview.userModifiedSnapshots ?? [])) {
67
+ throw new Error('candidate invariant transaction: userModifiedSnapshots must be an array');
68
+ }
69
+ const modified = actionSets.get('userModified');
70
+ const snapshots = new Set();
71
+ for (const snapshot of preview.userModifiedSnapshots ?? []) {
72
+ const path = snapshot?.path;
73
+ claimTransactionPath(path, 'userModifiedSnapshots', snapshots);
74
+ if (!modified.has(path)
75
+ || !HASH.test(snapshot.sha256 ?? '')
76
+ || !Number.isInteger(snapshot.mode)
77
+ || snapshot.mode < 0
78
+ || snapshot.mode > 0o777) {
79
+ throw new Error(`candidate invariant transaction: invalid local snapshot ${path}`);
80
+ }
81
+ }
82
+ if (snapshots.size !== modified.size) {
83
+ throw new Error('candidate invariant transaction: missing local snapshot');
84
+ }
63
85
  if (!Array.isArray(preview.bridgeRetired ?? [])) {
64
86
  throw new Error('candidate invariant transaction: bridgeRetired must be an array');
65
87
  }