@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
package/src/update.js ADDED
@@ -0,0 +1,815 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { findPackageRoot } from './catalog.js';
5
+ import {
6
+ applyProposedRepair,
7
+ diagnoseCustomizationMarkers,
8
+ extractRawCustomContent,
9
+ injectRawCustomContent,
10
+ inspectCustomizationBlock,
11
+ } from './customization.js';
12
+ import {
13
+ UNIVERSAL_PROJECT_DESTINATION,
14
+ loadHostRegistry,
15
+ resolveHomeDir,
16
+ } from './destinations.js';
17
+ import { pathExists } from './links.js';
18
+ import { inspectProjectLock } from './project-lock.js';
19
+ import {
20
+ STATE_SCHEMA_VERSION,
21
+ getGlobalStateDir,
22
+ getProjectStateDir,
23
+ loadGlobalState,
24
+ loadProjectState,
25
+ } from './state.js';
26
+ import { collectStatus } from './status.js';
27
+ import { executeProjectInstall } from './transaction.js';
28
+ import { commitSkillBackup, exportSkillTree, inventorySkillTree } from './backup.js';
29
+
30
+ export const UPDATE_SCHEMA_VERSION = 1;
31
+ export const CANONICAL_CUSTOMIZATION_OWNER = 'canonical';
32
+
33
+ const UNSAFE_KINDS = new Set([
34
+ 'missing-destination',
35
+ 'stale-state',
36
+ 'broken-link',
37
+ 'wrong-target',
38
+ 'copy-disagreement',
39
+ ]);
40
+
41
+ function hashBytes(bytes) {
42
+ return crypto.createHash('sha256').update(bytes).digest('hex');
43
+ }
44
+
45
+ function omitKey(map, key) {
46
+ const next = { ...(map || {}) };
47
+ delete next[key];
48
+ return next;
49
+ }
50
+
51
+ function mapsEqual(left, right) {
52
+ const keys = new Set([...Object.keys(left || {}), ...Object.keys(right || {})]);
53
+ for (const key of keys) {
54
+ if ((left || {})[key] !== (right || {})[key]) return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ function fileDiff(fromFiles, toFiles) {
60
+ const added = [];
61
+ const replaced = [];
62
+ const deleted = [];
63
+ for (const file of Object.keys(toFiles || {}).sort()) {
64
+ if (!(file in (fromFiles || {}))) added.push(file);
65
+ else if (fromFiles[file] !== toFiles[file]) replaced.push(file);
66
+ }
67
+ for (const file of Object.keys(fromFiles || {}).sort()) {
68
+ if (!(file in (toFiles || {}))) deleted.push(file);
69
+ }
70
+ return { added, replaced, deleted };
71
+ }
72
+
73
+ function relativeRootOf(relativeDestination, skillId) {
74
+ const posix = String(relativeDestination || '').replace(/\\/g, '/');
75
+ const suffix = `/${skillId}`;
76
+ if (posix.endsWith(suffix)) return posix.slice(0, -suffix.length);
77
+ return posix.split('/').slice(0, -1).join('/') || UNIVERSAL_PROJECT_DESTINATION;
78
+ }
79
+
80
+ function walkLiveFiles(dir) {
81
+ const files = {};
82
+ const inventory = inventorySkillTree(dir);
83
+ for (const [rel, entry] of Object.entries(inventory.entries || {})) {
84
+ if (entry.kind === 'file') files[rel] = entry.hash;
85
+ }
86
+ return { files, inventory };
87
+ }
88
+
89
+ function detectRenames(deleted, added, fromFiles, toFiles) {
90
+ const unusedAdded = new Set(added);
91
+ const renames = [];
92
+ for (const from of deleted) {
93
+ const hash = fromFiles[from];
94
+ const match = [...unusedAdded].find((to) => toFiles[to] === hash);
95
+ if (!match) continue;
96
+ unusedAdded.delete(match);
97
+ renames.push({ from, to: match, hash });
98
+ }
99
+ return renames;
100
+ }
101
+
102
+ function detectTypeChanges(baseFiles, inventory) {
103
+ const changes = [];
104
+ for (const [rel, entry] of Object.entries(inventory.entries || {})) {
105
+ if (baseFiles[rel] && entry.kind !== 'file') {
106
+ changes.push({ path: rel, from: 'file', to: entry.kind });
107
+ }
108
+ }
109
+ for (const rel of Object.keys(baseFiles || {})) {
110
+ if (!inventory.entries[rel] && !Object.keys(inventory.entries).some((key) => key.startsWith(`${rel}/`))) {
111
+ continue;
112
+ }
113
+ const entry = inventory.entries[rel];
114
+ if (entry && entry.kind === 'directory' && baseFiles[rel]) {
115
+ if (!changes.some((change) => change.path === rel)) {
116
+ changes.push({ path: rel, from: 'file', to: 'directory' });
117
+ }
118
+ }
119
+ }
120
+ return changes;
121
+ }
122
+
123
+ function symlinkEffects(inventory) {
124
+ return Object.entries(inventory.entries || {})
125
+ .filter(([, entry]) => entry.kind === 'symlink')
126
+ .map(([pathName, entry]) => ({
127
+ path: pathName,
128
+ target: entry.target,
129
+ escaped: Boolean(entry.escaped),
130
+ }));
131
+ }
132
+
133
+ function releaseRelation(installed, running) {
134
+ if (!installed || !running) return 'unknown';
135
+ if (installed === running) return 'same';
136
+ const left = String(installed).split('.').map((part) => Number(part));
137
+ const right = String(running).split('.').map((part) => Number(part));
138
+ if (left.some(Number.isNaN) || right.some(Number.isNaN)) {
139
+ return String(installed) < String(running) ? 'older' : 'newer';
140
+ }
141
+ const len = Math.max(left.length, right.length);
142
+ for (let i = 0; i < len; i++) {
143
+ const a = left[i] || 0;
144
+ const b = right[i] || 0;
145
+ if (a < b) return 'older';
146
+ if (a > b) return 'newer';
147
+ }
148
+ return 'same';
149
+ }
150
+
151
+ function readChangelog(packageRoot) {
152
+ const changelogPath = path.join(packageRoot, 'CHANGELOG.md');
153
+ if (!pathExists(changelogPath) || !fs.lstatSync(changelogPath).isFile()) return '';
154
+ const text = fs.readFileSync(changelogPath, 'utf8');
155
+ const match = text.match(/## \[Unreleased\][\s\S]*?(?=\n## \[|$)/);
156
+ return (match ? match[0] : text.slice(0, 4000)).trim();
157
+ }
158
+
159
+ function assertSupportedSchema(state, label) {
160
+ if (typeof state?.schemaVersion === 'number' && state.schemaVersion > STATE_SCHEMA_VERSION) {
161
+ throw new Error(
162
+ `unsupported ${label} schemaVersion ${state.schemaVersion}; this installer supports ${STATE_SCHEMA_VERSION}`,
163
+ );
164
+ }
165
+ }
166
+
167
+ function loadScopeState(options, root, scope) {
168
+ if (scope === 'global') {
169
+ return loadGlobalState(root, options.customStateDir);
170
+ }
171
+ const state = loadProjectState(root, options.customStateDir);
172
+ assertSupportedSchema(state, 'project state');
173
+ return state;
174
+ }
175
+
176
+ function canonicalCopy(entry, skillId) {
177
+ const copies = Array.isArray(entry?.copies) ? entry.copies : [];
178
+ const canonical = copies.find((copy) => copy.kind === 'canonical')
179
+ || copies.find((copy) => String(copy.destination || '').replace(/\\/g, '/').startsWith(`${UNIVERSAL_PROJECT_DESTINATION}/`));
180
+ if (canonical) return canonical;
181
+ if (entry?.destination) {
182
+ return { destination: entry.destination, method: entry.method || 'copy', kind: 'canonical' };
183
+ }
184
+ return {
185
+ destination: `${UNIVERSAL_PROJECT_DESTINATION}/${skillId}`,
186
+ method: 'copy',
187
+ kind: 'canonical',
188
+ };
189
+ }
190
+
191
+ function readSkillMarkdown(absDir) {
192
+ const skillMd = path.join(absDir, 'SKILL.md');
193
+ if (!pathExists(skillMd) || !fs.lstatSync(skillMd).isFile()) return null;
194
+ return fs.readFileSync(skillMd, 'utf8');
195
+ }
196
+
197
+ function officialLiveFiles(liveFiles, liveMarkdown, bundledMarkdown, skillId) {
198
+ const next = { ...liveFiles };
199
+ if (typeof liveMarkdown !== 'string' || typeof bundledMarkdown !== 'string' || !next['SKILL.md']) {
200
+ return next;
201
+ }
202
+ const inspection = inspectCustomizationBlock(liveMarkdown, skillId);
203
+ if (inspection.status !== 'valid' && inspection.status !== 'empty') return next;
204
+ const emptied = injectRawCustomContent(
205
+ liveMarkdown,
206
+ extractRawCustomContent(bundledMarkdown, skillId),
207
+ skillId,
208
+ );
209
+ next['SKILL.md'] = hashBytes(Buffer.from(emptied, 'utf8'));
210
+ return next;
211
+ }
212
+
213
+ function customizationKind(markdown, skillId) {
214
+ if (typeof markdown !== 'string') return 'absent';
215
+ const diagnosis = diagnoseCustomizationMarkers(markdown, skillId);
216
+ if (diagnosis.status === 'valid') return 'populated';
217
+ if (diagnosis.status === 'empty') return 'empty';
218
+ if (diagnosis.status === 'malformed') return 'malformed';
219
+ return 'absent';
220
+ }
221
+
222
+ function compareSkill(options) {
223
+ const {
224
+ skillId,
225
+ catalogSkill,
226
+ entry,
227
+ statusSkill,
228
+ root,
229
+ packageRoot,
230
+ bundledMarkdown,
231
+ } = options;
232
+
233
+ const owner = canonicalCopy(entry, skillId);
234
+ const ownerRel = String(owner.destination || '').replace(/\\/g, '/');
235
+ const ownerAbs = path.resolve(root, ...ownerRel.split('/'));
236
+ const liveMarkdown = readSkillMarkdown(ownerAbs);
237
+ const { files: liveFiles, inventory } = walkLiveFiles(ownerAbs);
238
+ const baseHashes = entry?.baseHashes || {};
239
+ const upstreamFiles = catalogSkill?.files || {};
240
+ const diagnosis = typeof liveMarkdown === 'string'
241
+ ? diagnoseCustomizationMarkers(liveMarkdown, skillId)
242
+ : { status: 'absent', shape: 'missing-file', repairable: false };
243
+ const officialLive = officialLiveFiles(liveFiles, liveMarkdown, bundledMarkdown, skillId);
244
+ const liveChanged = !mapsEqual(officialLive, baseHashes)
245
+ || symlinkEffects(inventory).length > 0
246
+ || detectTypeChanges(baseHashes, inventory).length > 0;
247
+ const hasOutsideDrift = !mapsEqual(omitKey(liveFiles, 'SKILL.md'), omitKey(baseHashes, 'SKILL.md'))
248
+ || symlinkEffects(inventory).length > 0
249
+ || detectTypeChanges(baseHashes, inventory).length > 0;
250
+ const upstreamChanged = !mapsEqual(baseHashes, upstreamFiles);
251
+ const customKind = customizationKind(liveMarkdown, skillId);
252
+ const statusKinds = (statusSkill?.destinations || []).flatMap((dest) => dest.classifications || []);
253
+ const unsafeKinds = [...new Set(statusKinds.filter((kind) => UNSAFE_KINDS.has(kind)))];
254
+ const missingBundled = !catalogSkill;
255
+ const markerProblem = customKind === 'malformed';
256
+
257
+ let comparison = 'no-op';
258
+ let changeKind = 'none';
259
+ if (missingBundled) {
260
+ comparison = 'missing-bundled';
261
+ } else if (unsafeKinds.length) {
262
+ comparison = 'unsafe';
263
+ } else if (markerProblem) {
264
+ comparison = 'malformed-markers';
265
+ } else if (liveChanged && upstreamChanged) {
266
+ comparison = 'concurrent';
267
+ changeKind = 'concurrent';
268
+ } else if (liveChanged) {
269
+ comparison = 'local-only';
270
+ changeKind = 'local-only';
271
+ } else if (upstreamChanged && customKind === 'populated') {
272
+ comparison = 'upstream-and-customization';
273
+ changeKind = 'upstream-only';
274
+ } else if (upstreamChanged) {
275
+ comparison = 'upstream-only';
276
+ changeKind = 'upstream-only';
277
+ } else if (customKind === 'populated') {
278
+ comparison = 'customization-only';
279
+ }
280
+
281
+ const blocked = comparison === 'unsafe' || comparison === 'missing-bundled';
282
+ const needsMarkerResolution = comparison === 'malformed-markers';
283
+ const needsResolution = changeKind === 'local-only' || changeKind === 'concurrent'
284
+ || (needsMarkerResolution && hasOutsideDrift);
285
+ const diff = fileDiff(officialLive, upstreamFiles);
286
+ const liveOnlyFiles = {};
287
+ for (const [rel, entry] of Object.entries(inventory.entries || {})) {
288
+ if (entry.kind === 'file') liveOnlyFiles[rel] = entry.hash;
289
+ }
290
+ const vsLiveBase = fileDiff(baseHashes, liveOnlyFiles);
291
+ const renames = detectRenames(vsLiveBase.deleted, vsLiveBase.added, baseHashes, liveOnlyFiles);
292
+ const renamedFrom = new Set(renames.map((item) => item.from));
293
+ const renamedTo = new Set(renames.map((item) => item.to));
294
+ const typeChanges = detectTypeChanges(baseHashes, inventory);
295
+ const effects = {
296
+ added: vsLiveBase.added.filter((file) => !renamedTo.has(file)),
297
+ replaced: vsLiveBase.replaced,
298
+ deleted: vsLiveBase.deleted.filter((file) => !renamedFrom.has(file)),
299
+ overwrite: diff.replaced,
300
+ delete: [
301
+ ...diff.deleted,
302
+ ...Object.keys(inventory.entries || {}).filter((rel) => {
303
+ const entry = inventory.entries[rel];
304
+ return (entry.kind === 'symlink' || entry.kind === 'directory') && !(rel in upstreamFiles);
305
+ }),
306
+ ],
307
+ renames,
308
+ typeChanges,
309
+ symlinks: symlinkEffects(inventory),
310
+ };
311
+ const exportRoot = options.exportDir || path.join(root, '.sigma-export');
312
+ const exportPath = path.join(exportRoot, skillId);
313
+ const skillChangelog = [
314
+ ...diff.added.map((file) => `+ ${file}`),
315
+ ...diff.replaced.map((file) => `~ ${file}`),
316
+ ...diff.deleted.map((file) => `- ${file}`),
317
+ ].join('\n');
318
+
319
+ return {
320
+ id: skillId,
321
+ title: catalogSkill?.title || statusSkill?.title || skillId,
322
+ installedRevision: entry?.revision || statusSkill?.installedRevision || null,
323
+ runningRevision: catalogSkill?.revision || null,
324
+ installedRelease: entry?.release || null,
325
+ comparison,
326
+ changeKind,
327
+ customization: customKind,
328
+ blocked,
329
+ needsResolution,
330
+ needsMarkerResolution,
331
+ markerShape: diagnosis.shape || null,
332
+ repairable: Boolean(diagnosis.repairable),
333
+ proposedRepair: diagnosis.proposedRepair,
334
+ repairEffects: needsMarkerResolution
335
+ ? { added: [], replaced: ['SKILL.md'], deleted: [] }
336
+ : undefined,
337
+ hasOutsideDrift,
338
+ blockedReasons: blocked
339
+ ? (missingBundled ? ['missing bundled Skill Revision'] : unsafeKinds)
340
+ : (needsMarkerResolution ? [diagnosis.error || 'malformed customization markers'] : []),
341
+ diff,
342
+ effects,
343
+ changelog: skillChangelog,
344
+ owner: CANONICAL_CUSTOMIZATION_OWNER,
345
+ ownerDestination: ownerRel,
346
+ destinations: (statusSkill?.destinations || []).map((dest) => ({
347
+ relativeDestination: dest.relativeDestination,
348
+ method: dest.method,
349
+ classifications: dest.classifications,
350
+ })),
351
+ copies: Array.isArray(entry?.copies) ? entry.copies : [],
352
+ rawCustom: (customKind === 'populated' || customKind === 'empty') && typeof liveMarkdown === 'string'
353
+ ? extractRawCustomContent(liveMarkdown, skillId)
354
+ : undefined,
355
+ backup: { required: needsResolution },
356
+ exportPath,
357
+ exportCollision: pathExists(exportPath),
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Plan a Release update against the Skill Pack bundled in this CLI.
363
+ *
364
+ * @param {object} options
365
+ * @returns {object}
366
+ */
367
+ export function createUpdatePlan(options = {}) {
368
+ const catalog = options.catalog;
369
+ if (!catalog) throw new Error('update requires a catalog');
370
+
371
+ const scope = options.scope === 'global' ? 'global' : 'project';
372
+ const env = options.env || process.env;
373
+ const homeDir = path.resolve(options.homeDir || resolveHomeDir(env));
374
+ const projectRoot = path.resolve(options.projectRoot || process.cwd());
375
+ const root = scope === 'global' ? homeDir : projectRoot;
376
+ const packageRoot = options.packageRoot || findPackageRoot();
377
+ const state = loadScopeState(options, root, scope);
378
+ const lockInspect = scope === 'project' ? inspectProjectLock(root) : { lock: { skills: {}, release: null } };
379
+ const status = collectStatus({
380
+ catalog,
381
+ projectRoot,
382
+ homeDir,
383
+ scope,
384
+ customStateDir: options.customStateDir,
385
+ packageRoot,
386
+ env,
387
+ registry: options.registry || loadHostRegistry(findPackageRoot()),
388
+ });
389
+
390
+ const skillIds = [...new Set([
391
+ ...Object.keys(state.skills || {}),
392
+ ...Object.keys(lockInspect.lock.skills || {}),
393
+ ])].sort();
394
+
395
+ const missing = skillIds.filter((skillId) => !catalog.skills.some((skill) => skill.id === skillId));
396
+ if (missing.length > 0) {
397
+ throw new Error(
398
+ `missing bundled Skill Revision for ${missing.join(', ')}; update stopped without mutation`,
399
+ );
400
+ }
401
+
402
+ const skills = skillIds.map((skillId) => {
403
+ const catalogSkill = catalog.skills.find((skill) => skill.id === skillId);
404
+ const bundledPath = path.join(packageRoot, skillId, 'SKILL.md');
405
+ const bundledMarkdown = pathExists(bundledPath) && fs.lstatSync(bundledPath).isFile()
406
+ ? fs.readFileSync(bundledPath, 'utf8')
407
+ : null;
408
+ return compareSkill({
409
+ skillId,
410
+ catalogSkill,
411
+ entry: state.skills?.[skillId],
412
+ statusSkill: status.skills.find((skill) => skill.id === skillId),
413
+ root,
414
+ packageRoot,
415
+ bundledMarkdown,
416
+ exportDir: options.exportDir,
417
+ });
418
+ });
419
+
420
+ const changed = skills.filter((skill) => (
421
+ skill.comparison === 'upstream-only'
422
+ || skill.comparison === 'upstream-and-customization'
423
+ || skill.comparison === 'concurrent'
424
+ || skill.comparison === 'local-only'
425
+ ));
426
+ const unchanged = skills.filter((skill) => (
427
+ skill.comparison === 'no-op' || skill.comparison === 'customization-only'
428
+ ));
429
+ const blocked = skills.filter((skill) => skill.blocked);
430
+ const needsResolution = skills.filter((skill) => skill.needsResolution);
431
+ const needsMarkerResolution = skills.filter((skill) => skill.needsMarkerResolution);
432
+ const kinds = [...new Set(skills.map((skill) => skill.changeKind).filter((kind) => kind && kind !== 'none'))];
433
+ const prompts = [];
434
+ if (kinds.length) {
435
+ prompts.push(`Resolve ${kinds.join(', ')} outside-edit cases with skip, export, or replace.`);
436
+ }
437
+ if (needsMarkerResolution.length) {
438
+ prompts.push('Resolve malformed customization markers with --malformed-markers skip, repair, or replace.');
439
+ }
440
+
441
+ return {
442
+ schemaVersion: UPDATE_SCHEMA_VERSION,
443
+ command: 'update',
444
+ scope,
445
+ dryRun: Boolean(options.dryRun),
446
+ release: {
447
+ installed: status.release?.installed || null,
448
+ running: status.release?.running || catalog.manifest.version,
449
+ relation: releaseRelation(status.release?.installed, status.release?.running || catalog.manifest.version),
450
+ },
451
+ changelog: readChangelog(packageRoot),
452
+ owner: CANONICAL_CUSTOMIZATION_OWNER,
453
+ prompt: prompts.join(' '),
454
+ changed,
455
+ unchanged,
456
+ blocked,
457
+ needsResolution,
458
+ needsMarkerResolution,
459
+ skills,
460
+ };
461
+ }
462
+
463
+ function selectSkills(plan, options) {
464
+ const requested = Array.isArray(options.skillIds) ? options.skillIds.filter(Boolean) : [];
465
+ if (requested.length > 0) {
466
+ const known = new Set(plan.skills.map((skill) => skill.id));
467
+ const unknown = requested.filter((skillId) => !known.has(skillId));
468
+ if (unknown.length > 0) {
469
+ throw new Error(`skill '${unknown[0]}' is not a managed installation`);
470
+ }
471
+ return requested;
472
+ }
473
+ const selected = plan.changed.filter((skill) => !skill.blocked).map((skill) => skill.id);
474
+ const malformedChoice = options.malformedMarkers;
475
+ if (malformedChoice === 'repair' || malformedChoice === 'replace' || malformedChoice === 'skip') {
476
+ for (const skill of plan.needsMarkerResolution || []) {
477
+ if (!selected.includes(skill.id)) selected.push(skill.id);
478
+ }
479
+ }
480
+ return selected;
481
+ }
482
+
483
+ function installOptionsFor(skill, options, scope) {
484
+ const copies = skill.copies || [];
485
+ const selectedRoots = [...new Set(copies.map((copy) => relativeRootOf(copy.destination, skill.id)))];
486
+ const roots = selectedRoots.length > 0 ? selectedRoots : [UNIVERSAL_PROJECT_DESTINATION];
487
+ const copyRoots = copies
488
+ .filter((copy) => copy.kind !== 'canonical' && copy.method === 'copy')
489
+ .map((copy) => relativeRootOf(copy.destination, skill.id));
490
+ const hasLink = copies.some((copy) => copy.kind !== 'canonical' && copy.method && copy.method !== 'copy');
491
+ return {
492
+ catalog: options.catalog,
493
+ skillId: skill.id,
494
+ projectRoot: options.projectRoot,
495
+ homeDir: options.homeDir,
496
+ scope,
497
+ customStateDir: options.customStateDir,
498
+ packageRoot: options.packageRoot,
499
+ dryRun: false,
500
+ env: options.env,
501
+ selectedRoots: roots,
502
+ method: hasLink ? 'link' : 'copy',
503
+ copyRoots,
504
+ adoptChanged: 'replace',
505
+ adoptMalformed: options.malformedMarkers === 'replace' ? 'replace' : undefined,
506
+ preservedCustomRaw: skill.rawCustom !== undefined ? skill.rawCustom : undefined,
507
+ registry: options.registry || loadHostRegistry(findPackageRoot()),
508
+ afterBackup: options.afterBackup,
509
+ saveState: options.saveState,
510
+ pruneBackups: options.pruneBackups,
511
+ };
512
+ }
513
+
514
+ function ownerAbsPath(skill, options) {
515
+ return path.resolve(
516
+ options.scope === 'global' ? options.homeDir : options.projectRoot,
517
+ ...String(skill.ownerDestination).split('/'),
518
+ );
519
+ }
520
+
521
+ function applyCanonicalRepair(skill, options) {
522
+ const ownerAbs = ownerAbsPath(skill, options);
523
+ const skillMd = path.join(ownerAbs, 'SKILL.md');
524
+ const current = fs.readFileSync(skillMd, 'utf8');
525
+ const repaired = applyProposedRepair(current, skill.id, { editor: options.repairEditor });
526
+ const stateDir = options.scope === 'global'
527
+ ? getGlobalStateDir(options.homeDir, options.customStateDir)
528
+ : getProjectStateDir(options.projectRoot, options.customStateDir);
529
+ const backupDir = commitSkillBackup({
530
+ stateDir,
531
+ skillId: skill.id,
532
+ sourceDir: ownerAbs,
533
+ ownership: {
534
+ scope: options.scope || 'project',
535
+ release: skill.installedRelease || null,
536
+ revision: skill.installedRevision || null,
537
+ method: 'copy',
538
+ canonicalTarget: skill.ownerDestination,
539
+ copies: [],
540
+ ownedPaths: skill.ownerDestination ? [skill.ownerDestination] : [],
541
+ },
542
+ });
543
+ if (typeof options.afterBackup === 'function') {
544
+ options.afterBackup(backupDir);
545
+ }
546
+ try {
547
+ fs.writeFileSync(skillMd, repaired, 'utf8');
548
+ } catch (err) {
549
+ fs.writeFileSync(skillMd, current, 'utf8');
550
+ throw err;
551
+ }
552
+ return backupDir;
553
+ }
554
+
555
+ /**
556
+ * Apply a planned update through the shared install transaction.
557
+ *
558
+ * @param {object} options
559
+ * @returns {object}
560
+ */
561
+ export function executeUpdate(options = {}) {
562
+ const plan = createUpdatePlan(options);
563
+ const requested = Array.isArray(options.skillIds) ? options.skillIds.filter(Boolean) : [];
564
+ const selectedIds = selectSkills(plan, options);
565
+ const selected = plan.skills.filter((skill) => selectedIds.includes(skill.id));
566
+ const skipped = plan.skills.filter((skill) => !selectedIds.includes(skill.id));
567
+ const outsideEdit = options.outsideEdit || null;
568
+ const malformedMarkers = options.malformedMarkers || null;
569
+
570
+ const selectedBlocked = selected.filter((skill) => skill.blocked);
571
+ if (selectedBlocked.length > 0) {
572
+ const reasons = selectedBlocked.map((skill) => `${skill.id}: ${skill.blockedReasons.join(', ')}`).join('; ');
573
+ throw new Error(`unsafe drift or missing revision stopped update without mutation: ${reasons}`);
574
+ }
575
+ if (!options.dryRun && requested.length === 0 && plan.blocked.length > 0) {
576
+ const reasons = plan.blocked.map((skill) => `${skill.id}: ${skill.blockedReasons.join(', ')}`).join('; ');
577
+ throw new Error(`unsafe drift or missing revision stopped update without mutation: ${reasons}`);
578
+ }
579
+
580
+ const selectedMalformed = selected.filter((skill) => skill.needsMarkerResolution);
581
+ if (!options.dryRun && selectedMalformed.length > 0 && !malformedMarkers) {
582
+ const ids = selectedMalformed.map((skill) => `${skill.id} (${skill.markerShape || 'malformed'})`).join(', ');
583
+ throw new Error(`malformed customization markers need --malformed-markers skip, repair, or replace: ${ids}`);
584
+ }
585
+ if (!options.dryRun && requested.length === 0 && (plan.needsMarkerResolution || []).length > 0 && !malformedMarkers) {
586
+ const ids = plan.needsMarkerResolution.map((skill) => `${skill.id} (${skill.markerShape || 'malformed'})`).join(', ');
587
+ throw new Error(`malformed customization markers need --malformed-markers skip, repair, or replace: ${ids}`);
588
+ }
589
+
590
+ const unresolved = selected.filter((skill) => skill.needsResolution && !skill.needsMarkerResolution);
591
+ if (!options.dryRun && unresolved.length > 0 && !outsideEdit) {
592
+ const ids = unresolved.map((skill) => `${skill.id} (${skill.changeKind})`).join(', ');
593
+ throw new Error(`outside edits need --outside-edit replace, skip, or export before mutation: ${ids}`);
594
+ }
595
+
596
+ const result = {
597
+ ...plan,
598
+ dryRun: Boolean(options.dryRun),
599
+ selected: selected.map((skill) => skill.id),
600
+ skipped: skipped.map((skill) => skill.id),
601
+ outsideEdit,
602
+ malformedMarkers,
603
+ results: [],
604
+ };
605
+
606
+ if (options.dryRun) return result;
607
+
608
+ const updatableComparisons = new Set(['upstream-only', 'upstream-and-customization', 'local-only', 'concurrent']);
609
+ for (const skill of selected) {
610
+ if (skill.needsMarkerResolution) {
611
+ if (malformedMarkers === 'skip') {
612
+ result.results.push({ id: skill.id, success: true, action: 'skip' });
613
+ continue;
614
+ }
615
+ if (malformedMarkers === 'repair') {
616
+ if (!skill.repairable) {
617
+ throw new Error(`${skill.id}: invalid repair; marker boundaries cannot be inferred`);
618
+ }
619
+ if (skill.hasOutsideDrift && !outsideEdit) {
620
+ throw new Error(`malformed markers combined with outside edits need --outside-edit skip, export, or replace: ${skill.id}`);
621
+ }
622
+ if (skill.hasOutsideDrift && outsideEdit === 'replace') {
623
+ const executed = executeProjectInstall(installOptionsFor(
624
+ skill,
625
+ { ...options, malformedMarkers: 'replace' },
626
+ options.scope === 'global' ? 'global' : 'project',
627
+ ));
628
+ const backupDir = (executed.plan?.destinations || []).find((dest) => dest.privateBackup)?.privateBackup;
629
+ result.results.push({
630
+ id: skill.id,
631
+ success: executed.success,
632
+ revision: executed.plan?.sourceRevision,
633
+ action: 'replace',
634
+ backupIntegrity: backupDir ? { ok: true, path: backupDir } : { ok: true },
635
+ });
636
+ continue;
637
+ }
638
+ const backupDir = applyCanonicalRepair(skill, options);
639
+ if (skill.hasOutsideDrift && outsideEdit === 'export') {
640
+ if (skill.exportCollision) throw new Error(`export collision at '${skill.exportPath}'`);
641
+ exportSkillTree({
642
+ sourceDir: ownerAbsPath(skill, options),
643
+ exportRoot: path.dirname(skill.exportPath),
644
+ skillId: skill.id,
645
+ dest: skill.exportPath,
646
+ refuseCollision: true,
647
+ });
648
+ }
649
+ result.results.push({ id: skill.id, success: true, action: 'repair', backupIntegrity: { ok: true, path: backupDir } });
650
+ continue;
651
+ }
652
+ if (malformedMarkers === 'replace') {
653
+ if (skill.hasOutsideDrift && !outsideEdit) {
654
+ throw new Error(`malformed markers combined with outside edits need --outside-edit skip, export, or replace: ${skill.id}`);
655
+ }
656
+ if (skill.hasOutsideDrift && (outsideEdit === 'skip' || outsideEdit === 'export')) {
657
+ if (outsideEdit === 'export') {
658
+ if (skill.exportCollision) throw new Error(`export collision at '${skill.exportPath}'`);
659
+ exportSkillTree({
660
+ sourceDir: ownerAbsPath(skill, options),
661
+ exportRoot: path.dirname(skill.exportPath),
662
+ skillId: skill.id,
663
+ dest: skill.exportPath,
664
+ refuseCollision: true,
665
+ });
666
+ }
667
+ result.results.push({ id: skill.id, success: true, action: outsideEdit });
668
+ continue;
669
+ }
670
+ const executed = executeProjectInstall(installOptionsFor(
671
+ skill,
672
+ options,
673
+ options.scope === 'global' ? 'global' : 'project',
674
+ ));
675
+ const backupDir = (executed.plan?.destinations || []).find((dest) => dest.privateBackup)?.privateBackup;
676
+ result.results.push({
677
+ id: skill.id,
678
+ success: executed.success,
679
+ revision: executed.plan?.sourceRevision,
680
+ action: 'replace',
681
+ backupIntegrity: backupDir ? { ok: true, path: backupDir } : { ok: true },
682
+ });
683
+ continue;
684
+ }
685
+ }
686
+ if (skill.needsResolution && outsideEdit === 'skip') {
687
+ result.results.push({ id: skill.id, success: true, action: 'skip' });
688
+ continue;
689
+ }
690
+ if (skill.needsResolution && outsideEdit === 'export') {
691
+ if (skill.exportCollision) {
692
+ throw new Error(`export collision at '${skill.exportPath}'`);
693
+ }
694
+ const exported = exportSkillTree({
695
+ sourceDir: ownerAbsPath(skill, options),
696
+ exportRoot: path.dirname(skill.exportPath),
697
+ skillId: skill.id,
698
+ dest: skill.exportPath,
699
+ refuseCollision: true,
700
+ });
701
+ result.results.push({ id: skill.id, success: true, action: 'export', exportPath: exported });
702
+ continue;
703
+ }
704
+ if (!updatableComparisons.has(skill.comparison)) continue;
705
+ const executed = executeProjectInstall(installOptionsFor(
706
+ skill,
707
+ options,
708
+ options.scope === 'global' ? 'global' : 'project',
709
+ ));
710
+ const backupDir = (executed.plan?.destinations || []).find((dest) => dest.privateBackup)?.privateBackup;
711
+ result.results.push({
712
+ id: skill.id,
713
+ success: executed.success,
714
+ revision: executed.plan?.sourceRevision,
715
+ action: 'replace',
716
+ backupIntegrity: backupDir ? { ok: true, path: backupDir } : { ok: true },
717
+ });
718
+ }
719
+
720
+ return result;
721
+ }
722
+
723
+ /**
724
+ * @param {object} plan
725
+ * @returns {string}
726
+ */
727
+ export function formatUpdateJson(plan) {
728
+ const payload = JSON.parse(JSON.stringify({
729
+ ...plan,
730
+ skills: (plan.skills || []).map(({ rawCustom, copies, ...skill }) => skill),
731
+ changed: (plan.changed || []).map(({ rawCustom, copies, ...skill }) => skill),
732
+ unchanged: (plan.unchanged || []).map(({ rawCustom, copies, ...skill }) => skill),
733
+ blocked: (plan.blocked || []).map(({ rawCustom, copies, ...skill }) => skill),
734
+ needsResolution: (plan.needsResolution || []).map(({ rawCustom, copies, ...skill }) => skill),
735
+ needsMarkerResolution: (plan.needsMarkerResolution || []).map(({ rawCustom, copies, ...skill }) => skill),
736
+ }));
737
+ return JSON.stringify(payload, null, 2);
738
+ }
739
+
740
+ /**
741
+ * @param {object} plan
742
+ * @returns {string}
743
+ */
744
+ export function formatUpdateHuman(plan) {
745
+ const scopeLabel = plan.scope === 'global' ? 'Global Installation' : 'Project Installation';
746
+ const lines = [
747
+ `SigmaSkills ${scopeLabel} Update`,
748
+ ` Installed Release: ${plan.release?.installed || 'none'}`,
749
+ ` Running Release: ${plan.release?.running || 'unknown'} (${plan.release?.relation || 'unknown'})`,
750
+ ` Customization owner: ${plan.owner} copy`,
751
+ ];
752
+ if (plan.changelog) {
753
+ lines.push(' Changelog:');
754
+ for (const line of plan.changelog.split('\n')) {
755
+ lines.push(` ${line}`);
756
+ }
757
+ }
758
+
759
+ const renderGroup = (title, skills) => {
760
+ lines.push('');
761
+ lines.push(`${title}:`);
762
+ if (!skills.length) {
763
+ lines.push(' (none)');
764
+ return;
765
+ }
766
+ for (const skill of skills) {
767
+ lines.push(` ${skill.title} (${skill.id}) [${skill.comparison}]`);
768
+ if (skill.changelog) {
769
+ lines.push(' Whole-skill diff:');
770
+ for (const line of skill.changelog.split('\n')) lines.push(` ${line}`);
771
+ }
772
+ if (skill.blockedReasons?.length) {
773
+ lines.push(` blocked: ${skill.blockedReasons.join(', ')}`);
774
+ }
775
+ if (skill.needsMarkerResolution) {
776
+ lines.push(` marker shape: ${skill.markerShape || 'malformed'}`);
777
+ lines.push(` repairable: ${skill.repairable ? 'yes' : 'no'}`);
778
+ if (skill.repairEffects) {
779
+ lines.push(` repair replacements: ${(skill.repairEffects.replaced || []).join(', ') || '(none)'}`);
780
+ lines.push(` repair deletions: ${(skill.repairEffects.deleted || []).join(', ') || '(none)'}`);
781
+ }
782
+ if (skill.effects?.delete?.length || skill.effects?.added?.length) {
783
+ lines.push(` whole-skill overwrite: ${(skill.effects.overwrite || []).join(', ') || '(none)'}`);
784
+ lines.push(` whole-skill deletions: ${(skill.effects.delete || []).join(', ') || '(none)'}`);
785
+ }
786
+ if (skill.proposedRepair) {
787
+ lines.push(' proposed repair bytes:');
788
+ for (const line of skill.proposedRepair.split('\n')) lines.push(` ${line}`);
789
+ }
790
+ }
791
+ }
792
+ };
793
+
794
+ renderGroup('Changed skills', plan.changed || []);
795
+ renderGroup('Unchanged skills', plan.unchanged || []);
796
+ renderGroup('Blocked skills', plan.blocked || []);
797
+ renderGroup('Malformed markers', plan.needsMarkerResolution || []);
798
+ if (plan.prompt) {
799
+ lines.push('');
800
+ lines.push(`Prompt: ${plan.prompt}`);
801
+ }
802
+ const localOnly = (plan.skills || []).filter((skill) => skill.changeKind === 'local-only');
803
+ const upstreamOnly = (plan.skills || []).filter((skill) => skill.changeKind === 'upstream-only');
804
+ const concurrent = (plan.skills || []).filter((skill) => skill.changeKind === 'concurrent');
805
+ renderGroup('Local-only changes', localOnly);
806
+ renderGroup('Upstream-only changes', upstreamOnly);
807
+ renderGroup('Concurrent local/upstream changes', concurrent);
808
+ if (Array.isArray(plan.selected)) {
809
+ lines.push('');
810
+ lines.push(`Selected: ${plan.selected.join(', ') || '(none)'}`);
811
+ lines.push(`Skipped: ${plan.skipped.join(', ') || '(none)'}`);
812
+ }
813
+ if (plan.dryRun) lines.push('', 'Dry run complete. No files were written.');
814
+ return lines.join('\n');
815
+ }