@djordje-stojanovic/sigmaskills 0.2.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 (53) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +342 -0
  4. package/bin/sigmaskills.js +7 -0
  5. package/manifest.json +28 -0
  6. package/package.json +42 -0
  7. package/registry/agent-hosts.json +2404 -0
  8. package/registry/schema.json +110 -0
  9. package/registry/skill-baselines.json +4 -0
  10. package/registry/source.json +6 -0
  11. package/sigmabrief/SKILL.md +56 -0
  12. package/sigmabrief/agents/openai.yaml +12 -0
  13. package/sigmabrief/references/brief-method.md +73 -0
  14. package/sigmabrief/references/prompt-contract.md +174 -0
  15. package/sigmaperformance/SKILL.md +118 -0
  16. package/sigmaperformance/agents/openai.yaml +12 -0
  17. package/sigmaperformance/references/audit-method.md +112 -0
  18. package/sigmaperformance/references/calibration.md +45 -0
  19. package/sigmaperformance/references/report-contract.md +103 -0
  20. package/sigmareview/SKILL.md +133 -0
  21. package/sigmareview/agents/openai.yaml +12 -0
  22. package/sigmareview/references/report-contract.md +217 -0
  23. package/sigmareview/references/review-method.md +233 -0
  24. package/sigmawrite/SKILL.md +45 -0
  25. package/sigmawrite/agents/openai.yaml +12 -0
  26. package/src/adoption.js +370 -0
  27. package/src/backup.js +398 -0
  28. package/src/catalog.js +211 -0
  29. package/src/cli.js +657 -0
  30. package/src/customization.js +344 -0
  31. package/src/destinations.js +491 -0
  32. package/src/interactive.js +959 -0
  33. package/src/links.js +157 -0
  34. package/src/plan.js +429 -0
  35. package/src/prepack.js +10 -0
  36. package/src/project-lock.js +169 -0
  37. package/src/purge.js +477 -0
  38. package/src/registry/automation-ci.js +411 -0
  39. package/src/registry/automation.js +554 -0
  40. package/src/registry/diff.js +149 -0
  41. package/src/registry/normalize.js +67 -0
  42. package/src/registry/parse.js +184 -0
  43. package/src/registry/sync.js +230 -0
  44. package/src/registry/validate.js +223 -0
  45. package/src/release-ci.js +12 -0
  46. package/src/release.js +837 -0
  47. package/src/restore.js +518 -0
  48. package/src/revision.js +82 -0
  49. package/src/state.js +480 -0
  50. package/src/status.js +469 -0
  51. package/src/transaction.js +636 -0
  52. package/src/uninstall.js +647 -0
  53. package/src/update.js +815 -0
@@ -0,0 +1,636 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { findPackageRoot, validateSkill } from './catalog.js';
4
+ import { injectCustomContent, injectRawCustomContent } from './customization.js';
5
+ import { commitSkillBackup, exportSkillTree, getBackupRoot, pruneOlderBackups } from './backup.js';
6
+ import { createInstallPlan } from './plan.js';
7
+ import { loadProjectLock, saveProjectLock, updateProjectLockSkill, PROJECT_LOCK_FILENAME } from './project-lock.js';
8
+ import { resolveHomeDir } from './destinations.js';
9
+ import {
10
+ getGlobalStateDir,
11
+ getGlobalStatePath,
12
+ getProjectStateDir,
13
+ getProjectStatePath,
14
+ loadGlobalState,
15
+ loadProjectState,
16
+ saveGlobalState,
17
+ saveProjectState,
18
+ recordSkillInState,
19
+ } from './state.js';
20
+ import { createSkillLink, pathExists, removeManagedPath } from './links.js';
21
+
22
+ /**
23
+ * Acquire process concurrency lock for project.
24
+ *
25
+ * @param {string} projectRoot
26
+ * @param {string} [customStateDir]
27
+ * @returns {() => void} Release lock function
28
+ */
29
+ export function acquireConcurrencyLock(projectRoot, customStateDir) {
30
+ const stateDir = getProjectStateDir(projectRoot, customStateDir);
31
+ if (!fs.existsSync(stateDir)) {
32
+ fs.mkdirSync(stateDir, { recursive: true });
33
+ }
34
+
35
+ const lockPath = path.join(stateDir, '.sigma.lock');
36
+
37
+ function tryCreateLock() {
38
+ try {
39
+ const fd = fs.openSync(lockPath, 'wx');
40
+ const payload = JSON.stringify({
41
+ pid: process.pid,
42
+ createdAt: new Date().toISOString(),
43
+ });
44
+ fs.writeFileSync(fd, payload, 'utf8');
45
+ fs.closeSync(fd);
46
+ return true;
47
+ } catch (err) {
48
+ if (err.code === 'EEXIST') {
49
+ return false;
50
+ }
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ if (!tryCreateLock()) {
56
+ // Check if existing lock is stale
57
+ try {
58
+ const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
59
+ const isAlive = isProcessAlive(existing.pid);
60
+ if (isAlive) {
61
+ throw new Error(
62
+ `Concurrent SigmaSkills operation in progress (PID ${existing.pid}). Aborting to prevent corruption.`,
63
+ );
64
+ }
65
+ // Stale lock from dead process: remove and retry
66
+ fs.unlinkSync(lockPath);
67
+ if (!tryCreateLock()) {
68
+ throw new Error('Failed to acquire lock after clearing stale lock file.');
69
+ }
70
+ } catch (readErr) {
71
+ if (readErr.message.includes('Concurrent SigmaSkills operation')) {
72
+ throw readErr;
73
+ }
74
+ // If reading/parsing failed, attempt unlink and recreate
75
+ try {
76
+ fs.unlinkSync(lockPath);
77
+ if (!tryCreateLock()) {
78
+ throw new Error('Failed to acquire lock.');
79
+ }
80
+ } catch {
81
+ throw new Error(`Failed to acquire project lock at ${lockPath}: ${readErr.message}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ let released = false;
87
+ return function releaseLock() {
88
+ if (released) return;
89
+ released = true;
90
+ try {
91
+ if (fs.existsSync(lockPath)) {
92
+ const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
93
+ if (existing.pid === process.pid) {
94
+ fs.unlinkSync(lockPath);
95
+ }
96
+ }
97
+ } catch {
98
+ // Best-effort cleanup
99
+ }
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Check if a process ID is currently running.
105
+ *
106
+ * @param {number} pid
107
+ * @returns {boolean}
108
+ */
109
+ function isProcessAlive(pid) {
110
+ if (!pid || typeof pid !== 'number') return false;
111
+ try {
112
+ process.kill(pid, 0);
113
+ return true;
114
+ } catch (err) {
115
+ return err.code === 'EPERM'; // Process exists but no permission
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Build the fail-closed error for an unowned destination.
121
+ *
122
+ * @param {object} plan
123
+ * @returns {Error}
124
+ */
125
+ export function createUnownedConflictError(plan) {
126
+ const conflictDest = (plan.destinations || []).find((dest) => dest.unownedConflict)?.destination
127
+ || plan.destination;
128
+ return new Error(
129
+ `Destination '${conflictDest}' already exists and is not owned by SigmaSkills. Safe adoption is not enabled. Installation aborted.`,
130
+ );
131
+ }
132
+
133
+ export function createNeedsResolutionError(plan) {
134
+ const pending = (plan.destinations || []).filter((dest) => dest.migratable && !dest.resolution);
135
+ const details = pending.map((dest) => {
136
+ const diff = dest.diff || { added: [], replaced: [], deleted: [] };
137
+ return `${dest.relativeDestination} (${dest.recognition}, provenance ${dest.confidence}; +${(diff.added || []).length} ~${(diff.replaced || []).length} -${(diff.deleted || []).length})`;
138
+ }).join('; ');
139
+ return new Error(
140
+ `Changed or legacy Sigma-looking trees need an explicit replace, skip, or export choice before mutation: ${details}`,
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Execute transactional single-skill project installation.
146
+ *
147
+ * @param {object} params
148
+ * @param {object} params.catalog
149
+ * @param {string} params.skillId
150
+ * @param {string} [params.projectRoot]
151
+ * @param {string} [params.customStateDir]
152
+ * @param {string} [params.packageRoot]
153
+ * @param {boolean} [params.dryRun]
154
+ * @returns {object} Execution summary with plan
155
+ */
156
+ export function executeProjectInstall(params) {
157
+ const {
158
+ catalog,
159
+ skillId,
160
+ customStateDir,
161
+ dryRun = false,
162
+ } = params;
163
+
164
+ const scope = params.scope || 'project';
165
+ const env = params.env || process.env;
166
+ const homeDir = path.resolve(params.homeDir || resolveHomeDir(env));
167
+ const projectRoot = path.resolve(params.projectRoot || process.cwd());
168
+ const root = scope === 'global' ? homeDir : projectRoot;
169
+ const packageRoot = params.packageRoot || findPackageRoot();
170
+
171
+ const plan = createInstallPlan(catalog, {
172
+ skillId,
173
+ projectRoot,
174
+ homeDir,
175
+ scope,
176
+ customStateDir,
177
+ packageRoot,
178
+ dryRun,
179
+ selectedRoots: params.selectedRoots,
180
+ destinationGroups: params.destinationGroups,
181
+ registry: params.registry,
182
+ env,
183
+ method: params.method,
184
+ copyRoots: params.copyRoots,
185
+ resolutions: params.resolutions,
186
+ adoptChanged: params.adoptChanged,
187
+ adoptLegacy: params.adoptLegacy,
188
+ adoptUnverified: params.adoptUnverified,
189
+ adoptMalformed: params.adoptMalformed,
190
+ exportDir: params.exportDir,
191
+ });
192
+
193
+ if (plan.unownedConflict) throw createUnownedConflictError(plan);
194
+
195
+ if (dryRun) {
196
+ return {
197
+ success: true,
198
+ dryRun: true,
199
+ plan,
200
+ };
201
+ }
202
+
203
+ if (plan.requiresApproval) throw createNeedsResolutionError(plan);
204
+
205
+ const releaseLock = acquireConcurrencyLock(root, customStateDir);
206
+
207
+ const stagingParent = path.join(root, '.agents', '.sigma-staging');
208
+ const stagingDir = path.join(
209
+ stagingParent,
210
+ `${skillId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
211
+ );
212
+
213
+ const committed = [];
214
+ const privateBackups = [];
215
+
216
+ const useProjectLock = scope !== 'global';
217
+ const lockPath = path.join(root, PROJECT_LOCK_FILENAME);
218
+ const lockExisted = useProjectLock && fs.existsSync(lockPath);
219
+ const originalLockBytes = lockExisted ? fs.readFileSync(lockPath) : null;
220
+ const originalLock = useProjectLock ? loadProjectLock(root) : { skills: {} };
221
+
222
+ const statePath = scope === 'global'
223
+ ? getGlobalStatePath(root, customStateDir)
224
+ : getProjectStatePath(root, customStateDir);
225
+ const stateExisted = fs.existsSync(statePath);
226
+ const originalStateBytes = stateExisted ? fs.readFileSync(statePath) : null;
227
+ const originalState = scope === 'global'
228
+ ? loadGlobalState(root, customStateDir)
229
+ : loadProjectState(root, customStateDir);
230
+ const persistState = params.saveState || (scope === 'global' ? saveGlobalState : saveProjectState);
231
+ const stateDirForBackups = scope === 'global'
232
+ ? getGlobalStateDir(root, customStateDir)
233
+ : getProjectStateDir(root, customStateDir);
234
+
235
+ const cleanupStaging = () => {
236
+ try {
237
+ if (fs.existsSync(stagingDir)) {
238
+ fs.rmSync(stagingDir, { recursive: true, force: true });
239
+ }
240
+ if (fs.existsSync(stagingParent) && fs.readdirSync(stagingParent).length === 0) {
241
+ fs.rmSync(stagingParent, { recursive: true, force: true });
242
+ }
243
+ } catch {
244
+ // Staging cleanup is best-effort
245
+ }
246
+ };
247
+
248
+ const rollback = () => {
249
+ try {
250
+ for (const entry of committed.splice(0).reverse()) {
251
+ if (entry.adopted) continue;
252
+ try {
253
+ if (entry.backupDir && pathExists(entry.backupDir)) {
254
+ if (pathExists(entry.destDir)) {
255
+ removeManagedPath(entry.destDir);
256
+ }
257
+ fs.renameSync(entry.backupDir, entry.destDir);
258
+ } else if (pathExists(entry.destDir)) {
259
+ removeManagedPath(entry.destDir);
260
+ }
261
+ } catch {
262
+ // Per-destination rollback is best-effort
263
+ }
264
+ }
265
+ for (const backupPath of privateBackups.splice(0)) {
266
+ try {
267
+ if (pathExists(backupPath)) removeManagedPath(backupPath);
268
+ } catch {
269
+ // Private backup cleanup is best-effort
270
+ }
271
+ }
272
+ // Restore state and lock or remove if they didn't exist before
273
+ if (stateExisted && originalStateBytes) {
274
+ fs.writeFileSync(statePath, originalStateBytes);
275
+ } else if (fs.existsSync(statePath)) {
276
+ fs.unlinkSync(statePath);
277
+ }
278
+
279
+ if (useProjectLock && lockExisted && originalLockBytes) {
280
+ fs.writeFileSync(lockPath, originalLockBytes);
281
+ } else if (useProjectLock && fs.existsSync(lockPath)) {
282
+ fs.unlinkSync(lockPath);
283
+ }
284
+ } catch {
285
+ // Rollback is best-effort
286
+ } finally {
287
+ cleanupStaging();
288
+ releaseLock();
289
+ }
290
+ };
291
+
292
+ // Register signal listeners during transaction
293
+ const signalHandler = () => {
294
+ rollback();
295
+ process.exit(130);
296
+ };
297
+ process.once('SIGINT', signalHandler);
298
+ process.once('SIGTERM', signalHandler);
299
+
300
+ try {
301
+ const willWrite = (dest) => (
302
+ !dest.adoption
303
+ && dest.resolution !== 'skip'
304
+ && dest.resolution !== 'export'
305
+ );
306
+ const allAdopted = plan.destinations.every((dest) => dest.adoption);
307
+ const needsSkillWrite = plan.destinations.some(willWrite);
308
+ const existingEntry = originalState.skills?.[skillId];
309
+ const existingDests = new Set(
310
+ (existingEntry?.copies || []).map((copy) => String(copy.destination || '').replace(/\\/g, '/')),
311
+ );
312
+ const plannedDests = plan.destinations.map((dest) => dest.relativeDestination);
313
+ const sameManagedLayout = existingEntry
314
+ && existingEntry.revision === plan.sourceRevision
315
+ && plannedDests.every((dest) => existingDests.has(dest))
316
+ && existingDests.size === plannedDests.length
317
+ && (scope === 'global' || originalLock.skills?.[skillId]?.revision === plan.sourceRevision);
318
+ if (allAdopted && sameManagedLayout) {
319
+ cleanupStaging();
320
+ releaseLock();
321
+ return {
322
+ success: true,
323
+ dryRun: false,
324
+ plan,
325
+ lock: originalLock,
326
+ state: originalState,
327
+ };
328
+ }
329
+
330
+ const catalogSkill = catalog.skills.find((item) => item.id === skillId);
331
+ let fileHashes = catalogSkill?.files || {};
332
+
333
+ if (needsSkillWrite) {
334
+ fs.mkdirSync(stagingDir, { recursive: true });
335
+ const sourceSkillDir = path.join(packageRoot, skillId);
336
+ if (!fs.existsSync(sourceSkillDir)) {
337
+ throw new Error(`source skill directory missing at ${sourceSkillDir}`);
338
+ }
339
+
340
+ fs.cpSync(sourceSkillDir, stagingDir, { recursive: true });
341
+
342
+ const manifestMetadata = catalog.manifest.skills.find((s) => s.id === skillId);
343
+ const validatedStaged = validateSkill(stagingDir, manifestMetadata);
344
+ if (validatedStaged.revision !== plan.sourceRevision) {
345
+ throw new Error(
346
+ `staged skill revision '${validatedStaged.revision}' does not match catalog revision '${plan.sourceRevision}'`,
347
+ );
348
+ }
349
+ fileHashes = validatedStaged.files;
350
+ if (params.preservedCustomRaw !== undefined) {
351
+ const stagedSkillMd = path.join(stagingDir, 'SKILL.md');
352
+ if (pathExists(stagedSkillMd)) {
353
+ const stagedMarkdown = fs.readFileSync(stagedSkillMd, 'utf8');
354
+ fs.writeFileSync(
355
+ stagedSkillMd,
356
+ injectRawCustomContent(stagedMarkdown, params.preservedCustomRaw, skillId),
357
+ 'utf8',
358
+ );
359
+ }
360
+ }
361
+ }
362
+
363
+ const createLink = params.createLink || ((linkPath, targetPath) => (
364
+ createSkillLink(linkPath, targetPath, root)
365
+ ));
366
+ const canonicalDest = plan.destinations.find((dest) => dest.kind === 'canonical')
367
+ || plan.destinations[0];
368
+
369
+ const restoreBackup = (destDir, backupDir) => {
370
+ if (backupDir && pathExists(backupDir)) {
371
+ if (pathExists(destDir)) removeManagedPath(destDir);
372
+ fs.renameSync(backupDir, destDir);
373
+ } else if (pathExists(destDir)) {
374
+ removeManagedPath(destDir);
375
+ }
376
+ };
377
+
378
+ const prepareDestination = (destDir) => {
379
+ const destParent = path.dirname(destDir);
380
+ if (!fs.existsSync(destParent)) {
381
+ fs.mkdirSync(destParent, { recursive: true });
382
+ }
383
+ let backupDir = null;
384
+ if (pathExists(destDir)) {
385
+ backupDir = path.join(
386
+ destParent,
387
+ `.${skillId}-backup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
388
+ );
389
+ fs.renameSync(destDir, backupDir);
390
+ }
391
+ return backupDir;
392
+ };
393
+
394
+ const commitCopy = (destDir) => {
395
+ const backupDir = prepareDestination(destDir);
396
+ try {
397
+ fs.cpSync(stagingDir, destDir, { recursive: true });
398
+ committed.push({ destDir, backupDir });
399
+ } catch (copyErr) {
400
+ restoreBackup(destDir, backupDir);
401
+ throw new Error(`failed to write destination '${destDir}': ${copyErr.message}`);
402
+ }
403
+ };
404
+
405
+ const commitLink = (dest) => {
406
+ const destDir = dest.destination;
407
+ const backupDir = prepareDestination(destDir);
408
+ try {
409
+ createLink(destDir, canonicalDest.destination, root);
410
+ committed.push({ destDir, backupDir });
411
+ } catch (linkErr) {
412
+ restoreBackup(destDir, backupDir);
413
+ const failure = {
414
+ destination: dest.destination,
415
+ relativeDestination: dest.relativeDestination,
416
+ relativeRoot: dest.relativeRoot,
417
+ method: dest.method,
418
+ cause: linkErr.message,
419
+ code: linkErr.code,
420
+ };
421
+ const decision = typeof params.onLinkFailure === 'function'
422
+ ? params.onLinkFailure(failure)
423
+ : 'abort';
424
+ if (decision !== 'copy') {
425
+ const wrapped = new Error(linkErr.message);
426
+ wrapped.cause = linkErr;
427
+ wrapped.code = linkErr.code;
428
+ wrapped.linkFailure = failure;
429
+ throw wrapped;
430
+ }
431
+ dest.fallbackFrom = dest.method;
432
+ dest.method = 'copy';
433
+ dest.dependsOn = null;
434
+ commitCopy(destDir);
435
+ }
436
+ };
437
+
438
+ const ordered = [...plan.destinations].sort((a, b) => {
439
+ if (a.kind === 'canonical' && b.kind !== 'canonical') return -1;
440
+ if (b.kind === 'canonical' && a.kind !== 'canonical') return 1;
441
+ return 0;
442
+ });
443
+
444
+ for (const dest of ordered) {
445
+ if (dest.adoption) {
446
+ committed.push({ destDir: dest.destination, backupDir: null, adopted: true });
447
+ continue;
448
+ }
449
+ if (dest.resolution === 'skip') {
450
+ dest.exportPath = null;
451
+ continue;
452
+ }
453
+ if (dest.resolution === 'export') {
454
+ const exportRoot = plan.exportDir || path.join(root, '.sigma-export');
455
+ const exporter = params.exportSkill || exportSkillTree;
456
+ dest.exportPath = exporter({
457
+ sourceDir: dest.destination,
458
+ exportRoot,
459
+ skillId,
460
+ dest: dest.exportPath,
461
+ });
462
+ continue;
463
+ }
464
+ if (dest.resolution === 'replace' && pathExists(dest.destination)) {
465
+ const backupFn = params.backupSkill || commitSkillBackup;
466
+ const existing = originalState.skills?.[skillId];
467
+ const privateBackup = backupFn({
468
+ stateDir: stateDirForBackups,
469
+ skillId,
470
+ sourceDir: dest.destination,
471
+ ownership: {
472
+ scope,
473
+ release: existing?.release || plan.release || null,
474
+ revision: existing?.revision || null,
475
+ method: existing?.method || dest.method,
476
+ canonicalTarget: existing?.destination || dest.relativeDestination,
477
+ copies: existing?.copies || [],
478
+ ownedPaths: existing?.ownedPaths || [dest.relativeDestination],
479
+ },
480
+ });
481
+ privateBackups.push(privateBackup);
482
+ dest.privateBackup = privateBackup;
483
+ if (typeof params.afterBackup === 'function') {
484
+ params.afterBackup(privateBackup);
485
+ }
486
+ }
487
+ if (dest.method === 'copy') commitCopy(dest.destination);
488
+ else commitLink(dest);
489
+ const customStatus = dest.customization?.status;
490
+ if (
491
+ params.preservedCustomRaw === undefined
492
+ && dest.resolution === 'replace'
493
+ && (customStatus === 'valid' || customStatus === 'empty')
494
+ ) {
495
+ const skillMd = path.join(dest.destination, 'SKILL.md');
496
+ if (pathExists(skillMd)) {
497
+ const current = fs.readFileSync(skillMd, 'utf8');
498
+ fs.writeFileSync(
499
+ skillMd,
500
+ injectCustomContent(current, dest.customization.customContent || '', skillId),
501
+ 'utf8',
502
+ );
503
+ }
504
+ }
505
+ }
506
+
507
+ const copies = plan.destinations
508
+ .filter((dest) => dest.adoption || dest.resolution === 'replace' || (!dest.migratable && dest.resolution !== 'skip' && dest.resolution !== 'export'))
509
+ .map((dest) => {
510
+ const independent = dest.method === 'copy';
511
+ return {
512
+ kind: dest.kind,
513
+ destination: dest.relativeDestination,
514
+ method: dest.method,
515
+ dependsOn: dest.dependsOn || null,
516
+ hostIds: (dest.hosts || []).map((host) => host.id),
517
+ ownedPaths: independent
518
+ ? plan.files.map((file) => `${dest.relativeDestination}/${file}`)
519
+ : [dest.relativeDestination],
520
+ ...(independent ? { baseHashes: dest.baseHashes && dest.resolution !== 'replace' ? dest.baseHashes : fileHashes } : {}),
521
+ };
522
+ });
523
+ if (copies.length === 0) {
524
+ cleanupStaging();
525
+ releaseLock();
526
+ return {
527
+ success: true,
528
+ dryRun: false,
529
+ plan,
530
+ lock: originalLock,
531
+ state: originalState,
532
+ };
533
+ }
534
+ const primary = copies.find((copy) => copy.kind === 'canonical') || copies[0];
535
+
536
+ const lastBackup = privateBackups.length > 0
537
+ ? path.relative(stateDirForBackups, privateBackups[privateBackups.length - 1]).replace(/\\/g, '/')
538
+ : undefined;
539
+ const updatedState = recordSkillInState(originalState, {
540
+ skillId,
541
+ release: plan.release,
542
+ revision: plan.sourceRevision,
543
+ method: plan.method,
544
+ destination: primary.destination,
545
+ projectRoot: root,
546
+ ownedPaths: primary.ownedPaths,
547
+ baseHashes: primary.baseHashes || fileHashes,
548
+ copies,
549
+ scope,
550
+ lastBackup,
551
+ cleanupDebt: [],
552
+ });
553
+ persistState(root, updatedState, customStateDir);
554
+
555
+ let updatedLock = originalLock;
556
+ if (useProjectLock) {
557
+ updatedLock = updateProjectLockSkill(
558
+ originalLock,
559
+ skillId,
560
+ plan.sourceRevision,
561
+ plan.release,
562
+ );
563
+ saveProjectLock(root, updatedLock);
564
+ }
565
+
566
+ // 5. Cleanup backups after state & lock write succeed
567
+ for (const entry of committed) {
568
+ if (entry.backupDir && pathExists(entry.backupDir)) {
569
+ removeManagedPath(entry.backupDir);
570
+ entry.backupDir = null;
571
+ }
572
+ }
573
+ for (const backupPath of privateBackups) {
574
+ let debt = [];
575
+ try {
576
+ const pruneFn = params.pruneBackups || pruneOlderBackups;
577
+ const result = pruneFn({
578
+ stateDir: stateDirForBackups,
579
+ skillId,
580
+ keepPath: backupPath,
581
+ });
582
+ if (result && Array.isArray(result.debt)) debt = result.debt;
583
+ } catch {
584
+ const dir = path.join(getBackupRoot(stateDirForBackups), skillId);
585
+ if (pathExists(dir)) {
586
+ const keep = path.resolve(backupPath);
587
+ for (const name of fs.readdirSync(dir)) {
588
+ const full = path.resolve(dir, name);
589
+ if (full !== keep) {
590
+ debt.push(path.relative(stateDirForBackups, full).replace(/\\/g, '/'));
591
+ }
592
+ }
593
+ }
594
+ }
595
+ if (debt.length > 0) {
596
+ try {
597
+ persistState(root, recordSkillInState(updatedState, {
598
+ skillId,
599
+ release: plan.release,
600
+ revision: plan.sourceRevision,
601
+ method: plan.method,
602
+ destination: primary.destination,
603
+ projectRoot: root,
604
+ ownedPaths: primary.ownedPaths,
605
+ baseHashes: primary.baseHashes || fileHashes,
606
+ copies,
607
+ scope,
608
+ lastBackup,
609
+ cleanupDebt: debt,
610
+ }), customStateDir);
611
+ } catch {
612
+ // Two backups remain; recording debt is best-effort.
613
+ }
614
+ }
615
+ }
616
+
617
+ cleanupStaging();
618
+ releaseLock();
619
+
620
+ return {
621
+ success: true,
622
+ dryRun: false,
623
+ plan,
624
+ lock: updatedLock,
625
+ state: updatedState,
626
+ };
627
+ } catch (err) {
628
+ rollback();
629
+ throw err;
630
+ } finally {
631
+ process.removeListener('SIGINT', signalHandler);
632
+ process.removeListener('SIGTERM', signalHandler);
633
+ cleanupStaging();
634
+ releaseLock();
635
+ }
636
+ }