@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,169 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const PROJECT_LOCK_FILENAME = 'skills-lock.json';
5
+ export const PROJECT_LOCK_SCHEMA_VERSION = 1;
6
+
7
+ function emptyProjectLock() {
8
+ return {
9
+ schemaVersion: PROJECT_LOCK_SCHEMA_VERSION,
10
+ release: null,
11
+ skills: {},
12
+ };
13
+ }
14
+
15
+ function isGenericLock(parsed) {
16
+ return !parsed || typeof parsed !== 'object' || typeof parsed.schemaVersion !== 'number';
17
+ }
18
+
19
+ /**
20
+ * Inspect a project lockfile without treating generic CLI locks as Sigma ownership.
21
+ *
22
+ * @param {string} projectRoot
23
+ * @returns {{ kind: 'missing' | 'sigma' | 'generic', lock: object }}
24
+ */
25
+ export function inspectProjectLock(projectRoot) {
26
+ const lockPath = path.join(projectRoot, PROJECT_LOCK_FILENAME);
27
+ if (!fs.existsSync(lockPath)) {
28
+ return { kind: 'missing', lock: emptyProjectLock() };
29
+ }
30
+
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
34
+ } catch (err) {
35
+ throw new Error(`failed to read project lockfile at ${lockPath}: ${err.message}`);
36
+ }
37
+
38
+ if (isGenericLock(parsed)) {
39
+ return { kind: 'generic', lock: emptyProjectLock() };
40
+ }
41
+
42
+ validateProjectLock(parsed);
43
+ return { kind: 'sigma', lock: parsed };
44
+ }
45
+
46
+ /**
47
+ * Load the Sigma project lock. Generic `skills-lock.json` files yield an empty Sigma lock.
48
+ *
49
+ * @param {string} projectRoot
50
+ * @returns {object}
51
+ */
52
+ export function loadProjectLock(projectRoot) {
53
+ return inspectProjectLock(projectRoot).lock;
54
+ }
55
+
56
+ /**
57
+ * Validate that a project lock structure complies with repo contracts:
58
+ * - schemaVersion is a positive integer
59
+ * - skills is an object
60
+ * - each skill entry contains only revision (no machine paths, no timestamps)
61
+ *
62
+ * @param {object} lock
63
+ */
64
+ export function validateProjectLock(lock) {
65
+ if (!lock || typeof lock !== 'object') {
66
+ throw new Error('invalid project lock: expected JSON object');
67
+ }
68
+ if (typeof lock.schemaVersion !== 'number' || lock.schemaVersion < 1) {
69
+ throw new Error('invalid project lock: missing or invalid schemaVersion');
70
+ }
71
+ if (lock.release !== null && typeof lock.release !== 'string') {
72
+ throw new Error('invalid project lock: release must be string or null');
73
+ }
74
+ if (!lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
75
+ throw new Error('invalid project lock: skills must be an object');
76
+ }
77
+
78
+ for (const [skillId, skillData] of Object.entries(lock.skills)) {
79
+ if (!skillData || typeof skillData !== 'object') {
80
+ throw new Error(`invalid project lock skill entry for '${skillId}'`);
81
+ }
82
+ if (!skillData.revision || typeof skillData.revision !== 'string') {
83
+ throw new Error(`invalid project lock skill '${skillId}': missing revision string`);
84
+ }
85
+ // Prohibit machine paths or timestamps in lockfile
86
+ if ('path' in skillData || 'installedAt' in skillData || 'updatedAt' in skillData || 'destination' in skillData) {
87
+ throw new Error(`invalid project lock skill '${skillId}': lock must not contain paths or timestamps`);
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Serialize and atomically write project lockfile (skills-lock.json) with sorted keys.
94
+ *
95
+ * @param {string} projectRoot
96
+ * @param {object} lock
97
+ */
98
+ export function saveProjectLock(projectRoot, lock) {
99
+ validateProjectLock(lock);
100
+
101
+ // Sort skill keys alphabetically for deterministic git-merge friendly format
102
+ const sortedSkills = {};
103
+ const skillKeys = Object.keys(lock.skills || {}).sort();
104
+ for (const key of skillKeys) {
105
+ sortedSkills[key] = {
106
+ revision: lock.skills[key].revision,
107
+ };
108
+ }
109
+
110
+ const cleanLock = {
111
+ schemaVersion: lock.schemaVersion || PROJECT_LOCK_SCHEMA_VERSION,
112
+ release: lock.release || null,
113
+ skills: sortedSkills,
114
+ };
115
+
116
+ const content = JSON.stringify(cleanLock, null, 2) + '\n';
117
+ const lockPath = path.join(projectRoot, PROJECT_LOCK_FILENAME);
118
+ const tempPath = path.join(projectRoot, `${PROJECT_LOCK_FILENAME}.tmp.${process.pid}.${Date.now()}`);
119
+
120
+ fs.writeFileSync(tempPath, content, 'utf8');
121
+ try {
122
+ fs.renameSync(tempPath, lockPath);
123
+ } catch (err) {
124
+ try {
125
+ fs.copyFileSync(tempPath, lockPath);
126
+ fs.unlinkSync(tempPath);
127
+ } catch {
128
+ throw err;
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Update or add a skill in the project lock.
135
+ *
136
+ * @param {object} currentLock
137
+ * @param {string} skillId
138
+ * @param {string} revision
139
+ * @param {string} release
140
+ * @returns {object} Updated lock object
141
+ */
142
+ export function updateProjectLockSkill(currentLock, skillId, revision, release) {
143
+ const nextSkills = { ...(currentLock.skills || {}) };
144
+ nextSkills[skillId] = { revision };
145
+
146
+ return {
147
+ schemaVersion: currentLock.schemaVersion || PROJECT_LOCK_SCHEMA_VERSION,
148
+ release: release || currentLock.release,
149
+ skills: nextSkills,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Drop a skill from the project lock after a successful uninstall.
155
+ *
156
+ * @param {object} currentLock
157
+ * @param {string} skillId
158
+ * @returns {object}
159
+ */
160
+ export function removeProjectLockSkill(currentLock, skillId) {
161
+ const nextSkills = { ...(currentLock.skills || {}) };
162
+ delete nextSkills[skillId];
163
+ const remaining = Object.keys(nextSkills);
164
+ return {
165
+ schemaVersion: currentLock.schemaVersion || PROJECT_LOCK_SCHEMA_VERSION,
166
+ release: remaining.length > 0 ? (currentLock.release || null) : null,
167
+ skills: nextSkills,
168
+ };
169
+ }
package/src/purge.js ADDED
@@ -0,0 +1,477 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copySkillTree, getBackupRoot } from './backup.js';
4
+ import { resolveHomeDir } from './destinations.js';
5
+ import { createSkillLink, inspectManagedPath, pathExists, removeManagedPath } from './links.js';
6
+ import { inspectProjectLock, PROJECT_LOCK_FILENAME } from './project-lock.js';
7
+ import {
8
+ STATE_FILENAME,
9
+ getGlobalStateDir,
10
+ getProjectStateDir,
11
+ } from './state.js';
12
+ import { acquireConcurrencyLock } from './transaction.js';
13
+ import { createUninstallPlan, UNINSTALL_JOURNAL_FILENAME } from './uninstall.js';
14
+
15
+ export const PURGE_SCHEMA_VERSION = 1;
16
+ export const PURGE_JOURNAL_FILENAME = 'purge-journal.json';
17
+ export const PURGE_QUARANTINE_DIRNAME = '.sigma-purge-quarantine';
18
+ export const PURGE_CONFIRMATION_PHRASE = 'purge SigmaSkills';
19
+
20
+ const PRIVATE_STAGING = [
21
+ '.sigma-uninstall-staging',
22
+ '.sigma-restore-staging',
23
+ '.sigma-staging',
24
+ ];
25
+ const HARD_STOP = new Set(['unowned', 'wrong-target', 'broken-link', 'copy-disagreement', 'stale-state']);
26
+
27
+ function codedError(message, code) {
28
+ const err = new Error(message);
29
+ err.code = code;
30
+ return err;
31
+ }
32
+
33
+ function resolveRoot(options) {
34
+ const scope = options.scope || 'project';
35
+ if (scope === 'global') return path.resolve(options.homeDir || resolveHomeDir(options.env || process.env));
36
+ return path.resolve(options.projectRoot || process.cwd());
37
+ }
38
+
39
+ function resolveStateDir(scope, root, customStateDir) {
40
+ return scope === 'global'
41
+ ? getGlobalStateDir(root, customStateDir)
42
+ : getProjectStateDir(root, customStateDir);
43
+ }
44
+
45
+ function posix(value) {
46
+ return String(value || '').replace(/\\/g, '/');
47
+ }
48
+
49
+ function relativeToRoot(root, absolutePath) {
50
+ const rel = path.relative(root, absolutePath);
51
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
52
+ return posix(path.basename(absolutePath));
53
+ }
54
+ return posix(rel);
55
+ }
56
+
57
+ function deletionOrder(destinations) {
58
+ const links = destinations.filter((dest) => dest.method && dest.method !== 'copy');
59
+ const hostCopies = destinations.filter((dest) => dest.method === 'copy' && dest.kind !== 'canonical');
60
+ const canonical = destinations.filter((dest) => dest.kind === 'canonical');
61
+ const rest = destinations.filter((dest) => !links.includes(dest) && !hostCopies.includes(dest) && !canonical.includes(dest));
62
+ return [...links, ...hostCopies, ...rest, ...canonical];
63
+ }
64
+
65
+ function readJsonIfPresent(file) {
66
+ if (!pathExists(file)) return null;
67
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
68
+ }
69
+
70
+ function persistJson(file, payload) {
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ const tmp = `${file}.${process.pid}.tmp`;
73
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
74
+ fs.renameSync(tmp, file);
75
+ }
76
+
77
+ function relocate(source, dest) {
78
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
79
+ try {
80
+ fs.renameSync(source, dest);
81
+ return;
82
+ } catch (err) {
83
+ if (err.code !== 'EXDEV') throw err;
84
+ }
85
+ if (!pathExists(source)) return;
86
+ const stat = fs.lstatSync(source);
87
+ if (stat.isSymbolicLink()) {
88
+ fs.symlinkSync(fs.readlinkSync(source), dest, process.platform === 'win32' ? 'junction' : 'dir');
89
+ fs.unlinkSync(source);
90
+ return;
91
+ }
92
+ if (stat.isFile()) {
93
+ fs.copyFileSync(source, dest);
94
+ fs.unlinkSync(source);
95
+ return;
96
+ }
97
+ copySkillTree(source, dest);
98
+ removeManagedPath(source);
99
+ }
100
+
101
+ function restoreQuarantined(root, items) {
102
+ for (const item of items.slice().reverse()) {
103
+ if (item.status !== 'quarantined' || !item.quarantinePath || !pathExists(item.quarantinePath)) continue;
104
+ try {
105
+ if (item.kind === 'link') {
106
+ if (!pathExists(item.absolutePath) && item.target && pathExists(item.target)) {
107
+ createSkillLink(item.absolutePath, item.target, root);
108
+ removeManagedPath(item.quarantinePath);
109
+ } else if (!pathExists(item.absolutePath)) {
110
+ relocate(item.quarantinePath, item.absolutePath);
111
+ }
112
+ } else if (!pathExists(item.absolutePath)) {
113
+ relocate(item.quarantinePath, item.absolutePath);
114
+ }
115
+ item.status = 'pending';
116
+ item.quarantinePath = null;
117
+ } catch {
118
+ // Continue restoring remaining leaves.
119
+ }
120
+ }
121
+ }
122
+
123
+ function collectPrivateItems(root, stateDir) {
124
+ const items = [];
125
+ const backupRoot = getBackupRoot(stateDir);
126
+ if (pathExists(backupRoot)) {
127
+ items.push({
128
+ kind: 'backups',
129
+ relative: relativeToRoot(root, backupRoot),
130
+ absolutePath: backupRoot,
131
+ status: 'pending',
132
+ });
133
+ }
134
+ for (const name of PRIVATE_STAGING) {
135
+ const absolutePath = path.join(stateDir, name);
136
+ if (pathExists(absolutePath)) {
137
+ items.push({ kind: 'staging', relative: name, absolutePath, status: 'pending' });
138
+ }
139
+ }
140
+ for (const name of [UNINSTALL_JOURNAL_FILENAME, PURGE_JOURNAL_FILENAME]) {
141
+ const absolutePath = path.join(stateDir, name);
142
+ if (pathExists(absolutePath)) {
143
+ items.push({ kind: 'journal', relative: name, absolutePath, status: 'pending' });
144
+ }
145
+ }
146
+ const lockPath = path.join(stateDir, '.sigma.lock');
147
+ if (pathExists(lockPath)) {
148
+ items.push({ kind: 'lock', relative: '.sigma.lock', absolutePath: lockPath, status: 'pending' });
149
+ }
150
+ const manifestPath = path.join(stateDir, STATE_FILENAME);
151
+ if (pathExists(manifestPath)) {
152
+ items.push({
153
+ kind: 'manifest',
154
+ relative: relativeToRoot(root, manifestPath),
155
+ absolutePath: manifestPath,
156
+ status: 'pending',
157
+ });
158
+ }
159
+ return items;
160
+ }
161
+
162
+ function findResumeJournal(stateDir) {
163
+ const primary = path.join(stateDir, PURGE_JOURNAL_FILENAME);
164
+ const nested = path.join(stateDir, PURGE_QUARANTINE_DIRNAME, PURGE_JOURNAL_FILENAME);
165
+ for (const file of [primary, nested]) {
166
+ const journal = readJsonIfPresent(file);
167
+ if (journal && journal.command === 'purge' && journal.status && journal.status !== 'complete') {
168
+ return { journal, file };
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Preview every revalidated Sigma-owned path in one Project or Global scope.
176
+ *
177
+ * @param {object} options
178
+ * @returns {object}
179
+ */
180
+ export function createPurgePlan(options = {}) {
181
+ const scope = options.scope || 'project';
182
+ const root = resolveRoot(options);
183
+ const stateDir = resolveStateDir(scope, root, options.customStateDir);
184
+ const resume = findResumeJournal(stateDir);
185
+ if (resume?.journal?.items && (resume.journal.status === 'quarantined' || resume.journal.status === 'cleanup')) {
186
+ return {
187
+ schemaVersion: PURGE_SCHEMA_VERSION,
188
+ command: 'purge',
189
+ scope,
190
+ dryRun: Boolean(options.dryRun),
191
+ confirmationPhrase: PURGE_CONFIRMATION_PHRASE,
192
+ resumed: true,
193
+ blocked: [],
194
+ items: resume.journal.items,
195
+ skills: resume.journal.skills || [],
196
+ quarantineDir: resume.journal.quarantineDir,
197
+ };
198
+ }
199
+
200
+ const uninstallPlan = createUninstallPlan({
201
+ ...options,
202
+ all: true,
203
+ yes: true,
204
+ });
205
+ const blocked = [];
206
+ const destinationItems = [];
207
+ for (const skill of uninstallPlan.skills) {
208
+ const reasons = (skill.blockedReasons || []).filter((reason) => HARD_STOP.has(reason));
209
+ if (reasons.length > 0) {
210
+ blocked.push({ id: skill.id, reasons });
211
+ }
212
+ for (const dest of deletionOrder(skill.destinations || [])) {
213
+ const kind = dest.method && dest.method !== 'copy' ? 'link' : 'tree';
214
+ destinationItems.push({
215
+ kind,
216
+ skillId: skill.id,
217
+ relative: dest.relativeDestination,
218
+ absolutePath: dest.absolutePath,
219
+ method: dest.method || null,
220
+ role: dest.kind || null,
221
+ dependsOn: dest.dependsOn || null,
222
+ missing: Boolean(dest.missing),
223
+ target: dest.method && dest.method !== 'copy' ? skill.canonicalAbs : null,
224
+ status: dest.missing ? 'absent' : 'pending',
225
+ });
226
+ }
227
+ }
228
+
229
+ const items = [
230
+ ...destinationItems,
231
+ ...collectPrivateItems(root, stateDir),
232
+ ];
233
+ if (scope === 'project') {
234
+ const lockInspect = inspectProjectLock(root);
235
+ if (lockInspect.kind === 'sigma') {
236
+ items.push({
237
+ kind: 'project-lock',
238
+ relative: PROJECT_LOCK_FILENAME,
239
+ absolutePath: path.join(root, PROJECT_LOCK_FILENAME),
240
+ status: 'pending',
241
+ });
242
+ }
243
+ }
244
+
245
+ return {
246
+ schemaVersion: PURGE_SCHEMA_VERSION,
247
+ command: 'purge',
248
+ scope,
249
+ dryRun: Boolean(options.dryRun),
250
+ confirmationPhrase: PURGE_CONFIRMATION_PHRASE,
251
+ resumed: false,
252
+ blocked,
253
+ items,
254
+ skills: (uninstallPlan.skills || []).map((skill) => ({
255
+ id: skill.id,
256
+ destinations: skill.destinations,
257
+ blockedReasons: skill.blockedReasons,
258
+ })),
259
+ };
260
+ }
261
+
262
+ function assertConfirmed(options) {
263
+ const phrase = options.confirmPurge;
264
+ if (phrase === undefined || phrase === null) {
265
+ throw codedError(
266
+ 'purge requires --confirm-purge with the typed confirmation phrase; --yes, CI, non-TTY, and JSON are not authority',
267
+ 'confirmation',
268
+ );
269
+ }
270
+ if (String(phrase).trim() === '') {
271
+ throw codedError('purge cancelled', 'cancelled');
272
+ }
273
+ if (String(phrase) !== PURGE_CONFIRMATION_PHRASE) {
274
+ throw codedError(
275
+ `purge confirmation phrase is wrong; type exactly: ${PURGE_CONFIRMATION_PHRASE}`,
276
+ 'confirmation',
277
+ );
278
+ }
279
+ }
280
+
281
+ function revalidateDestinations(plan) {
282
+ for (const item of plan.items) {
283
+ if (item.kind !== 'link' && item.kind !== 'tree') continue;
284
+ if (!pathExists(item.absolutePath)) {
285
+ item.missing = true;
286
+ item.status = item.status === 'quarantined' ? item.status : 'absent';
287
+ continue;
288
+ }
289
+ const expected = item.kind === 'link' ? item.target : null;
290
+ const inspect = inspectManagedPath(item.absolutePath, expected);
291
+ const recordedLink = item.kind === 'link';
292
+ const liveLink = inspect.method && inspect.method !== 'copy';
293
+ if (inspect.wrongTarget || Boolean(recordedLink) !== Boolean(liveLink)) {
294
+ throw codedError(
295
+ `unowned occupant stopped purge at '${item.relative}'`,
296
+ inspect.wrongTarget ? 'wrong-target' : 'unowned',
297
+ );
298
+ }
299
+ if (inspect.broken) {
300
+ throw codedError(`broken-link stopped purge at '${item.relative}'`, 'broken-link');
301
+ }
302
+ }
303
+ }
304
+
305
+ function journalPayload(plan, extra) {
306
+ return {
307
+ schemaVersion: PURGE_SCHEMA_VERSION,
308
+ command: 'purge',
309
+ scope: plan.scope,
310
+ confirmationPhrase: PURGE_CONFIRMATION_PHRASE,
311
+ ...extra,
312
+ items: plan.items,
313
+ skills: plan.skills,
314
+ };
315
+ }
316
+
317
+ function finishCleanup(stateDir, plan) {
318
+ const quarantineDir = path.join(stateDir, PURGE_QUARANTINE_DIRNAME);
319
+ for (const item of plan.items) {
320
+ if (item.kind === 'link' || item.kind === 'tree' || item.kind === 'backups') continue;
321
+ if (item.kind === 'lock') continue;
322
+ if (item.status === 'absent') continue;
323
+ if (pathExists(item.absolutePath)) {
324
+ removeManagedPath(item.absolutePath);
325
+ item.status = 'removed';
326
+ }
327
+ }
328
+ if (pathExists(quarantineDir)) {
329
+ fs.rmSync(quarantineDir, { recursive: true, force: true });
330
+ }
331
+ const leftoverJournal = path.join(stateDir, PURGE_JOURNAL_FILENAME);
332
+ if (pathExists(leftoverJournal)) fs.rmSync(leftoverJournal, { force: true });
333
+ const lockPath = path.join(stateDir, '.sigma.lock');
334
+ if (pathExists(lockPath)) {
335
+ try {
336
+ const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
337
+ if (!existing.pid || existing.pid === process.pid) fs.rmSync(lockPath, { force: true });
338
+ } catch {
339
+ // Lock removal is best-effort after the ownership plan is complete.
340
+ }
341
+ }
342
+ for (const name of [...PRIVATE_STAGING, 'backups']) {
343
+ const target = path.join(stateDir, name);
344
+ if (pathExists(target)) fs.rmSync(target, { recursive: true, force: true });
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Remove every revalidated Sigma-owned artifact in one chosen scope.
350
+ *
351
+ * @param {object} options
352
+ * @returns {object}
353
+ */
354
+ export function executePurge(options = {}) {
355
+ const plan = createPurgePlan(options);
356
+ if (options.dryRun) {
357
+ return { ...plan, dryRun: true };
358
+ }
359
+ assertConfirmed(options);
360
+ if ((plan.blocked || []).length > 0) {
361
+ const first = plan.blocked[0];
362
+ throw codedError(
363
+ `${(first.reasons || []).join(', ') || 'unowned'} stopped purge of '${first.id}'`,
364
+ first.reasons?.[0] || 'unowned',
365
+ );
366
+ }
367
+
368
+ const scope = options.scope || 'project';
369
+ const root = resolveRoot(options);
370
+ const customStateDir = options.customStateDir;
371
+ const stateDir = resolveStateDir(scope, root, customStateDir);
372
+ const releaseLock = acquireConcurrencyLock(root, customStateDir);
373
+ const quarantineDir = path.join(stateDir, PURGE_QUARANTINE_DIRNAME);
374
+ const journalPath = path.join(stateDir, PURGE_JOURNAL_FILENAME);
375
+ const writeJournal = (status) => {
376
+ const payload = journalPayload(plan, { status, quarantineDir: posix(relativeToRoot(root, quarantineDir)) });
377
+ persistJson(journalPath, payload);
378
+ if (pathExists(quarantineDir)) {
379
+ persistJson(path.join(quarantineDir, PURGE_JOURNAL_FILENAME), payload);
380
+ }
381
+ };
382
+
383
+ try {
384
+ if (plan.resumed) {
385
+ writeJournal('cleanup');
386
+ finishCleanup(stateDir, plan);
387
+ return { ...plan, dryRun: false, resumed: true };
388
+ }
389
+
390
+ revalidateDestinations(plan);
391
+ fs.mkdirSync(quarantineDir, { recursive: true });
392
+ writeJournal('in-progress');
393
+ let phase = 'quarantine';
394
+
395
+ try {
396
+ let index = 0;
397
+ for (const item of plan.items) {
398
+ if (item.kind !== 'link' && item.kind !== 'tree' && item.kind !== 'backups') continue;
399
+ if (item.status === 'absent' || item.missing || !pathExists(item.absolutePath)) {
400
+ item.status = 'absent';
401
+ continue;
402
+ }
403
+ const slot = path.join(quarantineDir, `${String(index).padStart(3, '0')}-${item.kind}-${(item.skillId || item.kind)}`);
404
+ index += 1;
405
+ relocate(item.absolutePath, slot);
406
+ item.quarantinePath = slot;
407
+ item.status = 'quarantined';
408
+ writeJournal('in-progress');
409
+ if (typeof options.afterQuarantineItem === 'function') options.afterQuarantineItem(item);
410
+ }
411
+ writeJournal('quarantined');
412
+ persistJson(path.join(quarantineDir, PURGE_JOURNAL_FILENAME), journalPayload(plan, {
413
+ status: 'quarantined',
414
+ quarantineDir: posix(relativeToRoot(root, quarantineDir)),
415
+ }));
416
+ phase = 'cleanup';
417
+ if (typeof options.afterQuarantine === 'function') options.afterQuarantine(quarantineDir);
418
+ } catch (err) {
419
+ if (phase === 'quarantine') {
420
+ restoreQuarantined(root, plan.items);
421
+ writeJournal('failed');
422
+ try {
423
+ const leftover = pathExists(quarantineDir)
424
+ ? fs.readdirSync(quarantineDir).filter((name) => name !== PURGE_JOURNAL_FILENAME)
425
+ : [];
426
+ if (leftover.length === 0 && pathExists(quarantineDir)) {
427
+ fs.rmSync(quarantineDir, { recursive: true, force: true });
428
+ }
429
+ } catch {
430
+ // Keep a failed journal when quarantine cannot be removed.
431
+ }
432
+ }
433
+ throw err;
434
+ }
435
+
436
+ writeJournal('cleanup');
437
+ finishCleanup(stateDir, plan);
438
+ return { ...plan, dryRun: false, resumed: false };
439
+ } finally {
440
+ releaseLock();
441
+ }
442
+ }
443
+
444
+ /**
445
+ * @param {object} result
446
+ * @returns {string}
447
+ */
448
+ export function formatPurgeJson(result) {
449
+ return `${JSON.stringify(result, null, 2)}\n`;
450
+ }
451
+
452
+ /**
453
+ * @param {object} result
454
+ * @returns {string}
455
+ */
456
+ export function formatPurgeHuman(result) {
457
+ const lines = [
458
+ `SigmaSkills Purge ${result.dryRun ? 'Preview' : 'Result'}`,
459
+ `Scope: ${result.scope}`,
460
+ `Confirmation phrase: ${result.confirmationPhrase}`,
461
+ '',
462
+ ];
463
+ if (result.resumed) lines.push('Mode: resume interrupted purge', '');
464
+ lines.push('Ownership plan:');
465
+ for (const item of result.items || []) {
466
+ lines.push(` ${item.relative} [${item.kind}${item.method ? `/${item.method}` : ''}]`);
467
+ }
468
+ if ((result.blocked || []).length > 0) {
469
+ lines.push('');
470
+ lines.push('Blocked:');
471
+ for (const item of result.blocked) {
472
+ lines.push(` ${item.id}: ${(item.reasons || []).join(', ')}`);
473
+ }
474
+ }
475
+ lines.push('');
476
+ return lines.join('\n');
477
+ }