yam-flow 2.7.0 → 2.8.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 (40) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +29 -0
  3. package/COMMANDS.md +42 -5
  4. package/README.md +40 -8
  5. package/ROADMAP.md +17 -6
  6. package/dist/bin/yam.js +441 -70
  7. package/dist/lib/external-updates.d.ts +9 -0
  8. package/dist/lib/external-updates.js +232 -4
  9. package/dist/lib/next-step.d.ts +61 -0
  10. package/dist/lib/next-step.js +340 -0
  11. package/dist/lib/release-auth-readiness.d.ts +81 -0
  12. package/dist/lib/release-auth-readiness.js +106 -0
  13. package/dist/lib/release-registry-status.d.ts +18 -0
  14. package/dist/lib/release-registry-status.js +37 -0
  15. package/dist/lib/scout-receipt.d.ts +89 -0
  16. package/dist/lib/scout-receipt.js +542 -0
  17. package/dist/lib/skill-installation.d.ts +30 -0
  18. package/dist/lib/skill-installation.js +470 -82
  19. package/dist/lib/trust-kernel.d.ts +2 -0
  20. package/dist/lib/trust-kernel.js +21 -2
  21. package/package.json +22 -3
  22. package/references/final-report.md +15 -1
  23. package/references/next-step.md +67 -0
  24. package/references/scout-wide-scan.md +16 -0
  25. package/references/study-note.md +4 -2
  26. package/references/tool-trust-layer.md +2 -0
  27. package/skills/deep/SKILL.md +2 -0
  28. package/skills/mission/SKILL.md +2 -0
  29. package/skills/quick/SKILL.md +1 -0
  30. package/skills/scout/SKILL.md +19 -9
  31. package/skills/ueye/SKILL.md +1 -0
  32. package/src/bin/yam.ts +445 -68
  33. package/src/lib/external-updates.ts +262 -4
  34. package/src/lib/next-step.ts +397 -0
  35. package/src/lib/release-auth-readiness.ts +148 -0
  36. package/src/lib/release-registry-status.ts +55 -0
  37. package/src/lib/scout-receipt.ts +593 -0
  38. package/src/lib/skill-installation.ts +542 -87
  39. package/src/lib/trust-kernel.ts +25 -2
  40. package/yam.manifest.json +4 -3
@@ -7,6 +7,7 @@ export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
7
7
  export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
8
8
  export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
9
9
  export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
10
+ const TRANSACTION_OWNER_MARKER = '.yam-flow-transaction-owner';
10
11
  function message(error) {
11
12
  return error instanceof Error ? error.message : String(error);
12
13
  }
@@ -106,31 +107,60 @@ function readOnlyNoFollowFlags() {
106
107
  : 0;
107
108
  return fsConstants.O_RDONLY | noFollow;
108
109
  }
110
+ async function confirmWindowsPathForHandle(target, opened, before, label) {
111
+ if (before.birthtimeNs <= 0n) {
112
+ throw new Error(`${label} requires a positive birthtime on Windows: ${target}`);
113
+ }
114
+ let pathHandle;
115
+ try {
116
+ pathHandle = await fsp.open(target, fsConstants.O_RDONLY);
117
+ const pathOpened = await pathHandle.stat({ bigint: true });
118
+ const confirmed = await fsp.lstat(target, { bigint: true });
119
+ if (!opened.isFile()
120
+ || !pathOpened.isFile()
121
+ || pathOpened.dev !== opened.dev
122
+ || pathOpened.ino !== opened.ino
123
+ || confirmed.isSymbolicLink()
124
+ || !confirmed.isFile()
125
+ || confirmed.dev !== before.dev
126
+ || confirmed.ino !== before.ino
127
+ || confirmed.birthtimeNs !== before.birthtimeNs) {
128
+ throw new Error(`${label} changed identity while confirming Windows path ownership: ${target}`);
129
+ }
130
+ return confirmed;
131
+ }
132
+ finally {
133
+ if (pathHandle)
134
+ await pathHandle.close();
135
+ }
136
+ }
109
137
  async function captureRegularFile(file, boundary = path.dirname(file)) {
110
138
  const parents = await captureRegularParentPath(boundary, file, 'regular file read');
111
- const before = await fsp.lstat(file);
139
+ const before = await fsp.lstat(file, { bigint: true });
112
140
  if (!before.isFile() || before.isSymbolicLink())
113
141
  throw new Error(`expected a regular file: ${file}`);
114
142
  const handle = await fsp.open(file, readOnlyNoFollowFlags());
115
143
  try {
116
- const opened = await handle.stat();
144
+ const opened = await handle.stat({ bigint: true });
117
145
  if (!opened.isFile())
118
146
  throw new Error(`expected a regular file: ${file}`);
119
147
  const bytes = await handle.readFile();
120
- const afterOpened = await handle.stat();
121
- const after = await fsp.lstat(file);
148
+ const afterOpened = await handle.stat({ bigint: true });
149
+ const after = process.platform === 'win32'
150
+ ? await confirmWindowsPathForHandle(file, opened, before, 'regular file')
151
+ : await fsp.lstat(file, { bigint: true });
122
152
  await revalidateRegularParentPath(parents, 'regular file read');
123
153
  if (!after.isFile()
124
154
  || after.isSymbolicLink()
125
- || before.dev !== opened.dev
126
- || before.ino !== opened.ino
127
155
  || afterOpened.dev !== opened.dev
128
156
  || afterOpened.ino !== opened.ino
129
- || after.dev !== opened.dev
130
- || after.ino !== opened.ino) {
157
+ || (process.platform !== 'win32' && (before.dev !== opened.dev
158
+ || before.ino !== opened.ino
159
+ || after.dev !== opened.dev
160
+ || after.ino !== opened.ino))) {
131
161
  throw new Error(`regular file changed identity while being captured: ${file}`);
132
162
  }
133
- return { bytes, mode: opened.mode & 0o777 };
163
+ return { bytes, mode: Number(opened.mode & 511n) };
134
164
  }
135
165
  finally {
136
166
  await handle.close();
@@ -148,20 +178,23 @@ async function copyRegularFile(source, target, sourceBoundary, targetBoundary) {
148
178
  const handle = await fsp.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, sourceFile.mode);
149
179
  try {
150
180
  await handle.writeFile(sourceFile.bytes);
151
- const opened = await handle.stat();
181
+ const opened = await handle.stat({ bigint: true });
152
182
  if (!opened.isFile())
153
183
  throw new Error(`install copy target must be a regular file: ${target}`);
154
184
  const openedIdentity = { dev: opened.dev, ino: opened.ino };
155
- const afterOpened = await handle.stat();
185
+ const afterOpened = await handle.stat({ bigint: true });
156
186
  if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
157
187
  throw new Error(`install copy target changed descriptor identity: ${target}`);
158
188
  }
159
189
  await revalidateRegularParentPath(targetParents, 'install copy target');
160
- const installed = await fsp.lstat(target);
190
+ const beforeInstalled = await fsp.lstat(target, { bigint: true });
191
+ const installed = process.platform === 'win32'
192
+ ? await confirmWindowsPathForHandle(target, opened, beforeInstalled, 'install copy target')
193
+ : beforeInstalled;
161
194
  if (!installed.isFile()
162
195
  || installed.isSymbolicLink()
163
- || installed.dev !== openedIdentity.dev
164
- || installed.ino !== openedIdentity.ino) {
196
+ || (process.platform !== 'win32' && (installed.dev !== openedIdentity.dev
197
+ || installed.ino !== openedIdentity.ino))) {
165
198
  throw new Error(`install copy target changed path identity: ${target}`);
166
199
  }
167
200
  }
@@ -232,8 +265,8 @@ async function expectedInstallManifest(sourceRoot, skills) {
232
265
  const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
233
266
  const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
234
267
  const skillText = skillBytes.toString('utf8');
235
- const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
236
- const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
268
+ const frontmatter = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
269
+ const name = frontmatter?.[1].match(/^name:\s*([^\r\n]+)\s*$/m)?.[1]
237
270
  ?.trim()
238
271
  .replace(/^["']|["']$/g, '');
239
272
  if (name !== skill) {
@@ -279,7 +312,7 @@ async function copyTree(source, target, sourceBoundary = source, targetBoundary
279
312
  await revalidateRegularParentPath(sourceIdentity, 'install source tree');
280
313
  await revalidateRegularParentPath(targetIdentity, 'install target tree');
281
314
  }
282
- async function moveRegularDirectory(source, target, sourceBoundary, targetBoundary, label, onMoved) {
315
+ async function moveRegularDirectory(source, target, sourceBoundary, targetBoundary, label, onMoved, identityOwner) {
283
316
  const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
284
317
  const sourceStat = await fsp.lstat(source);
285
318
  if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
@@ -288,19 +321,35 @@ async function moveRegularDirectory(source, target, sourceBoundary, targetBounda
288
321
  const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
289
322
  if (await exists(target))
290
323
  throw new Error(`${label} target unexpectedly exists: ${target}`);
291
- await fsp.rename(source, target);
292
- onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'directory' });
293
- await revalidateRegularParentPath(sourceParents, `${label} source`);
294
- await revalidateRegularParentPath(targetParents, `${label} target`);
295
- const targetStat = await fsp.lstat(target);
296
- if (!targetStat.isDirectory()
297
- || targetStat.isSymbolicLink()
298
- || targetStat.dev !== sourceStat.dev
299
- || targetStat.ino !== sourceStat.ino) {
300
- throw new Error(`${label} directory changed identity while being moved: ${target}`);
324
+ const recorded = identityOwner ? await captureRecordedEntry(source, 'directory', label) : undefined;
325
+ let identityTransferred = false;
326
+ try {
327
+ await fsp.rename(source, target);
328
+ if (!recorded)
329
+ onMoved?.();
330
+ if (recorded) {
331
+ recorded.path = target;
332
+ identityOwner?.push(recorded);
333
+ identityTransferred = true;
334
+ }
335
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
336
+ await revalidateRegularParentPath(targetParents, `${label} target`);
337
+ const targetStat = await fsp.lstat(target);
338
+ if (!targetStat.isDirectory()
339
+ || targetStat.isSymbolicLink()
340
+ || targetStat.dev !== sourceStat.dev
341
+ || targetStat.ino !== sourceStat.ino) {
342
+ throw new Error(`${label} directory changed identity while being moved: ${target}`);
343
+ }
344
+ return recorded;
345
+ }
346
+ catch (error) {
347
+ if (recorded && !identityTransferred)
348
+ await closeRecordedEntry(recorded);
349
+ throw error;
301
350
  }
302
351
  }
303
- async function moveRegularFile(source, target, sourceBoundary, targetBoundary, label, onMoved) {
352
+ async function moveRegularFile(source, target, sourceBoundary, targetBoundary, label, onMoved, identityOwner) {
304
353
  const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
305
354
  const sourceStat = await fsp.lstat(source);
306
355
  if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
@@ -309,16 +358,32 @@ async function moveRegularFile(source, target, sourceBoundary, targetBoundary, l
309
358
  const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
310
359
  if (await exists(target))
311
360
  throw new Error(`${label} target unexpectedly exists: ${target}`);
312
- await fsp.rename(source, target);
313
- onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'file' });
314
- await revalidateRegularParentPath(sourceParents, `${label} source`);
315
- await revalidateRegularParentPath(targetParents, `${label} target`);
316
- const targetStat = await fsp.lstat(target);
317
- if (!targetStat.isFile()
318
- || targetStat.isSymbolicLink()
319
- || targetStat.dev !== sourceStat.dev
320
- || targetStat.ino !== sourceStat.ino) {
321
- throw new Error(`${label} file changed identity while being moved: ${target}`);
361
+ const recorded = identityOwner ? await captureRecordedEntry(source, 'file', label) : undefined;
362
+ let identityTransferred = false;
363
+ try {
364
+ await fsp.rename(source, target);
365
+ if (!recorded)
366
+ onMoved?.();
367
+ if (recorded) {
368
+ recorded.path = target;
369
+ identityOwner?.push(recorded);
370
+ identityTransferred = true;
371
+ }
372
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
373
+ await revalidateRegularParentPath(targetParents, `${label} target`);
374
+ const targetStat = await fsp.lstat(target);
375
+ if (!targetStat.isFile()
376
+ || targetStat.isSymbolicLink()
377
+ || targetStat.dev !== sourceStat.dev
378
+ || targetStat.ino !== sourceStat.ino) {
379
+ throw new Error(`${label} file changed identity while being moved: ${target}`);
380
+ }
381
+ return recorded;
382
+ }
383
+ catch (error) {
384
+ if (recorded && !identityTransferred)
385
+ await closeRecordedEntry(recorded);
386
+ throw error;
322
387
  }
323
388
  }
324
389
  function manifestDigest(files) {
@@ -776,28 +841,26 @@ async function acquireInstallLock(destination) {
776
841
  }
777
842
  let lockIdentity;
778
843
  try {
779
- const opened = await handle.stat();
844
+ const lockContents = JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2);
845
+ await handle.writeFile(lockContents);
846
+ const opened = await handle.stat({ bigint: true });
780
847
  if (!opened.isFile())
781
848
  throw new Error(`install lock must be a regular file: ${lockPath}`);
782
- lockIdentity = { path: lockPath, dev: opened.dev, ino: opened.ino, kind: 'file' };
783
- await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
849
+ lockIdentity = await captureLockIdentity(lockPath, opened);
850
+ lockIdentity.contentsSha256 = createHash('sha256').update(lockContents).digest('hex');
784
851
  await revalidateRegularParentPath(destinationIdentity, 'skill destination');
785
- const current = await fsp.lstat(lockPath);
852
+ const current = await fsp.lstat(lockPath, { bigint: true });
786
853
  if (current.isSymbolicLink()
787
854
  || !current.isFile()
788
855
  || current.dev !== lockIdentity.dev
789
- || current.ino !== lockIdentity.ino) {
856
+ || current.ino !== lockIdentity.ino
857
+ || (lockIdentity.birthtimeNs !== undefined && current.birthtimeNs !== lockIdentity.birthtimeNs)) {
790
858
  throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
791
859
  }
792
860
  }
793
861
  catch (error) {
794
862
  const cleanupErrors = [];
795
- try {
796
- await handle.close();
797
- }
798
- catch (closeError) {
799
- cleanupErrors.push(message(closeError));
800
- }
863
+ cleanupErrors.push(...await closeFileHandle(handle, lockPath));
801
864
  try {
802
865
  await revalidateRegularParentPath(destinationIdentity, 'skill destination');
803
866
  if (!lockIdentity)
@@ -807,6 +870,12 @@ async function acquireInstallLock(destination) {
807
870
  catch (cleanupError) {
808
871
  cleanupErrors.push(message(cleanupError));
809
872
  }
873
+ finally {
874
+ const closeErrors = lockIdentity
875
+ ? await closeRecordedEntries([lockIdentity])
876
+ : [];
877
+ cleanupErrors.push(...closeErrors);
878
+ }
810
879
  if (cleanupErrors.length) {
811
880
  throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
812
881
  }
@@ -858,32 +927,340 @@ async function removeInstalledEntries(entries) {
858
927
  }
859
928
  return errors;
860
929
  }
861
- async function captureRecordedEntry(target, kind, label) {
862
- const stat = await fsp.lstat(target);
930
+ async function captureRecordedEntry(target, kind, label, ownershipMarker) {
931
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
932
+ ? fsConstants.O_NOFOLLOW
933
+ : 0;
934
+ const directoryOnly = kind === 'directory'
935
+ && process.platform !== 'win32'
936
+ && typeof fsConstants.O_DIRECTORY === 'number'
937
+ ? fsConstants.O_DIRECTORY
938
+ : 0;
939
+ let handle;
940
+ try {
941
+ if (process.platform !== 'win32') {
942
+ handle = await fsp.open(target, fsConstants.O_RDONLY | noFollow | directoryOnly);
943
+ }
944
+ if (handle)
945
+ return await captureRecordedEntryFromHandle(target, kind, label, handle);
946
+ const stat = await fsp.lstat(target, { bigint: true });
947
+ const typeMatches = kind === 'directory'
948
+ ? stat.isDirectory() && !stat.isSymbolicLink()
949
+ : stat.isFile() && !stat.isSymbolicLink();
950
+ if (!typeMatches || stat.birthtimeNs <= 0n) {
951
+ throw new Error(`${label} must be one stable regular ${kind} with a positive birthtime: ${target}`);
952
+ }
953
+ const entry = {
954
+ path: target,
955
+ dev: stat.dev,
956
+ ino: stat.ino,
957
+ kind,
958
+ birthtimeNs: stat.birthtimeNs,
959
+ ownershipMarker
960
+ };
961
+ if (kind === 'file') {
962
+ const bytes = await readRegularFileBytes(target, path.dirname(target));
963
+ entry.contentsSha256 = createHash('sha256').update(bytes).digest('hex');
964
+ }
965
+ else if (!ownershipMarker) {
966
+ entry.treeDigest = manifestDigest(await collectTreeManifest(target));
967
+ }
968
+ return entry;
969
+ }
970
+ catch (error) {
971
+ if (handle)
972
+ await handle.close().catch(() => { });
973
+ throw error;
974
+ }
975
+ }
976
+ async function captureRecordedEntryFromHandle(target, kind, label, handle) {
977
+ const opened = await handle.stat({ bigint: true });
978
+ const current = await fsp.lstat(target, { bigint: true });
863
979
  const typeMatches = kind === 'directory'
864
- ? stat.isDirectory() && !stat.isSymbolicLink()
865
- : stat.isFile() && !stat.isSymbolicLink();
866
- if (!typeMatches)
867
- throw new Error(`${label} must be a regular ${kind}: ${target}`);
868
- return { path: target, dev: stat.dev, ino: stat.ino, kind };
980
+ ? opened.isDirectory() && current.isDirectory() && !current.isSymbolicLink()
981
+ : opened.isFile() && current.isFile() && !current.isSymbolicLink();
982
+ const birthtimeAvailable = opened.birthtimeNs > 0n && current.birthtimeNs > 0n;
983
+ if (!typeMatches
984
+ || opened.dev !== current.dev
985
+ || opened.ino !== current.ino
986
+ || (birthtimeAvailable && opened.birthtimeNs !== current.birthtimeNs)) {
987
+ throw new Error(`${label} must be one stable regular ${kind}: ${target}`);
988
+ }
989
+ return {
990
+ path: target,
991
+ dev: opened.dev,
992
+ ino: opened.ino,
993
+ kind,
994
+ birthtimeNs: birthtimeAvailable ? opened.birthtimeNs : undefined,
995
+ handle
996
+ };
997
+ }
998
+ async function captureLockIdentity(target, created) {
999
+ const current = await fsp.lstat(target, { bigint: true });
1000
+ if (!created.isFile()
1001
+ || current.isSymbolicLink()
1002
+ || !current.isFile()
1003
+ || (process.platform !== 'win32' && (created.dev !== current.dev
1004
+ || created.ino !== current.ino))) {
1005
+ throw new Error(`install lock changed identity after exclusive creation: ${target}`);
1006
+ }
1007
+ let pinHandle;
1008
+ try {
1009
+ if (process.platform !== 'win32') {
1010
+ const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
1011
+ pinHandle = await fsp.open(target, fsConstants.O_RDONLY | noFollow);
1012
+ const pinned = await pinHandle.stat({ bigint: true });
1013
+ if (!pinned.isFile()
1014
+ || pinned.dev !== created.dev
1015
+ || pinned.ino !== created.ino) {
1016
+ throw new Error(`install lock pin descriptor does not match exclusive creation: ${target}`);
1017
+ }
1018
+ }
1019
+ else {
1020
+ const confirmed = await confirmWindowsPathForHandle(target, created, current, 'install lock');
1021
+ return {
1022
+ path: target,
1023
+ dev: confirmed.dev,
1024
+ ino: confirmed.ino,
1025
+ kind: 'file',
1026
+ birthtimeNs: confirmed.birthtimeNs
1027
+ };
1028
+ }
1029
+ return {
1030
+ path: target,
1031
+ dev: created.dev,
1032
+ ino: created.ino,
1033
+ kind: 'file',
1034
+ handle: pinHandle
1035
+ };
1036
+ }
1037
+ catch (error) {
1038
+ if (pinHandle)
1039
+ await pinHandle.close().catch(() => { });
1040
+ throw error;
1041
+ }
869
1042
  }
870
1043
  async function removeRecordedEntry(entry, label) {
871
- let current;
872
1044
  try {
873
- current = await fsp.lstat(entry.path);
1045
+ let current;
1046
+ try {
1047
+ current = await fsp.lstat(entry.path, { bigint: true });
1048
+ }
1049
+ catch (error) {
1050
+ if (error.code === 'ENOENT')
1051
+ return;
1052
+ throw error;
1053
+ }
1054
+ const opened = entry.handle ? await entry.handle.stat({ bigint: true }) : undefined;
1055
+ const typeMatches = entry.kind === 'directory'
1056
+ ? current.isDirectory() && !current.isSymbolicLink()
1057
+ : current.isFile() && !current.isSymbolicLink();
1058
+ if (!typeMatches
1059
+ || current.dev !== entry.dev
1060
+ || current.ino !== entry.ino
1061
+ || (entry.birthtimeNs !== undefined && current.birthtimeNs !== entry.birthtimeNs)
1062
+ || (opened && (opened.dev !== current.dev
1063
+ || opened.ino !== current.ino
1064
+ || (entry.birthtimeNs !== undefined && opened.birthtimeNs !== current.birthtimeNs)))) {
1065
+ throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
1066
+ }
1067
+ if (entry.contentsSha256) {
1068
+ try {
1069
+ const bytes = await readRegularFileBytes(entry.path, path.dirname(entry.path));
1070
+ const currentSha256 = createHash('sha256').update(bytes).digest('hex');
1071
+ if (currentSha256 !== entry.contentsSha256)
1072
+ throw new Error('content digest changed');
1073
+ }
1074
+ catch (error) {
1075
+ throw new Error(`${label} identity mismatch (content: ${message(error)}); preserved current path: ${entry.path}`);
1076
+ }
1077
+ }
1078
+ if (entry.treeDigest) {
1079
+ try {
1080
+ const currentTreeDigest = manifestDigest(await collectTreeManifest(entry.path));
1081
+ if (currentTreeDigest !== entry.treeDigest)
1082
+ throw new Error('tree digest changed');
1083
+ }
1084
+ catch (error) {
1085
+ throw new Error(`${label} identity mismatch (tree: ${message(error)}); preserved current path: ${entry.path}`);
1086
+ }
1087
+ }
1088
+ if (entry.ownershipMarker) {
1089
+ try {
1090
+ const markerPath = path.join(entry.path, entry.ownershipMarker.name);
1091
+ const markerBytes = await readRegularFileBytes(markerPath, entry.path);
1092
+ const markerSha256 = createHash('sha256').update(markerBytes).digest('hex');
1093
+ if (markerSha256 !== entry.ownershipMarker.sha256)
1094
+ throw new Error('ownership marker digest changed');
1095
+ }
1096
+ catch (error) {
1097
+ throw new Error(`${label} identity mismatch (ownership marker: ${message(error)}); preserved current path: ${entry.path}`);
1098
+ }
1099
+ }
1100
+ await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
1101
+ }
1102
+ finally {
1103
+ await closeRecordedEntry(entry);
1104
+ }
1105
+ }
1106
+ async function captureTransactionRoot(target, label) {
1107
+ const markerContents = `${randomUUID()}\n`;
1108
+ const markerPath = path.join(target, TRANSACTION_OWNER_MARKER);
1109
+ await fsp.writeFile(markerPath, markerContents, { flag: 'wx', mode: 0o600 });
1110
+ return captureRecordedEntry(target, 'directory', label, {
1111
+ name: TRANSACTION_OWNER_MARKER,
1112
+ sha256: createHash('sha256').update(markerContents).digest('hex')
1113
+ });
1114
+ }
1115
+ async function closeRecordedEntry(entry) {
1116
+ const handle = entry.handle;
1117
+ entry.handle = undefined;
1118
+ if (handle)
1119
+ await handle.close();
1120
+ }
1121
+ async function closeRecordedEntries(entries) {
1122
+ const errors = [];
1123
+ for (const entry of entries) {
1124
+ if (!entry)
1125
+ continue;
1126
+ try {
1127
+ await closeRecordedEntry(entry);
1128
+ }
1129
+ catch (error) {
1130
+ errors.push(`${entry.path}: ${message(error)}`);
1131
+ }
1132
+ }
1133
+ return errors;
1134
+ }
1135
+ async function closeFileHandle(handle, pathLabel) {
1136
+ try {
1137
+ await handle.close();
1138
+ return [];
1139
+ }
1140
+ catch (error) {
1141
+ return [`${pathLabel}: ${message(error)}`];
1142
+ }
1143
+ }
1144
+ export async function planSkillSetInstallation(options) {
1145
+ const sourceRoot = path.resolve(options.sourceRoot);
1146
+ const destination = safeRoot(options.destination, 'skill destination');
1147
+ const codexMirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : '';
1148
+ const skills = unique(options.skills);
1149
+ const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
1150
+ const replaceSkills = unique(options.replaceSkills || []);
1151
+ validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
1152
+ const expected = await expectedInstallManifest(sourceRoot, skills);
1153
+ const sourceDigest = manifestDigest(expected);
1154
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1155
+ const operations = [];
1156
+ const blockers = await nonMutatingInstallGateBlockers(destination);
1157
+ let ownership;
1158
+ try {
1159
+ if (blockers.length)
1160
+ throw new Error(blockers[0]);
1161
+ ownership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
1162
+ }
1163
+ catch (error) {
1164
+ if (!blockers.length)
1165
+ blockers.push(message(error));
1166
+ }
1167
+ if (ownership) {
1168
+ const replaceNames = new Set(ownership.replaceNames);
1169
+ for (const skill of skills) {
1170
+ const target = path.join(destination, skill);
1171
+ const existing = await exists(target);
1172
+ operations.push({
1173
+ action: !existing
1174
+ ? 'create'
1175
+ : replaceSkills.includes(skill)
1176
+ ? 'replace_explicitly_authorized'
1177
+ : 'replace_managed',
1178
+ skill,
1179
+ path: target,
1180
+ reason: !existing
1181
+ ? 'active skill is not installed'
1182
+ : replaceSkills.includes(skill)
1183
+ ? 'operator supplied an explicit per-skill replacement override'
1184
+ : 'existing skill ownership and complete content integrity match the yam receipt'
1185
+ });
1186
+ }
1187
+ for (const skill of cleanupCandidates) {
1188
+ if (!replaceNames.has(skill) || skills.includes(skill))
1189
+ continue;
1190
+ operations.push({
1191
+ action: 'remove_retired',
1192
+ skill,
1193
+ path: path.join(destination, skill),
1194
+ reason: 'retired or legacy skill is still proven yam-owned by the current receipt'
1195
+ });
1196
+ }
1197
+ operations.push({
1198
+ action: 'write_receipt',
1199
+ skill: '',
1200
+ path: receiptPath,
1201
+ reason: ownership.receipt ? 'replace the verified yam ownership receipt' : 'create the yam ownership receipt'
1202
+ });
1203
+ }
1204
+ const preservedPaths = codexMirror && codexMirror !== destination ? [codexMirror] : [];
1205
+ const replaceNames = new Set(ownership?.replaceNames || []);
1206
+ for (const skill of cleanupCandidates) {
1207
+ if (replaceNames.has(skill) || !await exists(path.join(destination, skill)))
1208
+ continue;
1209
+ preservedPaths.push(path.join(destination, skill));
1210
+ }
1211
+ preservedPaths.sort(compareStableText);
1212
+ const planBasis = {
1213
+ package: { name: options.packageName, version: options.version },
1214
+ source: { root: sourceRoot, digest: sourceDigest, file_count: expected.length },
1215
+ destination,
1216
+ receipt_path: receiptPath,
1217
+ operations,
1218
+ preserved_paths: preservedPaths,
1219
+ blockers
1220
+ };
1221
+ const planDigest = createHash('sha256').update(JSON.stringify(planBasis)).digest('hex');
1222
+ const ready = blockers.length === 0;
1223
+ return {
1224
+ schema: 'yam.install-plan.v1',
1225
+ generated_at: (options.now || (() => new Date()))().toISOString(),
1226
+ mutation_authorized: false,
1227
+ ...planBasis,
1228
+ ready,
1229
+ truth_status: ready ? 'verified' : 'blocked',
1230
+ plan_digest: planDigest,
1231
+ next_action: ready
1232
+ ? 'review this non-mutating plan, then run `yam install` without --dry-run to authorize the transaction'
1233
+ : 'resolve the listed lock, recovery, destination, or ownership blocker without deleting unreviewed state, then regenerate the plan'
1234
+ };
1235
+ }
1236
+ async function nonMutatingInstallGateBlockers(destination) {
1237
+ let stat;
1238
+ try {
1239
+ stat = await fsp.lstat(destination);
874
1240
  }
875
1241
  catch (error) {
876
1242
  if (error.code === 'ENOENT')
877
- return;
878
- throw error;
1243
+ return [];
1244
+ return [`skill destination cannot be inspected: ${message(error)}`];
1245
+ }
1246
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1247
+ return [`skill destination must be a regular physical directory: ${destination}`];
1248
+ }
1249
+ const blockers = [];
1250
+ const lockPath = path.join(destination, INSTALL_LOCK_NAME);
1251
+ if (await exists(lockPath)) {
1252
+ blockers.push(`another yam install may be active; lock exists at ${lockPath}`);
1253
+ }
1254
+ try {
1255
+ const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
1256
+ if (recoveryArtifacts.length) {
1257
+ blockers.push(`unfinished yam install transaction requires inspection: ${recoveryArtifacts.join(', ')}`);
1258
+ }
879
1259
  }
880
- const typeMatches = entry.kind === 'directory'
881
- ? current.isDirectory() && !current.isSymbolicLink()
882
- : current.isFile() && !current.isSymbolicLink();
883
- if (!typeMatches || current.dev !== entry.dev || current.ino !== entry.ino) {
884
- throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
1260
+ catch (error) {
1261
+ blockers.push(`install recovery state cannot be inspected: ${message(error)}`);
885
1262
  }
886
- await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
1263
+ return blockers;
887
1264
  }
888
1265
  export async function installSkillSetTransactional(options) {
889
1266
  const sourceRoot = path.resolve(options.sourceRoot);
@@ -910,13 +1287,14 @@ export async function installSkillSetTransactional(options) {
910
1287
  const expected = await expectedInstallManifest(sourceRoot, skills);
911
1288
  const transactionId = options.transactionId || randomUUID();
912
1289
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
913
- const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'install transaction root');
1290
+ const transactionEntry = await captureTransactionRoot(transactionRoot, 'install transaction root');
914
1291
  const stagedRoot = path.join(transactionRoot, 'staged');
915
1292
  const backupRoot = path.join(transactionRoot, 'backup');
916
1293
  const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
917
1294
  const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
918
1295
  const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
919
1296
  const installedEntries = [];
1297
+ const receiptInstalledEntries = [];
920
1298
  let destinationBackups = [];
921
1299
  let receiptBackedUp = false;
922
1300
  let receiptInstalled;
@@ -965,11 +1343,16 @@ export async function installSkillSetTransactional(options) {
965
1343
  for (const skill of skills) {
966
1344
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
967
1345
  const target = path.join(destination, skill);
968
- await moveRegularDirectory(path.join(stagedRoot, skill), target, stagedRoot, destination, 'staged skill install', (entry) => installedEntries.push(entry));
1346
+ const installedEntry = await moveRegularDirectory(path.join(stagedRoot, skill), target, stagedRoot, destination, 'staged skill install', undefined, installedEntries);
1347
+ if (!installedEntry)
1348
+ throw new Error(`staged skill install identity was not recorded: ${target}`);
969
1349
  await options.failpoint?.({ phase: 'skill-installed', skill });
970
1350
  }
971
1351
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
972
- await moveRegularFile(stagedReceiptPath, destinationReceiptPath, transactionRoot, destination, 'staged install receipt', (entry) => { receiptInstalled = entry; });
1352
+ receiptInstalled = await moveRegularFile(stagedReceiptPath, destinationReceiptPath, transactionRoot, destination, 'staged install receipt', undefined, receiptInstalledEntries);
1353
+ if (!receiptInstalled) {
1354
+ throw new Error(`staged install receipt identity was not recorded: ${destinationReceiptPath}`);
1355
+ }
973
1356
  await options.failpoint?.({ phase: 'receipt-installed' });
974
1357
  await options.failpoint?.({ phase: 'before-verify' });
975
1358
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
@@ -983,6 +1366,10 @@ export async function installSkillSetTransactional(options) {
983
1366
  });
984
1367
  if (!inspection.ok)
985
1368
  throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
1369
+ const ownershipHandleErrors = await closeRecordedEntries([receiptInstalled, ...installedEntries]);
1370
+ if (ownershipHandleErrors.length) {
1371
+ cleanupWarnings.push(`verified install identity handle cleanup failed: ${ownershipHandleErrors.join('; ')}`);
1372
+ }
986
1373
  try {
987
1374
  await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
988
1375
  }
@@ -999,6 +1386,7 @@ export async function installSkillSetTransactional(options) {
999
1386
  }
1000
1387
  catch (error) {
1001
1388
  const rollbackErrors = [];
1389
+ receiptInstalled ||= receiptInstalledEntries[0];
1002
1390
  let destinationStillOwned = true;
1003
1391
  try {
1004
1392
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
@@ -1037,6 +1425,8 @@ export async function installSkillSetTransactional(options) {
1037
1425
  }
1038
1426
  throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
1039
1427
  }
1428
+ const retainedHandleErrors = await closeRecordedEntries([transactionEntry, receiptInstalled, ...installedEntries]);
1429
+ rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
1040
1430
  throw new Error(`yam install failed and rollback is incomplete: ${message(error)}. `
1041
1431
  + `Recovery artifacts were kept at ${transactionRoot}. `
1042
1432
  + `Rollback errors: ${rollbackErrors.join('; ')}`);
@@ -1046,12 +1436,7 @@ export async function installSkillSetTransactional(options) {
1046
1436
  primaryError = error;
1047
1437
  }
1048
1438
  const lockCleanupErrors = [];
1049
- try {
1050
- await lock.handle.close();
1051
- }
1052
- catch (error) {
1053
- lockCleanupErrors.push(message(error));
1054
- }
1439
+ lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
1055
1440
  try {
1056
1441
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1057
1442
  await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
@@ -1059,6 +1444,9 @@ export async function installSkillSetTransactional(options) {
1059
1444
  catch (error) {
1060
1445
  lockCleanupErrors.push(message(error));
1061
1446
  }
1447
+ finally {
1448
+ lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
1449
+ }
1062
1450
  if (lockCleanupErrors.length) {
1063
1451
  const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
1064
1452
  if (primaryError)
@@ -1095,7 +1483,7 @@ export async function uninstallSkillSetSafely(options) {
1095
1483
  }
1096
1484
  await preflightSafeUninstallOwnership(destination, options.packageName, skills);
1097
1485
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
1098
- const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'uninstall transaction root');
1486
+ const transactionEntry = await captureTransactionRoot(transactionRoot, 'uninstall transaction root');
1099
1487
  const backupRoot = path.join(transactionRoot, 'backup');
1100
1488
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1101
1489
  const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
@@ -1170,6 +1558,8 @@ export async function uninstallSkillSetSafely(options) {
1170
1558
  }
1171
1559
  throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
1172
1560
  }
1561
+ const retainedHandleErrors = await closeRecordedEntries([transactionEntry]);
1562
+ rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
1173
1563
  throw new Error(`yam uninstall failed and rollback is incomplete: ${message(error)}. `
1174
1564
  + `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`);
1175
1565
  }
@@ -1178,12 +1568,7 @@ export async function uninstallSkillSetSafely(options) {
1178
1568
  primaryError = error;
1179
1569
  }
1180
1570
  const lockCleanupErrors = [];
1181
- try {
1182
- await lock.handle.close();
1183
- }
1184
- catch (error) {
1185
- lockCleanupErrors.push(message(error));
1186
- }
1571
+ lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
1187
1572
  try {
1188
1573
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1189
1574
  await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
@@ -1191,6 +1576,9 @@ export async function uninstallSkillSetSafely(options) {
1191
1576
  catch (error) {
1192
1577
  lockCleanupErrors.push(message(error));
1193
1578
  }
1579
+ finally {
1580
+ lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
1581
+ }
1194
1582
  if (lockCleanupErrors.length) {
1195
1583
  const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
1196
1584
  if (primaryError)