yam-flow 2.3.0 → 2.3.2

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.
@@ -0,0 +1,638 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
6
+ export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
7
+ export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
8
+ export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
9
+
10
+ export type InstallFileDigest = {
11
+ path: string;
12
+ bytes: number;
13
+ sha256: string;
14
+ };
15
+
16
+ export type InstallReceipt = {
17
+ schema: typeof INSTALL_RECEIPT_SCHEMA;
18
+ package: {
19
+ name: string;
20
+ version: string;
21
+ };
22
+ source: {
23
+ kind: 'package-bundled-skills';
24
+ identity: string;
25
+ };
26
+ destination: string;
27
+ installed_at: string;
28
+ transaction_id: string;
29
+ skills: string[];
30
+ integrity: {
31
+ algorithm: 'sha256';
32
+ file_count: number;
33
+ source_digest: string;
34
+ files: InstallFileDigest[];
35
+ };
36
+ };
37
+
38
+ export type InstallFailpointEvent = {
39
+ phase: 'after-stage' | 'after-backup' | 'skill-installed' | 'receipt-installed' | 'mirror-cleaned' | 'before-verify';
40
+ skill?: string;
41
+ };
42
+
43
+ export type TransactionalInstallOptions = {
44
+ sourceRoot: string;
45
+ destination: string;
46
+ codexMirror?: string;
47
+ packageName: string;
48
+ version: string;
49
+ skills: string[];
50
+ legacySkills?: string[];
51
+ retiredSkills?: string[];
52
+ now?: () => Date;
53
+ transactionId?: string;
54
+ failpoint?: (event: InstallFailpointEvent) => void | Promise<void>;
55
+ };
56
+
57
+ export type InstallationInspectionOptions = Pick<
58
+ TransactionalInstallOptions,
59
+ 'sourceRoot' | 'destination' | 'packageName' | 'version' | 'skills'
60
+ > & {
61
+ ignoreRecoveryArtifacts?: boolean;
62
+ };
63
+
64
+ export type SkillInstallStatus = {
65
+ skill: string;
66
+ status: 'ok' | 'missing' | 'drift';
67
+ issues: string[];
68
+ };
69
+
70
+ export type InstallationInspection = {
71
+ ok: boolean;
72
+ destination: string;
73
+ receiptPath: string;
74
+ receiptStatus: 'ok' | 'missing' | 'drift';
75
+ receipt?: InstallReceipt;
76
+ sourceDigest?: string;
77
+ recoveryArtifacts: string[];
78
+ skills: SkillInstallStatus[];
79
+ issues: string[];
80
+ };
81
+
82
+ export type TransactionalInstallResult = {
83
+ receipt: InstallReceipt;
84
+ receiptPath: string;
85
+ installedFiles: number;
86
+ sourceDigest: string;
87
+ cleanupWarnings: string[];
88
+ };
89
+
90
+ type MovedEntry = {
91
+ original: string;
92
+ backup: string;
93
+ };
94
+
95
+ function message(error: unknown) {
96
+ return error instanceof Error ? error.message : String(error);
97
+ }
98
+
99
+ function unique(values: string[]) {
100
+ return [...new Set(values)];
101
+ }
102
+
103
+ function validateManagedNames(names: string[]) {
104
+ for (const name of names) {
105
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
106
+ throw new Error(`unsafe managed skill name: ${name}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ function safeRoot(value: string, label: string) {
112
+ const resolved = path.resolve(value);
113
+ if (resolved === path.parse(resolved).root) {
114
+ throw new Error(`${label} cannot be a filesystem root: ${resolved}`);
115
+ }
116
+ return resolved;
117
+ }
118
+
119
+ async function exists(target: string) {
120
+ try {
121
+ await fsp.access(target);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ export async function findInstallRecoveryArtifacts(root: string) {
129
+ try {
130
+ const entries = await fsp.readdir(root, { withFileTypes: true });
131
+ return entries
132
+ .filter((entry) => entry.name.startsWith(INSTALL_TRANSACTION_PREFIX) && entry.name !== INSTALL_RECEIPT_NAME)
133
+ .map((entry) => path.join(root, entry.name))
134
+ .sort();
135
+ } catch (error) {
136
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ async function digestFile(file: string, relativePath: string): Promise<InstallFileDigest> {
142
+ const stat = await fsp.lstat(file);
143
+ if (!stat.isFile() || stat.isSymbolicLink()) {
144
+ throw new Error(`install source must be a regular file: ${file}`);
145
+ }
146
+ const contents = await fsp.readFile(file);
147
+ return {
148
+ path: relativePath.split(path.sep).join('/'),
149
+ bytes: contents.byteLength,
150
+ sha256: createHash('sha256').update(contents).digest('hex')
151
+ };
152
+ }
153
+
154
+ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFileDigest[]> {
155
+ const stat = await fsp.lstat(root);
156
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
157
+ throw new Error(`install tree must be a regular directory: ${root}`);
158
+ }
159
+
160
+ const files: InstallFileDigest[] = [];
161
+ async function walk(current: string, relative: string): Promise<void> {
162
+ const entries = await fsp.readdir(current, { withFileTypes: true });
163
+ entries.sort((left, right) => left.name.localeCompare(right.name));
164
+ for (const entry of entries) {
165
+ const absolute = path.join(current, entry.name);
166
+ const nextRelative = relative ? path.join(relative, entry.name) : entry.name;
167
+ if (entry.isDirectory()) {
168
+ await walk(absolute, nextRelative);
169
+ } else if (entry.isFile()) {
170
+ files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
171
+ } else {
172
+ throw new Error(`unsupported install tree entry: ${absolute}`);
173
+ }
174
+ }
175
+ }
176
+
177
+ await walk(root, '');
178
+ return files.sort((left, right) => left.path.localeCompare(right.path));
179
+ }
180
+
181
+ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
182
+ const referencesRoot = path.join(sourceRoot, 'references');
183
+ const referenceFiles = await collectTreeManifest(referencesRoot);
184
+ if (referenceFiles.length === 0) throw new Error(`no reference files found: ${referencesRoot}`);
185
+
186
+ const files: InstallFileDigest[] = [];
187
+ for (const skill of skills) {
188
+ const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
189
+ const skillText = await fsp.readFile(skillFile, 'utf8');
190
+ const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
191
+ const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
192
+ ?.trim()
193
+ .replace(/^["']|["']$/g, '');
194
+ if (name !== skill) {
195
+ throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
196
+ }
197
+ files.push(await digestFile(skillFile, path.join(skill, 'SKILL.md')));
198
+ for (const reference of referenceFiles) {
199
+ files.push({
200
+ ...reference,
201
+ path: path.posix.join(skill, 'references', reference.path)
202
+ });
203
+ }
204
+ }
205
+ return files.sort((left, right) => left.path.localeCompare(right.path));
206
+ }
207
+
208
+ async function copyTree(source: string, target: string): Promise<void> {
209
+ const stat = await fsp.lstat(source);
210
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
211
+ throw new Error(`install source must be a regular directory: ${source}`);
212
+ }
213
+ await fsp.mkdir(target, { recursive: true });
214
+ const entries = await fsp.readdir(source, { withFileTypes: true });
215
+ entries.sort((left, right) => left.name.localeCompare(right.name));
216
+ for (const entry of entries) {
217
+ const from = path.join(source, entry.name);
218
+ const to = path.join(target, entry.name);
219
+ if (entry.isDirectory()) {
220
+ await copyTree(from, to);
221
+ } else if (entry.isFile()) {
222
+ await fsp.copyFile(from, to);
223
+ } else {
224
+ throw new Error(`unsupported install source entry: ${from}`);
225
+ }
226
+ }
227
+ }
228
+
229
+ function manifestDigest(files: InstallFileDigest[]) {
230
+ const digest = createHash('sha256');
231
+ for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
232
+ digest.update(file.path);
233
+ digest.update('\0');
234
+ digest.update(String(file.bytes));
235
+ digest.update('\0');
236
+ digest.update(file.sha256);
237
+ digest.update('\n');
238
+ }
239
+ return digest.digest('hex');
240
+ }
241
+
242
+ function manifestIssues(expected: InstallFileDigest[], actual: InstallFileDigest[], label: string) {
243
+ const issues: string[] = [];
244
+ const expectedByPath = new Map(expected.map((file) => [file.path, file]));
245
+ const actualByPath = new Map(actual.map((file) => [file.path, file]));
246
+ if (actualByPath.size !== actual.length) issues.push(`${label} contains duplicate file paths`);
247
+
248
+ for (const expectedFile of expected) {
249
+ const actualFile = actualByPath.get(expectedFile.path);
250
+ if (!actualFile) {
251
+ issues.push(`${label} missing file: ${expectedFile.path}`);
252
+ continue;
253
+ }
254
+ if (actualFile.bytes !== expectedFile.bytes || actualFile.sha256 !== expectedFile.sha256) {
255
+ issues.push(`${label} hash drift: ${expectedFile.path}`);
256
+ }
257
+ }
258
+ for (const actualFile of actual) {
259
+ if (!expectedByPath.has(actualFile.path)) issues.push(`${label} unexpected file: ${actualFile.path}`);
260
+ }
261
+ return issues;
262
+ }
263
+
264
+ function isFileDigest(value: unknown): value is InstallFileDigest {
265
+ if (!value || typeof value !== 'object') return false;
266
+ const file = value as Record<string, unknown>;
267
+ return typeof file.path === 'string'
268
+ && typeof file.bytes === 'number'
269
+ && Number.isSafeInteger(file.bytes)
270
+ && file.bytes >= 0
271
+ && typeof file.sha256 === 'string'
272
+ && /^[a-f0-9]{64}$/.test(file.sha256);
273
+ }
274
+
275
+ function receiptIssues(
276
+ receipt: unknown,
277
+ options: InstallationInspectionOptions,
278
+ destination: string,
279
+ expected: InstallFileDigest[]
280
+ ) {
281
+ const issues: string[] = [];
282
+ if (!receipt || typeof receipt !== 'object') return ['install receipt must be a JSON object'];
283
+ const value = receipt as Record<string, any>;
284
+ if (value.schema !== INSTALL_RECEIPT_SCHEMA) issues.push(`install receipt schema mismatch: ${value.schema || 'missing'}`);
285
+ if (value.package?.name !== options.packageName) issues.push(`install receipt package mismatch: ${value.package?.name || 'missing'}`);
286
+ if (value.package?.version !== options.version) issues.push(`install receipt version drift: ${value.package?.version || 'missing'} != ${options.version}`);
287
+ if (value.source?.kind !== 'package-bundled-skills') issues.push('install receipt source kind mismatch');
288
+ if (value.source?.identity !== `${options.packageName}@${options.version}`) issues.push('install receipt source identity drift');
289
+ if (typeof value.destination !== 'string' || path.resolve(value.destination) !== destination) issues.push('install receipt destination drift');
290
+ if (typeof value.installed_at !== 'string' || Number.isNaN(Date.parse(value.installed_at))) issues.push('install receipt timestamp missing or invalid');
291
+ if (typeof value.transaction_id !== 'string' || !value.transaction_id) issues.push('install receipt transaction id missing');
292
+ if (JSON.stringify(value.skills) !== JSON.stringify(options.skills)) issues.push('install receipt skill inventory drift');
293
+ if (value.integrity?.algorithm !== 'sha256') issues.push('install receipt integrity algorithm mismatch');
294
+ if (value.integrity?.file_count !== expected.length) issues.push('install receipt file count drift');
295
+ const expectedDigest = manifestDigest(expected);
296
+ if (value.integrity?.source_digest !== expectedDigest) issues.push('install receipt source digest drift');
297
+ if (!Array.isArray(value.integrity?.files) || !value.integrity.files.every(isFileDigest)) {
298
+ issues.push('install receipt file manifest is malformed');
299
+ } else {
300
+ issues.push(...manifestIssues(expected, value.integrity.files, 'install receipt'));
301
+ }
302
+ return issues;
303
+ }
304
+
305
+ export async function inspectSkillInstallation(options: InstallationInspectionOptions): Promise<InstallationInspection> {
306
+ const destination = safeRoot(options.destination, 'skill destination');
307
+ validateManagedNames(options.skills);
308
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
309
+ const issues: string[] = [];
310
+ const recoveryArtifacts = options.ignoreRecoveryArtifacts ? [] : await findInstallRecoveryArtifacts(destination);
311
+ for (const artifact of recoveryArtifacts) issues.push(`unfinished install transaction requires inspection: ${artifact}`);
312
+ let expected: InstallFileDigest[];
313
+
314
+ try {
315
+ expected = await expectedInstallManifest(path.resolve(options.sourceRoot), options.skills);
316
+ } catch (error) {
317
+ const sourceIssue = `install source verification failed: ${message(error)}`;
318
+ return {
319
+ ok: false,
320
+ destination,
321
+ receiptPath,
322
+ receiptStatus: 'drift',
323
+ sourceDigest: undefined,
324
+ recoveryArtifacts,
325
+ skills: options.skills.map((skill) => ({ skill, status: 'drift', issues: [sourceIssue] })),
326
+ issues: [...issues, sourceIssue]
327
+ };
328
+ }
329
+
330
+ const skillStatuses: SkillInstallStatus[] = [];
331
+ for (const skill of options.skills) {
332
+ const target = path.join(destination, skill);
333
+ const expectedForSkill = expected.filter((file) => file.path.startsWith(`${skill}/`));
334
+ if (!await exists(target)) {
335
+ const issue = `${skill} is not installed`;
336
+ skillStatuses.push({ skill, status: 'missing', issues: [issue] });
337
+ issues.push(issue);
338
+ continue;
339
+ }
340
+ try {
341
+ const installed = (await collectTreeManifest(target, skill));
342
+ const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
343
+ skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
344
+ issues.push(...skillIssues);
345
+ } catch (error) {
346
+ const issue = `installed ${skill} cannot be verified: ${message(error)}`;
347
+ skillStatuses.push({ skill, status: 'drift', issues: [issue] });
348
+ issues.push(issue);
349
+ }
350
+ }
351
+
352
+ let receiptStatus: InstallationInspection['receiptStatus'] = 'missing';
353
+ let receipt: InstallReceipt | undefined;
354
+ if (!await exists(receiptPath)) {
355
+ issues.push(`install receipt missing: ${receiptPath}`);
356
+ } else {
357
+ try {
358
+ const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
359
+ const problems = receiptIssues(parsed, options, destination, expected);
360
+ if (problems.length) {
361
+ receiptStatus = 'drift';
362
+ issues.push(...problems);
363
+ } else {
364
+ receiptStatus = 'ok';
365
+ receipt = parsed as InstallReceipt;
366
+ }
367
+ } catch (error) {
368
+ receiptStatus = 'drift';
369
+ issues.push(`install receipt unreadable: ${message(error)}`);
370
+ }
371
+ }
372
+
373
+ return {
374
+ ok: skillStatuses.every((skill) => skill.status === 'ok') && receiptStatus === 'ok' && recoveryArtifacts.length === 0,
375
+ destination,
376
+ receiptPath,
377
+ receiptStatus,
378
+ receipt,
379
+ sourceDigest: manifestDigest(expected),
380
+ recoveryArtifacts,
381
+ skills: skillStatuses,
382
+ issues
383
+ };
384
+ }
385
+
386
+ async function acquireInstallLock(destination: string) {
387
+ const lockPath = path.join(destination, INSTALL_LOCK_NAME);
388
+ let handle: fsp.FileHandle;
389
+ try {
390
+ handle = await fsp.open(lockPath, 'wx', 0o600);
391
+ } catch (error) {
392
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
393
+ throw new Error(`another yam install may be active; lock exists at ${lockPath}. Remove it only after confirming no install is running.`);
394
+ }
395
+ throw error;
396
+ }
397
+ try {
398
+ await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
399
+ } catch (error) {
400
+ await handle.close().catch(() => undefined);
401
+ await fsp.rm(lockPath, { force: true }).catch(() => undefined);
402
+ throw error;
403
+ }
404
+ return { handle, lockPath };
405
+ }
406
+
407
+ async function moveExistingEntries(root: string, names: string[], backupRoot: string, moved: MovedEntry[]) {
408
+ await fsp.mkdir(backupRoot, { recursive: true });
409
+ for (const name of names) {
410
+ const original = path.join(root, name);
411
+ if (!await exists(original)) continue;
412
+ const backup = path.join(backupRoot, name);
413
+ await fsp.rename(original, backup);
414
+ moved.push({ original, backup });
415
+ }
416
+ }
417
+
418
+ async function restoreMovedEntries(entries: MovedEntry[]) {
419
+ const errors: string[] = [];
420
+ for (const entry of [...entries].reverse()) {
421
+ try {
422
+ if (await exists(entry.original)) {
423
+ throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
424
+ }
425
+ await fsp.mkdir(path.dirname(entry.original), { recursive: true });
426
+ await fsp.rename(entry.backup, entry.original);
427
+ } catch (error) {
428
+ errors.push(message(error));
429
+ }
430
+ }
431
+ return errors;
432
+ }
433
+
434
+ async function removeInstalledEntries(entries: string[]) {
435
+ const errors: string[] = [];
436
+ for (const entry of [...entries].reverse()) {
437
+ try {
438
+ await fsp.rm(entry, { recursive: true, force: true });
439
+ } catch (error) {
440
+ errors.push(`could not remove new install ${entry}: ${message(error)}`);
441
+ }
442
+ }
443
+ return errors;
444
+ }
445
+
446
+ export async function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult> {
447
+ const sourceRoot = path.resolve(options.sourceRoot);
448
+ const destination = safeRoot(options.destination, 'skill destination');
449
+ const mirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : undefined;
450
+ const skills = unique(options.skills);
451
+ const managedNames = unique([...skills, ...(options.legacySkills || []), ...(options.retiredSkills || [])]);
452
+ validateManagedNames(managedNames);
453
+ await fsp.mkdir(destination, { recursive: true });
454
+
455
+ const lock = await acquireInstallLock(destination);
456
+ const cleanupWarnings: string[] = [];
457
+ let result: TransactionalInstallResult | undefined;
458
+ let primaryError: unknown;
459
+ try {
460
+ const recoveryArtifacts = [
461
+ ...await findInstallRecoveryArtifacts(destination),
462
+ ...(mirror && mirror !== destination ? await findInstallRecoveryArtifacts(mirror) : [])
463
+ ];
464
+ if (recoveryArtifacts.length) {
465
+ throw new Error(
466
+ `unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
467
+ + 'Inspect and preserve any backup state before removing the transaction artifact and retrying.'
468
+ );
469
+ }
470
+ const expected = await expectedInstallManifest(sourceRoot, skills);
471
+ const transactionId = options.transactionId || randomUUID();
472
+ const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
473
+ const stagedRoot = path.join(transactionRoot, 'staged');
474
+ const backupRoot = path.join(transactionRoot, 'backup');
475
+ const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
476
+ const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
477
+ const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
478
+ let mirrorTransactionRoot: string | undefined;
479
+ let mirrorBackupRoot: string | undefined;
480
+ const installedEntries: string[] = [];
481
+ let destinationBackups: MovedEntry[] = [];
482
+ let mirrorBackups: MovedEntry[] = [];
483
+ let receiptBackedUp = false;
484
+ let receiptInstalled = false;
485
+ let mutationStarted = false;
486
+
487
+ try {
488
+ await fsp.mkdir(stagedRoot, { recursive: true });
489
+ for (const skill of skills) {
490
+ const stagedSkill = path.join(stagedRoot, skill);
491
+ await fsp.mkdir(stagedSkill, { recursive: true });
492
+ await fsp.copyFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'));
493
+ await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'));
494
+ }
495
+ const stagedManifest = await collectTreeManifest(stagedRoot);
496
+ const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
497
+ if (stagedIssues.length) throw new Error(stagedIssues.join('; '));
498
+
499
+ const receipt: InstallReceipt = {
500
+ schema: INSTALL_RECEIPT_SCHEMA,
501
+ package: { name: options.packageName, version: options.version },
502
+ source: { kind: 'package-bundled-skills', identity: `${options.packageName}@${options.version}` },
503
+ destination,
504
+ installed_at: (options.now || (() => new Date()))().toISOString(),
505
+ transaction_id: transactionId,
506
+ skills,
507
+ integrity: {
508
+ algorithm: 'sha256',
509
+ file_count: expected.length,
510
+ source_digest: manifestDigest(expected),
511
+ files: expected
512
+ }
513
+ };
514
+ await fsp.writeFile(stagedReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
515
+ await options.failpoint?.({ phase: 'after-stage' });
516
+
517
+ if (mirror && mirror !== destination && await exists(mirror)) {
518
+ mirrorTransactionRoot = await fsp.mkdtemp(path.join(mirror, '.yam-flow-install-'));
519
+ await fsp.chmod(mirrorTransactionRoot, 0o700);
520
+ mirrorBackupRoot = path.join(mirrorTransactionRoot, 'backup');
521
+ }
522
+
523
+ mutationStarted = true;
524
+ await moveExistingEntries(destination, managedNames, backupRoot, destinationBackups);
525
+ if (await exists(destinationReceiptPath)) {
526
+ await fsp.rename(destinationReceiptPath, receiptBackupPath);
527
+ receiptBackedUp = true;
528
+ }
529
+ await options.failpoint?.({ phase: 'after-backup' });
530
+
531
+ for (const skill of skills) {
532
+ const target = path.join(destination, skill);
533
+ await fsp.rename(path.join(stagedRoot, skill), target);
534
+ installedEntries.push(target);
535
+ await options.failpoint?.({ phase: 'skill-installed', skill });
536
+ }
537
+ await fsp.rename(stagedReceiptPath, destinationReceiptPath);
538
+ receiptInstalled = true;
539
+ await options.failpoint?.({ phase: 'receipt-installed' });
540
+
541
+ if (mirror && mirrorBackupRoot) {
542
+ await moveExistingEntries(mirror, managedNames, mirrorBackupRoot, mirrorBackups);
543
+ await options.failpoint?.({ phase: 'mirror-cleaned' });
544
+ }
545
+
546
+ await options.failpoint?.({ phase: 'before-verify' });
547
+ const inspection = await inspectSkillInstallation({
548
+ sourceRoot,
549
+ destination,
550
+ packageName: options.packageName,
551
+ version: options.version,
552
+ skills,
553
+ ignoreRecoveryArtifacts: true
554
+ });
555
+ if (!inspection.ok) throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
556
+
557
+ for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
558
+ if (!cleanup) continue;
559
+ try {
560
+ await fsp.rm(cleanup, { recursive: true, force: true });
561
+ } catch (error) {
562
+ cleanupWarnings.push(`verified install retained cleanup artifact ${cleanup}: ${message(error)}`);
563
+ }
564
+ }
565
+ result = {
566
+ receipt,
567
+ receiptPath: destinationReceiptPath,
568
+ installedFiles: expected.length,
569
+ sourceDigest: receipt.integrity.source_digest,
570
+ cleanupWarnings
571
+ };
572
+ } catch (error) {
573
+ const rollbackErrors: string[] = [];
574
+ if (mutationStarted) {
575
+ if (receiptInstalled) rollbackErrors.push(...await removeInstalledEntries([destinationReceiptPath]));
576
+ rollbackErrors.push(...await removeInstalledEntries(installedEntries));
577
+ rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
578
+ if (receiptBackedUp) {
579
+ try {
580
+ if (await exists(destinationReceiptPath)) throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
581
+ await fsp.rename(receiptBackupPath, destinationReceiptPath);
582
+ } catch (receiptError) {
583
+ rollbackErrors.push(message(receiptError));
584
+ }
585
+ }
586
+ rollbackErrors.push(...await restoreMovedEntries(mirrorBackups));
587
+ }
588
+
589
+ if (rollbackErrors.length === 0) {
590
+ const cleanupErrors: string[] = [];
591
+ for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
592
+ if (!cleanup) continue;
593
+ try {
594
+ await fsp.rm(cleanup, { recursive: true, force: true });
595
+ } catch (cleanupError) {
596
+ cleanupErrors.push(`${cleanup}: ${message(cleanupError)}`);
597
+ }
598
+ }
599
+ if (cleanupErrors.length) {
600
+ throw new Error(
601
+ `yam install failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
602
+ + `Cleanup errors: ${cleanupErrors.join('; ')}`
603
+ );
604
+ }
605
+ throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
606
+ }
607
+ throw new Error(
608
+ `yam install failed and rollback is incomplete: ${message(error)}. `
609
+ + `Recovery artifacts were kept at ${transactionRoot}${mirrorTransactionRoot ? ` and ${mirrorTransactionRoot}` : ''}. `
610
+ + `Rollback errors: ${rollbackErrors.join('; ')}`
611
+ );
612
+ }
613
+ } catch (error) {
614
+ primaryError = error;
615
+ }
616
+
617
+ const lockCleanupErrors: string[] = [];
618
+ try {
619
+ await lock.handle.close();
620
+ } catch (error) {
621
+ lockCleanupErrors.push(message(error));
622
+ }
623
+ try {
624
+ await fsp.rm(lock.lockPath, { force: true });
625
+ } catch (error) {
626
+ lockCleanupErrors.push(message(error));
627
+ }
628
+ if (lockCleanupErrors.length) {
629
+ const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
630
+ if (primaryError) primaryError = new Error(`${message(primaryError)}. ${warning}`);
631
+ else cleanupWarnings.push(warning);
632
+ }
633
+
634
+ if (primaryError) throw primaryError;
635
+ if (!result) throw new Error('yam install ended without a result');
636
+ result.cleanupWarnings = cleanupWarnings;
637
+ return result;
638
+ }
package/yam.manifest.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "yam",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "principles": [
5
5
  "Direction before execution.",
6
6
  "Start fast.",
7
7
  "Deepen deliberately.",
8
8
  "Basic direction fit and honest verification always.",
9
9
  "Honest by design.",
10
+ "Stage and verify the complete managed skill set before replacement, then record install integrity for later drift checks.",
10
11
  "Heavy proof when risk or user intent calls for it.",
11
12
  "Token-aware by default.",
12
13
  "Context-reuse first.",
13
14
  "End with remaining tasks and fix-first items.",
14
15
  "When code, config, release metadata, documentation, or project artifacts changed, include a Study Note even if no yam skill was explicitly invoked.",
16
+ "When the opt-in Study Note hook is enabled, check the final response at Codex Stop and request one bounded correction pass instead of relying only on prompt-time advice.",
15
17
  "Explain what changed, what role it has, where it runs or is read, what was wrong or missing when relevant, before/after behavior, expected behavior, and one useful syntax or structure insight in short non-specialist language.",
16
18
  "When relevant, include architecture hygiene: avoid dumping unrelated logic into page.tsx, one-off component CSS into global.css, or structured product data into broad DB jsonb blobs when better project boundaries exist.",
17
19
  "Use handoff notes for fix-first items, remaining tasks, recommended next direction, requirement coverage, blocker kind, failure cause, recovery hint, safe retry, evidence level, and owner scope when useful.",