@litfamily/litclaude 1.0.6 → 1.0.7

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 (45) hide show
  1. package/CHANGELOG.md +5 -5
  2. package/README.md +11 -38
  3. package/README_ko-KR.md +11 -37
  4. package/docs/assets/cover-motion.webp +0 -0
  5. package/docs/assets/readme/badge-version.svg +1 -1
  6. package/docs/privacy.md +12 -17
  7. package/package.json +3 -1
  8. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  9. package/plugins/litclaude/bin/litclaude-hook.js +20 -57
  10. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  11. package/plugins/litclaude/hooks/hooks.json +4 -4
  12. package/plugins/litclaude/lib/canonical-skill-catalog.mjs +0 -1
  13. package/plugins/litclaude/lib/canonical-skill-resources.mjs +5 -27
  14. package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
  15. package/plugins/litclaude/lib/litgoal/paths.mjs +10 -8
  16. package/plugins/litclaude/lib/project-state-root.mjs +22 -0
  17. package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
  18. package/plugins/litclaude/lib/start-work-cli.mjs +3 -3
  19. package/plugins/litclaude/lib/wikify-knowledge-cli.mjs +3 -1
  20. package/plugins/litclaude/lib/wikify-knowledge.mjs +6 -5
  21. package/plugins/litclaude/scripts/scaffold-plan.mjs +9 -3
  22. package/plugins/litclaude/skills/lit-team/scripts/team.mjs +4 -3
  23. package/scripts/prepublish-test-gate.mjs +26 -0
  24. package/plugins/litclaude/lib/skill-loop/apply.mjs +0 -391
  25. package/plugins/litclaude/lib/skill-loop/authority.mjs +0 -129
  26. package/plugins/litclaude/lib/skill-loop/cli.mjs +0 -67
  27. package/plugins/litclaude/lib/skill-loop/common.mjs +0 -280
  28. package/plugins/litclaude/lib/skill-loop/config.mjs +0 -91
  29. package/plugins/litclaude/lib/skill-loop/curator.mjs +0 -670
  30. package/plugins/litclaude/lib/skill-loop/hook-runtime.mjs +0 -188
  31. package/plugins/litclaude/lib/skill-loop/hook-state.mjs +0 -484
  32. package/plugins/litclaude/lib/skill-loop/ledger.mjs +0 -834
  33. package/plugins/litclaude/lib/skill-loop/mutation-transaction.mjs +0 -529
  34. package/plugins/litclaude/lib/skill-loop/proposal-reader.mjs +0 -26
  35. package/plugins/litclaude/lib/skill-loop/proposal-record.mjs +0 -104
  36. package/plugins/litclaude/lib/skill-loop/proposal-store.mjs +0 -187
  37. package/plugins/litclaude/lib/skill-loop/proposals.mjs +0 -26
  38. package/plugins/litclaude/lib/skill-loop/review-runner.mjs +0 -13
  39. package/plugins/litclaude/lib/skill-loop/review-transaction.mjs +0 -358
  40. package/plugins/litclaude/lib/skill-loop/review.mjs +0 -223
  41. package/plugins/litclaude/lib/skill-loop/usage.mjs +0 -218
  42. package/plugins/litclaude/lib/skill-observer.mjs +0 -693
  43. package/plugins/litclaude/skills/skill-observer/SKILL.md +0 -155
  44. package/plugins/litclaude/skills/skill-observer/references/review-contract.md +0 -77
  45. package/plugins/litclaude/skills/skill-observer/scripts/skill-loop.mjs +0 -10
@@ -1,670 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, opendirSync, renameSync, rmdirSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { isDeepStrictEqual } from "node:util";
5
-
6
- import { resolveAgentTarget } from "./authority.mjs";
7
- import {
8
- captureDirectoryIdentity,
9
- directoryIdentityMatches,
10
- ensureSafeDirectoryChain,
11
- parseTimestamp,
12
- safeIsoPath,
13
- skillRootFor,
14
- withSkillLoopLock,
15
- } from "./common.mjs";
16
- import { readSkillLoopConfig, writeSkillLoopConfigUnlocked } from "./config.mjs";
17
- import {
18
- captureBackupSnapshot,
19
- captureSkillSnapshot,
20
- captureStagedSnapshot,
21
- cleanupCreatedSnapshotBlobs,
22
- createSnapshotWorkBudget,
23
- ledgerReferenceSnapshotUnchanged,
24
- materializeSnapshot,
25
- readLedgerReferenceSnapshot,
26
- SESSION_START_LEDGER_WORK_LIMITS,
27
- } from "./ledger.mjs";
28
- import { readUsage, writeUsageUnlocked } from "./usage.mjs";
29
- import {
30
- beginSkillMutationTransactionUnlocked,
31
- recoverSkillMutationTransactionUnlocked,
32
- requestCuratorMutationRollbackUnlocked,
33
- } from "./mutation-transaction.mjs";
34
- import { recoverReviewTransactionUnlocked } from "./review-transaction.mjs";
35
-
36
- export class SkillLoopCuratorError extends Error {
37
- constructor(code, message = code) {
38
- super(`${code}: ${message}`);
39
- this.name = "SkillLoopCuratorError";
40
- this.code = code;
41
- }
42
- }
43
-
44
- const days = (value) => value * 24 * 60 * 60 * 1000;
45
- export const SESSION_START_CURATOR_LIMITS = Object.freeze({
46
- maxSkills: 32,
47
- maxBytes: 8 * 1024 * 1024,
48
- maxTransitions: 8,
49
- maxDirectoryEntries: 1_024,
50
- maxDirectories: 128,
51
- maxDepth: 8,
52
- maxFiles: 256,
53
- });
54
- const WORK_CAP_ADVISORY = "Skill curator deferred: SessionStart work cap exceeded; run `/litclaude:skill-observer curator run` explicitly.";
55
-
56
- function latestActivity(entry) {
57
- return Math.max(...[
58
- entry.created_at, entry.last_used_at, entry.last_viewed_at, entry.last_patched_at,
59
- ].filter(Boolean).map(parseTimestamp));
60
- }
61
-
62
- function validateWorkLimits(limits) {
63
- if (limits === null) return null;
64
- const normalized = { ...SESSION_START_CURATOR_LIMITS, ...limits };
65
- if (limits && !Object.hasOwn(limits, "maxDirectoryEntries")
66
- && Object.hasOwn(limits, "maxDirectories")) {
67
- normalized.maxDirectoryEntries = limits.maxDirectories;
68
- }
69
- if (!limits || ![
70
- normalized.maxSkills, normalized.maxBytes, normalized.maxTransitions,
71
- normalized.maxDirectoryEntries, normalized.maxDirectories, normalized.maxDepth,
72
- normalized.maxFiles,
73
- ].every(Number.isSafeInteger)
74
- || normalized.maxSkills < 1 || normalized.maxSkills > 256
75
- || normalized.maxBytes < 1 || normalized.maxBytes > 32 * 1024 * 1024
76
- || normalized.maxTransitions < 1 || normalized.maxTransitions > 64
77
- || normalized.maxDirectoryEntries < 1 || normalized.maxDirectoryEntries > 16_384
78
- || normalized.maxDirectories < 1 || normalized.maxDirectories > 4096
79
- || normalized.maxDepth < 1 || normalized.maxDepth > 32
80
- || normalized.maxFiles < 1 || normalized.maxFiles > 256) {
81
- throw new SkillLoopCuratorError("CURATOR_WORK_CAP_INVALID");
82
- }
83
- return normalized;
84
- }
85
-
86
- function measuredSkillBytes(directory, byteAllowance, limits, work) {
87
- let bytes = 0;
88
- const walk = (path, depth) => {
89
- work.directories += 1;
90
- if (work.directories > limits.maxDirectories || depth > limits.maxDepth) return false;
91
- let handle;
92
- try {
93
- handle = opendirSync(path);
94
- let entry;
95
- while ((entry = handle.readSync()) !== null) {
96
- work.directoryEntries += 1;
97
- if (work.directoryEntries > limits.maxDirectoryEntries) return false;
98
- const child = join(path, entry.name);
99
- const details = lstatSync(child, { throwIfNoEntry: false });
100
- if (!details || details.isSymbolicLink()) throw new SkillLoopCuratorError("CURATOR_TARGET_UNSAFE");
101
- if (details.isDirectory()) {
102
- if (walk(child, depth + 1) === false) return false;
103
- continue;
104
- }
105
- if (!details.isFile() || details.nlink !== 1) {
106
- throw new SkillLoopCuratorError("CURATOR_TARGET_UNSAFE");
107
- }
108
- work.files += 1;
109
- if (work.files > limits.maxFiles) return false;
110
- bytes += details.size;
111
- work.bytes += details.size;
112
- if (bytes > byteAllowance || work.bytes > limits.maxBytes) return false;
113
- }
114
- } finally {
115
- handle?.closeSync();
116
- }
117
- return true;
118
- };
119
- return walk(directory, 0) === false ? byteAllowance + 1 : bytes;
120
- }
121
-
122
- const deferredForWorkCap = () => ({
123
- reason: "WORK_CAP_EXCEEDED",
124
- transitions: [],
125
- advisory: WORK_CAP_ADVISORY,
126
- });
127
-
128
- function captureCuratorSnapshot(root, skill, limits, work, createdBlobs, { capIsCas = false } = {}) {
129
- try {
130
- return captureSkillSnapshot(root, skillRootFor(root), skill, limits === null ? {} : {
131
- workLimits: limits,
132
- work,
133
- createdBlobs,
134
- });
135
- } catch (error) {
136
- if (error?.code === "SNAPSHOT_WORK_CAP_EXCEEDED") {
137
- if (capIsCas) throw new SkillLoopCuratorError("CURATOR_CAS_MISMATCH");
138
- return null;
139
- }
140
- throw error;
141
- }
142
- }
143
-
144
- function referencedSnapshotDigests(entries) {
145
- const digests = new Set();
146
- for (const entry of entries) {
147
- for (const item of [...entry.before, ...entry.after]) digests.add(item.blob.digest);
148
- }
149
- return digests;
150
- }
151
-
152
- const snapshotWorkFields = ["directoryEntries", "directories", "files", "bytes"];
153
-
154
- function validateSnapshotWork(work) {
155
- if (!work || Object.keys(work).sort().join(",") !== [...snapshotWorkFields].sort().join(",")
156
- || !snapshotWorkFields.every((field) => Number.isSafeInteger(work[field]) && work[field] >= 0)) {
157
- throw new SkillLoopCuratorError("CURATOR_WORK_CAP_INVALID");
158
- }
159
- return work;
160
- }
161
-
162
- const copySnapshotWork = (work) => Object.fromEntries(
163
- snapshotWorkFields.map((field) => [field, work[field]]),
164
- );
165
-
166
- const snapshotWorkDelta = (before, after) => Object.fromEntries(
167
- snapshotWorkFields.map((field) => [field, after[field] - before[field]]),
168
- );
169
-
170
- function canAffordSnapshotWork(work, costs, limits) {
171
- if (limits === null) return true;
172
- const reserved = copySnapshotWork(work);
173
- for (const cost of costs) {
174
- for (const field of snapshotWorkFields) reserved[field] += cost[field];
175
- }
176
- return reserved.directoryEntries <= limits.maxDirectoryEntries
177
- && reserved.directories <= limits.maxDirectories
178
- && reserved.files <= limits.maxFiles
179
- && reserved.bytes <= limits.maxBytes;
180
- }
181
-
182
- function assertCuratorSelectionCas(root, expectedConfig, expectedUsage) {
183
- const config = readSkillLoopConfig(root);
184
- const usage = readUsage(root);
185
- if (!isDeepStrictEqual(config, expectedConfig) || !isDeepStrictEqual(usage, expectedUsage)) {
186
- throw new SkillLoopCuratorError("CURATOR_CAS_MISMATCH");
187
- }
188
- return { config, usage };
189
- }
190
-
191
- function removeEmptyOwnedDirectory(path, identity) {
192
- if (identity === null) return;
193
- if (!directoryIdentityMatches(path, identity)) {
194
- throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
195
- }
196
- let handle;
197
- try {
198
- handle = opendirSync(path);
199
- if (handle.readSync() !== null) return;
200
- } finally {
201
- handle?.closeSync();
202
- }
203
- if (!directoryIdentityMatches(path, identity)) {
204
- throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
205
- }
206
- const quarantine = `${path}.remove.${randomUUID()}`;
207
- try {
208
- renameSync(path, quarantine);
209
- if (!directoryIdentityMatches(quarantine, identity)) throw new Error("directory identity changed");
210
- let quarantined;
211
- try {
212
- quarantined = opendirSync(quarantine);
213
- if (quarantined.readSync() !== null) throw new Error("directory gained an entry");
214
- } finally {
215
- quarantined?.closeSync();
216
- }
217
- rmdirSync(quarantine);
218
- } catch {
219
- try {
220
- if (!existsSync(path) && existsSync(quarantine)) renameSync(quarantine, path);
221
- } catch { /* Leave uncertain cleanup as a visible residue. */ }
222
- throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
223
- }
224
- }
225
-
226
- export function runCurator(root, {
227
- now = new Date().toISOString(),
228
- lastActivityAt = now,
229
- beforeTransition,
230
- beforeConfigCommit,
231
- afterIdentityCapture,
232
- afterBackupPublish,
233
- afterPostBackupCas,
234
- afterMutationJournal,
235
- afterArchiveRename,
236
- afterUsageCommit,
237
- afterLedgerCommit,
238
- afterConfigCommit,
239
- beforeDeferredCleanup,
240
- workLimits = null,
241
- snapshotWork = null,
242
- } = {}) {
243
- const nowMs = parseTimestamp(now);
244
- const lastActivityMs = parseTimestamp(lastActivityAt);
245
- if (nowMs === null || lastActivityMs === null || lastActivityMs > nowMs) {
246
- throw new SkillLoopCuratorError("CURATOR_CLOCK_INVALID");
247
- }
248
- const limits = validateWorkLimits(workLimits);
249
- const traversalWork = validateSnapshotWork(snapshotWork ?? createSnapshotWorkBudget());
250
- const snapshotOptions = limits === null ? undefined : { workLimits: limits, work: traversalWork };
251
- return withSkillLoopLock(root, "transaction", () => {
252
- recoverReviewTransactionUnlocked(root);
253
- let mutationRecovery;
254
- try {
255
- mutationRecovery = recoverSkillMutationTransactionUnlocked(root, { snapshotOptions });
256
- } catch (error) {
257
- if (error?.code === "SNAPSHOT_WORK_CAP_EXCEEDED") return deferredForWorkCap();
258
- throw error;
259
- }
260
- if (mutationRecovery.status === "finalized" && mutationRecovery.kind === "curator") {
261
- const [from, to] = mutationRecovery.transition;
262
- return {
263
- reason: "TRANSITIONS_APPLIED",
264
- transitions: [{
265
- skill: mutationRecovery.ledgerEntry.targetSkill,
266
- from,
267
- to,
268
- ledgerEntryId: mutationRecovery.ledgerEntry.id,
269
- archivedPath: mutationRecovery.archivePath,
270
- }],
271
- archiveRoot: mutationRecovery.archivePath === null ? null : join(mutationRecovery.archivePath, ".."),
272
- recovered: true,
273
- };
274
- }
275
- const createdBlobs = new Map();
276
- let referencedDigests = null;
277
- let ledgerReferenceFingerprint = null;
278
- const blobStoreExisted = Boolean(lstatSync(
279
- join(root, ".litclaude", "skill-ledger-blobs"), { throwIfNoEntry: false },
280
- ));
281
- const cleanupCreatedBlobs = ({ notify = false } = {}) => {
282
- if (createdBlobs.size > 0) {
283
- if (notify) beforeDeferredCleanup?.(new Map(createdBlobs));
284
- if (!ledgerReferenceSnapshotUnchanged(root, ledgerReferenceFingerprint)) return;
285
- cleanupCreatedSnapshotBlobs(root, createdBlobs, {
286
- removeEmptyDirectory: !blobStoreExisted,
287
- referencedDigests,
288
- });
289
- }
290
- };
291
- const deferAndCleanup = () => {
292
- cleanupCreatedBlobs({ notify: true });
293
- return deferredForWorkCap();
294
- };
295
- const config = readSkillLoopConfig(root);
296
- if (nowMs - lastActivityMs < config.curator.minIdleHours * 60 * 60 * 1000) {
297
- return { reason: "MIN_IDLE_NOT_REACHED", transitions: [] };
298
- }
299
- if (config.lastCuratorRunAt
300
- && nowMs - parseTimestamp(config.lastCuratorRunAt) < days(config.curator.intervalDays)) {
301
- return { reason: "INTERVAL_NOT_REACHED", transitions: [] };
302
- }
303
- let usage = readUsage(root);
304
- let expectedConfig = config;
305
- if (parseTimestamp(usage.updatedAt) > nowMs) throw new SkillLoopCuratorError("CURATOR_CLOCK_INVALID");
306
- const skills = ensureSafeDirectoryChain(root, [".claude", "skills"], {
307
- create: false,
308
- ErrorType: SkillLoopCuratorError,
309
- code: "CURATOR_TARGET_UNSAFE",
310
- });
311
- const eligible = [];
312
- const candidates = [];
313
- let inspectedSkills = 0;
314
- let measuredBytes = 0;
315
- const scanWork = traversalWork;
316
- for (const name of Object.keys(usage.skills).sort()) {
317
- inspectedSkills += 1;
318
- if (limits && inspectedSkills > limits.maxSkills) return deferAndCleanup();
319
- const entry = usage.skills[name];
320
- if (entry.pinned || skills === null) continue;
321
- try {
322
- resolveAgentTarget(root, skillRootFor(root), name, {
323
- allowMissing: false,
324
- requireOwned: true,
325
- ErrorType: SkillLoopCuratorError,
326
- notOwnedCode: "CURATOR_NOT_AGENT_OWNED",
327
- pathCode: "CURATOR_TARGET_UNSAFE",
328
- maxDirectoryEntries: limits?.maxDirectoryEntries ?? null,
329
- workCapCode: "CURATOR_WORK_CAP_EXCEEDED",
330
- });
331
- } catch (error) {
332
- if (error?.code === "CURATOR_WORK_CAP_EXCEEDED") return deferAndCleanup();
333
- continue;
334
- }
335
- eligible.push(name);
336
- if (limits) {
337
- const skillBytes = measuredSkillBytes(
338
- join(skills, name), limits.maxBytes - scanWork.bytes, limits, scanWork,
339
- );
340
- measuredBytes += skillBytes;
341
- if (measuredBytes > limits.maxBytes) return deferAndCleanup();
342
- }
343
- if (entry.use_count + entry.view_count + entry.patch_count === 0) continue;
344
- const idleMs = nowMs - latestActivity(entry);
345
- if (entry.state === "stale" && idleMs >= days(config.curator.archiveAfterDays)) {
346
- candidates.push({ skill: name, from: "stale", to: "archived" });
347
- } else if (entry.state === "active" && idleMs >= days(config.curator.staleAfterDays)) {
348
- candidates.push({ skill: name, from: "active", to: "stale" });
349
- }
350
- if (limits && candidates.length > limits.maxTransitions) return deferAndCleanup();
351
- }
352
- beforeConfigCommit?.();
353
- assertCuratorSelectionCas(root, expectedConfig, usage);
354
- if (candidates.length > 0) {
355
- let ledgerSnapshot;
356
- try {
357
- ledgerSnapshot = readLedgerReferenceSnapshot(root, {
358
- workLimits: SESSION_START_LEDGER_WORK_LIMITS,
359
- });
360
- } catch (error) {
361
- if (error?.code === "LEDGER_WORK_CAP_EXCEEDED") return deferredForWorkCap();
362
- throw error;
363
- }
364
- referencedDigests = referencedSnapshotDigests(ledgerSnapshot.entries);
365
- ledgerReferenceFingerprint = ledgerSnapshot.fingerprint;
366
- }
367
- const stamp = safeIsoPath(now);
368
- let backupRoot = null;
369
- let archiveRoot = null;
370
- const snapshots = new Map();
371
- if (candidates.length > 0) {
372
- for (const skill of eligible) {
373
- const snapshot = captureCuratorSnapshot(root, skill, limits, traversalWork, createdBlobs);
374
- if (snapshot === null) return deferAndCleanup();
375
- snapshots.set(skill, snapshot);
376
- }
377
- }
378
- const prepared = [];
379
- for (const candidate of candidates) {
380
- const source = join(skills, candidate.skill);
381
- const before = snapshots.get(candidate.skill)
382
- ?? captureCuratorSnapshot(root, candidate.skill, limits, traversalWork, createdBlobs);
383
- if (before === null) return deferAndCleanup();
384
- beforeTransition?.(candidate.skill);
385
- resolveAgentTarget(root, skillRootFor(root), candidate.skill, {
386
- allowMissing: false,
387
- requireOwned: true,
388
- ErrorType: SkillLoopCuratorError,
389
- notOwnedCode: "CURATOR_NOT_AGENT_OWNED",
390
- pathCode: "CURATOR_TARGET_UNSAFE",
391
- maxDirectoryEntries: limits?.maxDirectoryEntries ?? null,
392
- workCapCode: "CURATOR_WORK_CAP_EXCEEDED",
393
- });
394
- const workBeforeLive = copySnapshotWork(traversalWork);
395
- const live = captureCuratorSnapshot(root, candidate.skill, limits, traversalWork, createdBlobs);
396
- if (live === null) return deferAndCleanup();
397
- if (JSON.stringify(live) !== JSON.stringify(before)) throw new SkillLoopCuratorError("CURATOR_CAS_MISMATCH");
398
- const expectedIdentity = captureDirectoryIdentity(source, SkillLoopCuratorError, "CURATOR_TARGET_UNSAFE");
399
- afterIdentityCapture?.(candidate.skill);
400
- if (!directoryIdentityMatches(source, expectedIdentity)) {
401
- throw new SkillLoopCuratorError("CURATOR_IDENTITY_MISMATCH");
402
- }
403
- prepared.push({
404
- candidate,
405
- source,
406
- before,
407
- expectedIdentity,
408
- recoveryCost: snapshotWorkDelta(workBeforeLive, traversalWork),
409
- });
410
- }
411
- assertCuratorSelectionCas(root, expectedConfig, usage);
412
- if (!canAffordSnapshotWork(
413
- traversalWork,
414
- prepared.flatMap(({ recoveryCost }) => [recoveryCost, recoveryCost, recoveryCost]),
415
- limits,
416
- )) {
417
- return deferAndCleanup();
418
- }
419
-
420
- const transitions = [];
421
- const publishedBackups = new Set();
422
- const backupRecords = new Map();
423
- const backupOwnership = { container: null, stamp: null };
424
- const archiveOwnership = { container: null, stamp: null };
425
- const ensureTimestampRoot = (name, code, ownership) => {
426
- const containerPath = join(root, ".litclaude", name);
427
- const stampPath = join(containerPath, stamp);
428
- const containerExisted = Boolean(lstatSync(containerPath, { throwIfNoEntry: false }));
429
- const stampExisted = Boolean(lstatSync(stampPath, { throwIfNoEntry: false }));
430
- const directory = ensureSafeDirectoryChain(root, [".litclaude", name, stamp], {
431
- create: true,
432
- ErrorType: SkillLoopCuratorError,
433
- code,
434
- });
435
- if (!containerExisted && ownership.container === null) {
436
- ownership.container = {
437
- path: containerPath,
438
- identity: captureDirectoryIdentity(containerPath, SkillLoopCuratorError, code),
439
- };
440
- }
441
- if (!stampExisted && ownership.stamp === null) {
442
- ownership.stamp = {
443
- path: stampPath,
444
- identity: captureDirectoryIdentity(stampPath, SkillLoopCuratorError, code),
445
- };
446
- }
447
- return directory;
448
- };
449
- const publishBackup = (skill, snapshot) => {
450
- backupRoot ??= ensureTimestampRoot("skill-backups", "CURATOR_BACKUP_FAILED", backupOwnership);
451
- const path = join(backupRoot, skill);
452
- materializeSnapshot(root, path, snapshot);
453
- backupRecords.set(skill, {
454
- path,
455
- identity: captureDirectoryIdentity(path, SkillLoopCuratorError, "CURATOR_BACKUP_FAILED"),
456
- snapshot,
457
- committed: false,
458
- });
459
- publishedBackups.add(skill);
460
- afterBackupPublish?.(skill, path);
461
- };
462
- const cleanupUncommittedBackups = () => {
463
- const removable = [];
464
- for (const [skill, record] of backupRecords) {
465
- if (record.committed) continue;
466
- if (!directoryIdentityMatches(record.path, record.identity)) {
467
- throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
468
- }
469
- const cleanupSnapshot = captureBackupSnapshot(root, record.path, limits === null ? {} : {
470
- workLimits: limits,
471
- work: createSnapshotWorkBudget(),
472
- });
473
- if (!isDeepStrictEqual(cleanupSnapshot, record.snapshot)) {
474
- throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
475
- }
476
- removable.push([skill, record]);
477
- }
478
- for (const [skill, record] of removable) {
479
- if (!directoryIdentityMatches(record.path, record.identity)) {
480
- throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
481
- }
482
- const quarantine = join(skills, `.${skill}.rollback-${randomUUID()}`);
483
- try {
484
- renameSync(record.path, quarantine);
485
- if (!directoryIdentityMatches(quarantine, record.identity)) throw new Error("backup identity changed");
486
- const quarantinedSnapshot = captureStagedSnapshot(root, quarantine, limits === null ? {} : {
487
- workLimits: limits,
488
- work: createSnapshotWorkBudget(),
489
- });
490
- if (!isDeepStrictEqual(quarantinedSnapshot, record.snapshot)) {
491
- throw new Error("backup contents changed");
492
- }
493
- rmSync(quarantine, { recursive: true, force: true });
494
- } catch {
495
- try {
496
- if (!existsSync(record.path) && existsSync(quarantine)) renameSync(quarantine, record.path);
497
- } catch { /* Leave uncertain cleanup as a visible residue. */ }
498
- throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
499
- }
500
- backupRecords.delete(skill);
501
- }
502
- removeEmptyOwnedDirectory(backupOwnership.stamp?.path, backupOwnership.stamp?.identity ?? null);
503
- removeEmptyOwnedDirectory(backupOwnership.container?.path, backupOwnership.container?.identity ?? null);
504
- };
505
- const cleanupOwnedArchiveDirectories = () => {
506
- removeEmptyOwnedDirectory(archiveOwnership.stamp?.path, archiveOwnership.stamp?.identity ?? null);
507
- removeEmptyOwnedDirectory(archiveOwnership.container?.path, archiveOwnership.container?.identity ?? null);
508
- };
509
- for (const { candidate, source, before, expectedIdentity } of prepared) {
510
- if (!directoryIdentityMatches(source, expectedIdentity)) {
511
- throw new SkillLoopCuratorError("CURATOR_IDENTITY_MISMATCH");
512
- }
513
- publishBackup(candidate.skill, before);
514
- const live = captureCuratorSnapshot(root, candidate.skill, limits, traversalWork, createdBlobs);
515
- if (live === null) {
516
- cleanupUncommittedBackups();
517
- if (transitions.length > 0) {
518
- cleanupCreatedBlobs();
519
- throw new SkillLoopCuratorError("CURATOR_CAS_MISMATCH");
520
- }
521
- return deferAndCleanup();
522
- }
523
- if (!isDeepStrictEqual(live, before) || !directoryIdentityMatches(source, expectedIdentity)) {
524
- cleanupUncommittedBackups();
525
- cleanupCreatedBlobs();
526
- throw new SkillLoopCuratorError("CURATOR_CAS_MISMATCH");
527
- }
528
- let selectionCas;
529
- try {
530
- selectionCas = assertCuratorSelectionCas(root, expectedConfig, usage);
531
- } catch (error) {
532
- cleanupUncommittedBackups();
533
- cleanupCreatedBlobs();
534
- throw error;
535
- }
536
- afterPostBackupCas?.(candidate.skill);
537
- let archivedPath = null;
538
- if (candidate.to === "archived") {
539
- archiveRoot ??= ensureTimestampRoot("skill-archives", "CURATOR_ARCHIVE_FAILED", archiveOwnership);
540
- archivedPath = join(archiveRoot, candidate.skill);
541
- if (existsSync(archivedPath)) throw new SkillLoopCuratorError("CURATOR_ARCHIVE_FAILED");
542
- }
543
- const after = candidate.to === "archived" ? [] : before;
544
- const usageBefore = selectionCas.usage;
545
- const usageAfter = structuredClone(usageBefore);
546
- usageAfter.skills[candidate.skill].state = candidate.to;
547
- usageAfter.skills[candidate.skill].archived_at = candidate.to === "archived" ? now : null;
548
- usageAfter.updatedAt = now;
549
- const configBefore = selectionCas.config;
550
- const configAfter = { ...configBefore, lastCuratorRunAt: now };
551
- const ledger = {
552
- schema: "litfamily.skill-ledger/v1",
553
- id: `ledger-${randomUUID()}`,
554
- createdAt: now,
555
- proposalId: null,
556
- actor: "curator",
557
- operation: "curator-transition",
558
- targetSkill: candidate.skill,
559
- targetRoot: skillRootFor(root),
560
- before,
561
- after,
562
- reason: `usage:${candidate.from}->${candidate.to}`,
563
- };
564
- beginSkillMutationTransactionUnlocked(root, {
565
- schema: "litclaude.skill-mutation-transaction/v1",
566
- id: ledger.id,
567
- kind: "curator",
568
- requestId: ledger.id,
569
- createdAt: now,
570
- targetRoot: ledger.targetRoot,
571
- targetSkill: candidate.skill,
572
- action: candidate.to === "archived" ? "curator-archive" : "curator-stale",
573
- stageName: null,
574
- displacedName: null,
575
- archiveParts: candidate.to === "archived"
576
- ? ["skill-archives", stamp, candidate.skill]
577
- : null,
578
- beforeIdentity: expectedIdentity,
579
- stageIdentity: null,
580
- before,
581
- after,
582
- ledgerEntry: ledger,
583
- proposalBefore: null,
584
- proposalAfter: null,
585
- usageBefore,
586
- usageAfter,
587
- configBefore,
588
- configAfter,
589
- abortRequested: false,
590
- });
591
- afterMutationJournal?.(candidate.skill);
592
- const rollbackCandidateRace = () => {
593
- const rolledBack = requestCuratorMutationRollbackUnlocked(root, ledger.id);
594
- if (rolledBack.status !== "rolled-back") {
595
- throw new SkillLoopCuratorError("CURATOR_STATE_UNKNOWN");
596
- }
597
- cleanupOwnedArchiveDirectories();
598
- cleanupUncommittedBackups();
599
- cleanupCreatedBlobs();
600
- throw new SkillLoopCuratorError("CURATOR_CAS_MISMATCH");
601
- };
602
- if (candidate.to === "archived") {
603
- renameSync(source, archivedPath);
604
- if (!directoryIdentityMatches(archivedPath, expectedIdentity)) {
605
- throw new SkillLoopCuratorError("CURATOR_IDENTITY_MISMATCH");
606
- }
607
- afterArchiveRename?.(candidate.skill);
608
- let archiveRace = false;
609
- try {
610
- const archivedLive = captureStagedSnapshot(root, archivedPath, limits === null ? {
611
- createdBlobs,
612
- } : {
613
- workLimits: limits,
614
- work: traversalWork,
615
- createdBlobs,
616
- });
617
- archiveRace = !isDeepStrictEqual(archivedLive, before);
618
- } catch (error) {
619
- if (["SNAPSHOT_WORK_CAP_EXCEEDED", "SNAPSHOT_PATH_UNSAFE"].includes(error?.code)) {
620
- archiveRace = true;
621
- } else {
622
- throw error;
623
- }
624
- }
625
- if (archiveRace) {
626
- rollbackCandidateRace();
627
- }
628
- } else {
629
- let staleRace = false;
630
- try {
631
- const staleLive = captureCuratorSnapshot(
632
- root, candidate.skill, limits, traversalWork, createdBlobs,
633
- );
634
- staleRace = staleLive === null || !isDeepStrictEqual(staleLive, before)
635
- || !directoryIdentityMatches(source, expectedIdentity);
636
- } catch (error) {
637
- if (error?.code === "SNAPSHOT_PATH_UNSAFE") staleRace = true;
638
- else throw error;
639
- }
640
- if (staleRace) {
641
- rollbackCandidateRace();
642
- }
643
- }
644
- const finalized = recoverSkillMutationTransactionUnlocked(root, {
645
- afterUsageCommit: () => afterUsageCommit?.(candidate.skill),
646
- afterLedgerCommit: () => afterLedgerCommit?.(candidate.skill),
647
- afterConfigCommit: () => afterConfigCommit?.(candidate.skill),
648
- snapshotOptions,
649
- });
650
- if (finalized.status !== "finalized") throw new SkillLoopCuratorError("CURATOR_STATE_UNKNOWN");
651
- usage = usageAfter;
652
- expectedConfig = configAfter;
653
- backupRecords.get(candidate.skill).committed = true;
654
- for (const item of [...ledger.before, ...ledger.after]) referencedDigests.add(item.blob.digest);
655
- transitions.push({ ...candidate, ledgerEntryId: ledger.id, archivedPath });
656
- }
657
- for (const [skill, snapshot] of snapshots) {
658
- if (!publishedBackups.has(skill)) {
659
- publishBackup(skill, snapshot);
660
- backupRecords.get(skill).committed = true;
661
- }
662
- }
663
- if (candidates.length === 0) {
664
- usage.updatedAt = now;
665
- writeUsageUnlocked(root, usage);
666
- writeSkillLoopConfigUnlocked(root, { ...config, lastCuratorRunAt: now });
667
- }
668
- return { reason: candidates.length > 0 ? "TRANSITIONS_APPLIED" : "NO_ELIGIBLE_TRANSITIONS", transitions, backupRoot, archiveRoot };
669
- }, SkillLoopCuratorError, "CURATOR_LOCK_FAILED");
670
- }