@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.
- package/CHANGELOG.md +5 -5
- package/README.md +11 -38
- package/README_ko-KR.md +11 -37
- package/docs/assets/cover-motion.webp +0 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/privacy.md +12 -17
- package/package.json +3 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-hook.js +20 -57
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/hooks.json +4 -4
- package/plugins/litclaude/lib/canonical-skill-catalog.mjs +0 -1
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +5 -27
- package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
- package/plugins/litclaude/lib/litgoal/paths.mjs +10 -8
- package/plugins/litclaude/lib/project-state-root.mjs +22 -0
- package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
- package/plugins/litclaude/lib/start-work-cli.mjs +3 -3
- package/plugins/litclaude/lib/wikify-knowledge-cli.mjs +3 -1
- package/plugins/litclaude/lib/wikify-knowledge.mjs +6 -5
- package/plugins/litclaude/scripts/scaffold-plan.mjs +9 -3
- package/plugins/litclaude/skills/lit-team/scripts/team.mjs +4 -3
- package/scripts/prepublish-test-gate.mjs +26 -0
- package/plugins/litclaude/lib/skill-loop/apply.mjs +0 -391
- package/plugins/litclaude/lib/skill-loop/authority.mjs +0 -129
- package/plugins/litclaude/lib/skill-loop/cli.mjs +0 -67
- package/plugins/litclaude/lib/skill-loop/common.mjs +0 -280
- package/plugins/litclaude/lib/skill-loop/config.mjs +0 -91
- package/plugins/litclaude/lib/skill-loop/curator.mjs +0 -670
- package/plugins/litclaude/lib/skill-loop/hook-runtime.mjs +0 -188
- package/plugins/litclaude/lib/skill-loop/hook-state.mjs +0 -484
- package/plugins/litclaude/lib/skill-loop/ledger.mjs +0 -834
- package/plugins/litclaude/lib/skill-loop/mutation-transaction.mjs +0 -529
- package/plugins/litclaude/lib/skill-loop/proposal-reader.mjs +0 -26
- package/plugins/litclaude/lib/skill-loop/proposal-record.mjs +0 -104
- package/plugins/litclaude/lib/skill-loop/proposal-store.mjs +0 -187
- package/plugins/litclaude/lib/skill-loop/proposals.mjs +0 -26
- package/plugins/litclaude/lib/skill-loop/review-runner.mjs +0 -13
- package/plugins/litclaude/lib/skill-loop/review-transaction.mjs +0 -358
- package/plugins/litclaude/lib/skill-loop/review.mjs +0 -223
- package/plugins/litclaude/lib/skill-loop/usage.mjs +0 -218
- package/plugins/litclaude/lib/skill-observer.mjs +0 -693
- package/plugins/litclaude/skills/skill-observer/SKILL.md +0 -155
- package/plugins/litclaude/skills/skill-observer/references/review-contract.md +0 -77
- package/plugins/litclaude/skills/skill-observer/scripts/skill-loop.mjs +0 -10
|
@@ -1,834 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
lstatSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
opendirSync,
|
|
7
|
-
renameSync,
|
|
8
|
-
rmdirSync,
|
|
9
|
-
rmSync,
|
|
10
|
-
} from "node:fs";
|
|
11
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
12
|
-
|
|
13
|
-
import { parseStrictJson } from "../strict-json.mjs";
|
|
14
|
-
import { resolveAgentTarget } from "./authority.mjs";
|
|
15
|
-
import {
|
|
16
|
-
assertNoSecretStrings,
|
|
17
|
-
assertRegularSingleLink,
|
|
18
|
-
atomicWriteBytes,
|
|
19
|
-
captureDirectoryIdentity,
|
|
20
|
-
decodeSkillLoopUtf8,
|
|
21
|
-
directoryIdentityMatches,
|
|
22
|
-
ensureSafeDirectoryChain,
|
|
23
|
-
isPlainObject,
|
|
24
|
-
parseTimestamp,
|
|
25
|
-
portablePathIdentity,
|
|
26
|
-
readStrictJsonFile,
|
|
27
|
-
RECORD_ID,
|
|
28
|
-
requireExactKeys,
|
|
29
|
-
sha256,
|
|
30
|
-
skillRootFor,
|
|
31
|
-
skillRootsEquivalent,
|
|
32
|
-
SKILL_ID,
|
|
33
|
-
withSkillLoopLock,
|
|
34
|
-
} from "./common.mjs";
|
|
35
|
-
import { proposalFilePath, readProposalRecord, transitionProposalRecordUnlocked } from "./proposal-store.mjs";
|
|
36
|
-
import { recoverReviewTransactionUnlocked } from "./review-transaction.mjs";
|
|
37
|
-
import { readUsage, writeUsageUnlocked } from "./usage.mjs";
|
|
38
|
-
import {
|
|
39
|
-
beginSkillMutationTransactionUnlocked,
|
|
40
|
-
recoverSkillMutationTransactionUnlocked,
|
|
41
|
-
} from "./mutation-transaction.mjs";
|
|
42
|
-
|
|
43
|
-
export class SkillLoopLedgerError extends Error {
|
|
44
|
-
constructor(code, message = code) {
|
|
45
|
-
super(`${code}: ${message}`);
|
|
46
|
-
this.name = "SkillLoopLedgerError";
|
|
47
|
-
this.code = code;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export const SESSION_START_LEDGER_WORK_LIMITS = Object.freeze({
|
|
52
|
-
maxEntries: 256,
|
|
53
|
-
maxBlobs: 512,
|
|
54
|
-
maxBlobBytes: 32 * 1024 * 1024,
|
|
55
|
-
maxTargetEntries: 256,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
export const SNAPSHOT_WORK_LIMITS = Object.freeze({
|
|
59
|
-
maxDirectoryEntries: 1_024,
|
|
60
|
-
maxDirectories: 128,
|
|
61
|
-
maxDepth: 32,
|
|
62
|
-
maxFiles: 256,
|
|
63
|
-
maxBytes: 32 * 1024 * 1024,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
export function createSnapshotWorkBudget() {
|
|
67
|
-
return { directoryEntries: 0, directories: 0, files: 0, bytes: 0 };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function validateLedgerWorkLimits(limits) {
|
|
71
|
-
if (limits === null || limits === undefined) return null;
|
|
72
|
-
const normalized = { ...SESSION_START_LEDGER_WORK_LIMITS, ...limits };
|
|
73
|
-
if (!limits || ![
|
|
74
|
-
normalized.maxEntries, normalized.maxBlobs, normalized.maxBlobBytes, normalized.maxTargetEntries,
|
|
75
|
-
].every(Number.isSafeInteger)
|
|
76
|
-
|| normalized.maxEntries < 1 || normalized.maxEntries > 4096
|
|
77
|
-
|| normalized.maxBlobs < 1 || normalized.maxBlobs > 8192
|
|
78
|
-
|| normalized.maxBlobBytes < 1 || normalized.maxBlobBytes > 256 * 1024 * 1024
|
|
79
|
-
|| normalized.maxTargetEntries < 1 || normalized.maxTargetEntries > 4096) {
|
|
80
|
-
throw new SkillLoopLedgerError("LEDGER_WORK_CAP_INVALID");
|
|
81
|
-
}
|
|
82
|
-
return normalized;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const ledgerFile = (root, create) => {
|
|
86
|
-
const state = ensureSafeDirectoryChain(root, [".litclaude"], {
|
|
87
|
-
create,
|
|
88
|
-
ErrorType: SkillLoopLedgerError,
|
|
89
|
-
code: "LEDGER_STORE_UNSAFE",
|
|
90
|
-
});
|
|
91
|
-
return state === null ? null : join(state, "skill-ledger.jsonl");
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
function captureLedgerStoreFingerprint(root) {
|
|
95
|
-
const path = ledgerFile(root, false);
|
|
96
|
-
if (path === null) return { exists: false };
|
|
97
|
-
const details = lstatSync(path, { throwIfNoEntry: false });
|
|
98
|
-
if (!details) return { exists: false };
|
|
99
|
-
const bytes = assertRegularSingleLink(path, SkillLoopLedgerError, "LEDGER_STORE_UNSAFE", {
|
|
100
|
-
maxBytes: 16 * 1024 * 1024,
|
|
101
|
-
});
|
|
102
|
-
return {
|
|
103
|
-
exists: true,
|
|
104
|
-
dev: details.dev,
|
|
105
|
-
ino: details.ino,
|
|
106
|
-
size: bytes.length,
|
|
107
|
-
digest: sha256(bytes),
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function validLedgerStoreFingerprint(fingerprint) {
|
|
112
|
-
return isPlainObject(fingerprint) && (fingerprint.exists === false
|
|
113
|
-
? requireExactKeys(fingerprint, ["exists"])
|
|
114
|
-
: fingerprint.exists === true
|
|
115
|
-
&& requireExactKeys(fingerprint, ["exists", "dev", "ino", "size", "digest"])
|
|
116
|
-
&& Number.isSafeInteger(fingerprint.dev) && fingerprint.dev >= 0
|
|
117
|
-
&& Number.isSafeInteger(fingerprint.ino) && fingerprint.ino > 0
|
|
118
|
-
&& Number.isSafeInteger(fingerprint.size) && fingerprint.size >= 0
|
|
119
|
-
&& /^[a-f0-9]{64}$/u.test(fingerprint.digest));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const blobDirectory = (root, create) => ensureSafeDirectoryChain(root, [".litclaude", "skill-ledger-blobs"], {
|
|
123
|
-
create,
|
|
124
|
-
ErrorType: SkillLoopLedgerError,
|
|
125
|
-
code: "LEDGER_STORE_UNSAFE",
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
export function ledgerBlobPath(root, digest) {
|
|
129
|
-
if (!/^[a-f0-9]{64}$/u.test(digest)) throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
130
|
-
const directory = blobDirectory(root, false);
|
|
131
|
-
return directory === null ? join(root, ".litclaude", "skill-ledger-blobs", digest) : join(directory, digest);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function validateSnapshot(snapshot) {
|
|
135
|
-
if (!Array.isArray(snapshot) || snapshot.length > 256) throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
136
|
-
const canonical = new Set();
|
|
137
|
-
for (const item of snapshot) {
|
|
138
|
-
const identity = portablePathIdentity(item?.file);
|
|
139
|
-
if (identity === null || canonical.has(identity)
|
|
140
|
-
|| !requireExactKeys(item, ["file", "blob"])
|
|
141
|
-
|| !requireExactKeys(item.blob, ["algorithm", "digest", "size"])
|
|
142
|
-
|| item.blob.algorithm !== "sha256" || !/^[a-f0-9]{64}$/u.test(item.blob.digest)
|
|
143
|
-
|| !Number.isSafeInteger(item.blob.size) || item.blob.size < 0) {
|
|
144
|
-
throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
145
|
-
}
|
|
146
|
-
canonical.add(identity);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const snapshotsEqual = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
151
|
-
const usageTransition = (entry) => /^usage:(active|stale|archived)->(active|stale|archived)$/u.exec(
|
|
152
|
-
entry?.reason ?? "",
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
export function rollbackEntryMatchesReference(reference, rollback) {
|
|
156
|
-
if (!reference || !rollback || !["apply", "curator-transition", "rollback"].includes(reference.operation)
|
|
157
|
-
|| reference.actor !== (reference.operation === "curator-transition" ? "curator" : "user")
|
|
158
|
-
|| rollback.operation !== "rollback" || rollback.actor !== "user"
|
|
159
|
-
|| rollback.restoresLedgerEntryId !== reference.id
|
|
160
|
-
|| rollback.proposalId !== reference.proposalId
|
|
161
|
-
|| rollback.targetSkill !== reference.targetSkill || rollback.targetRoot !== reference.targetRoot
|
|
162
|
-
|| !snapshotsEqual(rollback.before, reference.after)
|
|
163
|
-
|| !snapshotsEqual(rollback.after, reference.before)) return false;
|
|
164
|
-
const referenceTransition = usageTransition(reference);
|
|
165
|
-
const rollbackTransition = usageTransition(rollback);
|
|
166
|
-
if (referenceTransition) {
|
|
167
|
-
return rollbackTransition !== null
|
|
168
|
-
&& rollbackTransition[1] === referenceTransition[2]
|
|
169
|
-
&& rollbackTransition[2] === referenceTransition[1];
|
|
170
|
-
}
|
|
171
|
-
return !Object.hasOwn(reference, "reason") && !Object.hasOwn(rollback, "reason");
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function validateLedgerEntry(entry) {
|
|
175
|
-
if (!requireExactKeys(entry, [
|
|
176
|
-
"schema", "id", "createdAt", "proposalId", "actor", "operation", "targetSkill", "targetRoot",
|
|
177
|
-
"before", "after",
|
|
178
|
-
], ["restoresLedgerEntryId", "reason"])
|
|
179
|
-
|| entry.schema !== "litfamily.skill-ledger/v1" || !RECORD_ID.test(entry.id)
|
|
180
|
-
|| parseTimestamp(entry.createdAt) === null
|
|
181
|
-
|| !(entry.proposalId === null || (typeof entry.proposalId === "string" && entry.proposalId.length > 0 && entry.proposalId.length <= 128))
|
|
182
|
-
|| !["user", "curator"].includes(entry.actor)
|
|
183
|
-
|| !["apply", "reject", "rollback", "curator-transition"].includes(entry.operation)
|
|
184
|
-
|| !SKILL_ID.test(entry.targetSkill) || portablePathIdentity(entry.targetSkill) === null
|
|
185
|
-
|| typeof entry.targetRoot !== "string" || entry.targetRoot.length < 1 || entry.targetRoot.length > 2048
|
|
186
|
-
|| (Object.hasOwn(entry, "reason") && (typeof entry.reason !== "string" || entry.reason.length > 8192))) {
|
|
187
|
-
throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
188
|
-
}
|
|
189
|
-
const rollback = entry.operation === "rollback";
|
|
190
|
-
if (rollback !== Object.hasOwn(entry, "restoresLedgerEntryId")
|
|
191
|
-
|| (rollback && !RECORD_ID.test(entry.restoresLedgerEntryId))) throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
192
|
-
if (["apply", "reject"].includes(entry.operation) && (entry.actor !== "user" || entry.proposalId === null)) {
|
|
193
|
-
throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
194
|
-
}
|
|
195
|
-
if (entry.operation === "rollback" && entry.actor !== "user") throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
196
|
-
if (entry.operation === "curator-transition" && (entry.actor !== "curator" || entry.proposalId !== null)) {
|
|
197
|
-
throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
198
|
-
}
|
|
199
|
-
validateSnapshot(entry.before);
|
|
200
|
-
validateSnapshot(entry.after);
|
|
201
|
-
if (entry.operation === "reject" && (entry.before.length !== 0 || entry.after.length !== 0)) {
|
|
202
|
-
throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
203
|
-
}
|
|
204
|
-
assertNoSecretStrings(entry, () => { throw new SkillLoopLedgerError("LEDGER_SECRET_REFUSED"); });
|
|
205
|
-
return entry;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export function readLedger(root, { workLimits = null } = {}) {
|
|
209
|
-
const limits = validateLedgerWorkLimits(workLimits);
|
|
210
|
-
const path = ledgerFile(root, false);
|
|
211
|
-
if (path === null || !lstatSync(path, { throwIfNoEntry: false })) return [];
|
|
212
|
-
let text;
|
|
213
|
-
try {
|
|
214
|
-
text = decodeSkillLoopUtf8(assertRegularSingleLink(path, SkillLoopLedgerError, "LEDGER_STORE_UNSAFE", {
|
|
215
|
-
maxBytes: 16 * 1024 * 1024,
|
|
216
|
-
}));
|
|
217
|
-
} catch (error) {
|
|
218
|
-
if (error instanceof SkillLoopLedgerError) throw error;
|
|
219
|
-
throw new SkillLoopLedgerError("LEDGER_STORE_INVALID");
|
|
220
|
-
}
|
|
221
|
-
const records = [];
|
|
222
|
-
const ids = new Set();
|
|
223
|
-
try {
|
|
224
|
-
const lines = text.split("\n");
|
|
225
|
-
if (lines.at(-1) === "") lines.pop();
|
|
226
|
-
for (const line of lines) {
|
|
227
|
-
if (line === "") throw new SkillLoopLedgerError("LEDGER_STORE_INVALID");
|
|
228
|
-
const entry = validateLedgerEntry(parseStrictJson(line));
|
|
229
|
-
if (ids.has(entry.id)) throw new SkillLoopLedgerError("LEDGER_SCHEMA_INVALID");
|
|
230
|
-
ids.add(entry.id);
|
|
231
|
-
records.push(entry);
|
|
232
|
-
if (limits && records.length > limits.maxEntries) {
|
|
233
|
-
throw new SkillLoopLedgerError("LEDGER_WORK_CAP_EXCEEDED");
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
} catch (error) {
|
|
237
|
-
if (error instanceof SkillLoopLedgerError) throw error;
|
|
238
|
-
throw new SkillLoopLedgerError("LEDGER_STORE_INVALID");
|
|
239
|
-
}
|
|
240
|
-
const byId = new Map(records.map((entry) => [entry.id, entry]));
|
|
241
|
-
const visiting = new Set();
|
|
242
|
-
const visited = new Set();
|
|
243
|
-
const visit = (entry) => {
|
|
244
|
-
if (visited.has(entry.id) || entry.operation !== "rollback") return;
|
|
245
|
-
if (visiting.has(entry.id)) throw new SkillLoopLedgerError("ROLLBACK_CYCLE");
|
|
246
|
-
visiting.add(entry.id);
|
|
247
|
-
const referenced = byId.get(entry.restoresLedgerEntryId);
|
|
248
|
-
if (referenced) visit(referenced);
|
|
249
|
-
visiting.delete(entry.id);
|
|
250
|
-
visited.add(entry.id);
|
|
251
|
-
};
|
|
252
|
-
for (const entry of records) visit(entry);
|
|
253
|
-
const prior = new Map();
|
|
254
|
-
const blobs = new Map();
|
|
255
|
-
let blobBytes = 0;
|
|
256
|
-
for (const entry of records) {
|
|
257
|
-
if (entry.operation !== "reject") {
|
|
258
|
-
resolveAgentTarget(root, entry.targetRoot, entry.targetSkill, {
|
|
259
|
-
allowMissing: true,
|
|
260
|
-
requireOwned: false,
|
|
261
|
-
ErrorType: SkillLoopLedgerError,
|
|
262
|
-
notOwnedCode: "TARGET_NOT_AGENT_OWNED",
|
|
263
|
-
pathCode: "SNAPSHOT_PATH_UNSAFE",
|
|
264
|
-
collisionCode: "TARGET_IDENTITY_COLLISION",
|
|
265
|
-
maxDirectoryEntries: limits?.maxTargetEntries ?? null,
|
|
266
|
-
workCapCode: "LEDGER_WORK_CAP_EXCEEDED",
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
for (const item of [...entry.before, ...entry.after]) {
|
|
270
|
-
const existing = blobs.get(item.blob.digest);
|
|
271
|
-
if (existing && existing.blob.size !== item.blob.size) throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
272
|
-
if (existing) continue;
|
|
273
|
-
blobs.set(item.blob.digest, item);
|
|
274
|
-
blobBytes += item.blob.size;
|
|
275
|
-
if (limits && (blobs.size > limits.maxBlobs || blobBytes > limits.maxBlobBytes)) {
|
|
276
|
-
throw new SkillLoopLedgerError("LEDGER_WORK_CAP_EXCEEDED");
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
if (entry.operation === "rollback") {
|
|
280
|
-
if (entry.restoresLedgerEntryId === entry.id) throw new SkillLoopLedgerError("ROLLBACK_CYCLE");
|
|
281
|
-
const referenced = prior.get(entry.restoresLedgerEntryId);
|
|
282
|
-
if (!referenced) {
|
|
283
|
-
if (byId.has(entry.restoresLedgerEntryId)) throw new SkillLoopLedgerError("ROLLBACK_FORWARD_REFERENCE");
|
|
284
|
-
throw new SkillLoopLedgerError("ROLLBACK_ENTRY_INELIGIBLE");
|
|
285
|
-
}
|
|
286
|
-
if (!["apply", "curator-transition", "rollback"].includes(referenced.operation)) {
|
|
287
|
-
throw new SkillLoopLedgerError("ROLLBACK_ENTRY_INELIGIBLE");
|
|
288
|
-
}
|
|
289
|
-
if (referenced.targetSkill !== entry.targetSkill || referenced.targetRoot !== entry.targetRoot) {
|
|
290
|
-
throw new SkillLoopLedgerError("ROLLBACK_TARGET_MISMATCH");
|
|
291
|
-
}
|
|
292
|
-
if (!rollbackEntryMatchesReference(referenced, entry)) {
|
|
293
|
-
throw new SkillLoopLedgerError("ROLLBACK_INVERSE_INVALID");
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
prior.set(entry.id, entry);
|
|
297
|
-
}
|
|
298
|
-
for (const item of blobs.values()) verifySnapshotBlobs(root, [item]);
|
|
299
|
-
return records;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
export function readLedgerReferenceSnapshot(root, {
|
|
303
|
-
workLimits = SESSION_START_LEDGER_WORK_LIMITS,
|
|
304
|
-
} = {}) {
|
|
305
|
-
const before = captureLedgerStoreFingerprint(root);
|
|
306
|
-
const entries = readLedger(root, { workLimits });
|
|
307
|
-
const fingerprint = captureLedgerStoreFingerprint(root);
|
|
308
|
-
if (JSON.stringify(before) !== JSON.stringify(fingerprint)) {
|
|
309
|
-
throw new SkillLoopLedgerError("LEDGER_STORE_INVALID");
|
|
310
|
-
}
|
|
311
|
-
return { entries, fingerprint };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
export function ledgerReferenceSnapshotUnchanged(root, fingerprint) {
|
|
315
|
-
if (!validLedgerStoreFingerprint(fingerprint)) return false;
|
|
316
|
-
try {
|
|
317
|
-
return JSON.stringify(captureLedgerStoreFingerprint(root)) === JSON.stringify(fingerprint);
|
|
318
|
-
} catch {
|
|
319
|
-
return false;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export function appendLedgerEntryUnlocked(root, entry) {
|
|
324
|
-
validateLedgerEntry(entry);
|
|
325
|
-
const existing = readLedger(root);
|
|
326
|
-
if (existing.some(({ id }) => id === entry.id)) throw new SkillLoopLedgerError("LEDGER_ID_COLLISION");
|
|
327
|
-
if (entry.operation !== "reject") {
|
|
328
|
-
resolveAgentTarget(root, entry.targetRoot, entry.targetSkill, {
|
|
329
|
-
allowMissing: true,
|
|
330
|
-
requireOwned: false,
|
|
331
|
-
ErrorType: SkillLoopLedgerError,
|
|
332
|
-
notOwnedCode: "TARGET_NOT_AGENT_OWNED",
|
|
333
|
-
pathCode: "SNAPSHOT_PATH_UNSAFE",
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
verifySnapshotBlobs(root, entry.before);
|
|
337
|
-
verifySnapshotBlobs(root, entry.after);
|
|
338
|
-
if (entry.operation === "rollback") {
|
|
339
|
-
if (entry.restoresLedgerEntryId === entry.id) throw new SkillLoopLedgerError("ROLLBACK_CYCLE");
|
|
340
|
-
const referenced = existing.find(({ id }) => id === entry.restoresLedgerEntryId);
|
|
341
|
-
if (!referenced || !["apply", "curator-transition", "rollback"].includes(referenced.operation)) {
|
|
342
|
-
throw new SkillLoopLedgerError("ROLLBACK_ENTRY_INELIGIBLE");
|
|
343
|
-
}
|
|
344
|
-
if (referenced.targetSkill !== entry.targetSkill || referenced.targetRoot !== entry.targetRoot) {
|
|
345
|
-
throw new SkillLoopLedgerError("ROLLBACK_TARGET_MISMATCH");
|
|
346
|
-
}
|
|
347
|
-
if (!rollbackEntryMatchesReference(referenced, entry)) {
|
|
348
|
-
throw new SkillLoopLedgerError("ROLLBACK_INVERSE_INVALID");
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
const path = ledgerFile(root, true);
|
|
352
|
-
const current = lstatSync(path, { throwIfNoEntry: false });
|
|
353
|
-
if (current && (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1)) {
|
|
354
|
-
throw new SkillLoopLedgerError("LEDGER_STORE_UNSAFE");
|
|
355
|
-
}
|
|
356
|
-
try {
|
|
357
|
-
const prior = current
|
|
358
|
-
? assertRegularSingleLink(path, SkillLoopLedgerError, "LEDGER_STORE_UNSAFE", { maxBytes: 16 * 1024 * 1024 })
|
|
359
|
-
: Buffer.alloc(0);
|
|
360
|
-
const addition = Buffer.from(`${JSON.stringify(entry)}\n`, "utf8");
|
|
361
|
-
if (prior.length + addition.length > 16 * 1024 * 1024) {
|
|
362
|
-
throw new SkillLoopLedgerError("LEDGER_TOO_LARGE");
|
|
363
|
-
}
|
|
364
|
-
atomicWriteBytes(path, Buffer.concat([prior, addition]),
|
|
365
|
-
SkillLoopLedgerError, "LEDGER_APPEND_FAILED");
|
|
366
|
-
} catch (error) {
|
|
367
|
-
if (error instanceof SkillLoopLedgerError) throw error;
|
|
368
|
-
throw new SkillLoopLedgerError("LEDGER_APPEND_FAILED");
|
|
369
|
-
}
|
|
370
|
-
return entry;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export function appendLedgerEntry(root, entry) {
|
|
374
|
-
return withSkillLoopLock(root, "transaction", () => {
|
|
375
|
-
recoverReviewTransactionUnlocked(root);
|
|
376
|
-
recoverSkillMutationTransactionUnlocked(root);
|
|
377
|
-
return appendLedgerEntryUnlocked(root, entry);
|
|
378
|
-
},
|
|
379
|
-
SkillLoopLedgerError, "LEDGER_LOCK_FAILED");
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export function proposalReceiptMatches(proposal, entry, { operation, currentSnapshot } = {}) {
|
|
383
|
-
if (!entry || entry.operation !== operation || entry.proposalId !== proposal.id
|
|
384
|
-
|| entry.targetSkill !== proposal.targetSkill || entry.targetRoot !== proposal.targetRoot
|
|
385
|
-
|| parseTimestamp(entry.createdAt) === null || parseTimestamp(proposal.createdAt) === null
|
|
386
|
-
|| parseTimestamp(entry.createdAt) < parseTimestamp(proposal.createdAt)) return false;
|
|
387
|
-
if (operation === "apply" && currentSnapshot !== undefined
|
|
388
|
-
&& !snapshotsEqual(currentSnapshot, entry.after)) return false;
|
|
389
|
-
return true;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
export function validateProposalReceipt(root, proposal, ledgerIndex) {
|
|
393
|
-
const index = ledgerIndex ?? new Map(readLedger(root).map((entry) => [entry.id, entry]));
|
|
394
|
-
const entry = index.get(proposal.ledgerEntryId);
|
|
395
|
-
const expectedOperation = proposal.status === "applied" ? "apply" : "rollback";
|
|
396
|
-
if (!proposalReceiptMatches(proposal, entry, { operation: expectedOperation })) {
|
|
397
|
-
throw new SkillLoopLedgerError("PROPOSAL_LEDGER_INVALID");
|
|
398
|
-
}
|
|
399
|
-
return entry;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function persistBlob(root, bytes, createdBlobs) {
|
|
403
|
-
const digest = sha256(bytes);
|
|
404
|
-
const directory = blobDirectory(root, true);
|
|
405
|
-
const path = join(directory, digest);
|
|
406
|
-
if (existsSync(path)) {
|
|
407
|
-
const current = assertRegularSingleLink(path, SkillLoopLedgerError, "LEDGER_BLOB_INVALID", {
|
|
408
|
-
maxBytes: 8 * 1024 * 1024,
|
|
409
|
-
});
|
|
410
|
-
if (current.length !== bytes.length || sha256(current) !== digest) throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
411
|
-
} else {
|
|
412
|
-
atomicWriteBytes(path, bytes, SkillLoopLedgerError, "LEDGER_STORE_UNSAFE");
|
|
413
|
-
const details = lstatSync(path, { throwIfNoEntry: false });
|
|
414
|
-
if (!details?.isFile() || details.isSymbolicLink() || details.nlink !== 1) {
|
|
415
|
-
throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
416
|
-
}
|
|
417
|
-
createdBlobs?.set(digest, { dev: details.dev, ino: details.ino });
|
|
418
|
-
}
|
|
419
|
-
return { algorithm: "sha256", digest, size: bytes.length };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export function cleanupCreatedSnapshotBlobs(root, createdBlobs, {
|
|
423
|
-
removeEmptyDirectory = false,
|
|
424
|
-
referencedDigests,
|
|
425
|
-
} = {}) {
|
|
426
|
-
if (!(createdBlobs instanceof Map) || !(referencedDigests instanceof Set)
|
|
427
|
-
|| createdBlobs.size > 256 || referencedDigests.size > 512) {
|
|
428
|
-
throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
429
|
-
}
|
|
430
|
-
const removable = [];
|
|
431
|
-
for (const [digest, identity] of createdBlobs) {
|
|
432
|
-
if (!/^[a-f0-9]{64}$/u.test(digest) || !identity
|
|
433
|
-
|| !Number.isSafeInteger(identity.dev) || identity.dev < 0
|
|
434
|
-
|| !Number.isSafeInteger(identity.ino) || identity.ino <= 0) {
|
|
435
|
-
throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
436
|
-
}
|
|
437
|
-
if (referencedDigests.has(digest)) continue;
|
|
438
|
-
const path = ledgerBlobPath(root, digest);
|
|
439
|
-
const details = lstatSync(path, { throwIfNoEntry: false });
|
|
440
|
-
if (!details) continue;
|
|
441
|
-
if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1
|
|
442
|
-
|| details.dev !== identity.dev || details.ino !== identity.ino) {
|
|
443
|
-
throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
444
|
-
}
|
|
445
|
-
const bytes = assertRegularSingleLink(path, SkillLoopLedgerError, "LEDGER_BLOB_INVALID", {
|
|
446
|
-
maxBytes: 8 * 1024 * 1024,
|
|
447
|
-
});
|
|
448
|
-
if (sha256(bytes) !== digest) throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
449
|
-
removable.push(path);
|
|
450
|
-
}
|
|
451
|
-
for (const path of removable) rmSync(path);
|
|
452
|
-
if (removeEmptyDirectory) {
|
|
453
|
-
const directory = blobDirectory(root, false);
|
|
454
|
-
if (directory !== null) {
|
|
455
|
-
let handle;
|
|
456
|
-
try {
|
|
457
|
-
handle = opendirSync(directory);
|
|
458
|
-
if (handle.readSync() === null) {
|
|
459
|
-
handle.closeSync();
|
|
460
|
-
handle = undefined;
|
|
461
|
-
rmdirSync(directory);
|
|
462
|
-
}
|
|
463
|
-
} finally {
|
|
464
|
-
handle?.closeSync();
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function safeTargetDirectory(root, targetRoot, targetSkill) {
|
|
471
|
-
if (!skillRootsEquivalent(root, targetRoot) || !SKILL_ID.test(targetSkill)) {
|
|
472
|
-
throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
473
|
-
}
|
|
474
|
-
const skills = ensureSafeDirectoryChain(root, [".claude", "skills"], {
|
|
475
|
-
create: false,
|
|
476
|
-
ErrorType: SkillLoopLedgerError,
|
|
477
|
-
code: "SNAPSHOT_PATH_UNSAFE",
|
|
478
|
-
});
|
|
479
|
-
if (skills === null) return null;
|
|
480
|
-
const target = join(skills, targetSkill);
|
|
481
|
-
const details = lstatSync(target, { throwIfNoEntry: false });
|
|
482
|
-
if (!details) return null;
|
|
483
|
-
if (!details.isDirectory() || details.isSymbolicLink()) throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
484
|
-
return target;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function validateSnapshotWorkLimits(workLimits) {
|
|
488
|
-
const limits = { ...SNAPSHOT_WORK_LIMITS, ...(workLimits ?? {}) };
|
|
489
|
-
if (![limits.maxDirectoryEntries, limits.maxDirectories, limits.maxDepth,
|
|
490
|
-
limits.maxFiles, limits.maxBytes].every(Number.isSafeInteger)
|
|
491
|
-
|| limits.maxDirectoryEntries < 1 || limits.maxDirectoryEntries > 16_384
|
|
492
|
-
|| limits.maxDirectories < 1 || limits.maxDirectories > 4_096
|
|
493
|
-
|| limits.maxDepth < 1 || limits.maxDepth > 32
|
|
494
|
-
|| limits.maxFiles < 1 || limits.maxFiles > 256
|
|
495
|
-
|| limits.maxBytes < 1 || limits.maxBytes > 32 * 1024 * 1024) {
|
|
496
|
-
throw new SkillLoopLedgerError("SNAPSHOT_WORK_CAP_INVALID");
|
|
497
|
-
}
|
|
498
|
-
return limits;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function validateSnapshotWorkBudget(work) {
|
|
502
|
-
if (!work || !requireExactKeys(work, ["directoryEntries", "directories", "files", "bytes"])
|
|
503
|
-
|| ![work.directoryEntries, work.directories, work.files, work.bytes].every(Number.isSafeInteger)
|
|
504
|
-
|| work.directoryEntries < 0 || work.directories < 0 || work.files < 0 || work.bytes < 0) {
|
|
505
|
-
throw new SkillLoopLedgerError("SNAPSHOT_WORK_CAP_INVALID");
|
|
506
|
-
}
|
|
507
|
-
return work;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function captureDirectorySnapshot(root, target, {
|
|
511
|
-
workLimits = null,
|
|
512
|
-
work = null,
|
|
513
|
-
createdBlobs = null,
|
|
514
|
-
} = {}) {
|
|
515
|
-
if (target === null) return [];
|
|
516
|
-
if (createdBlobs !== null && !(createdBlobs instanceof Map)) {
|
|
517
|
-
throw new SkillLoopLedgerError("SNAPSHOT_WORK_CAP_INVALID");
|
|
518
|
-
}
|
|
519
|
-
const limits = validateSnapshotWorkLimits(workLimits);
|
|
520
|
-
const capCode = workLimits === null ? "SNAPSHOT_TOO_LARGE" : "SNAPSHOT_WORK_CAP_EXCEEDED";
|
|
521
|
-
const budget = validateSnapshotWorkBudget(work ?? createSnapshotWorkBudget());
|
|
522
|
-
const files = [];
|
|
523
|
-
const identities = new Set();
|
|
524
|
-
const walk = (directory, depth) => {
|
|
525
|
-
budget.directories += 1;
|
|
526
|
-
if (budget.directories > limits.maxDirectories || depth > limits.maxDepth) {
|
|
527
|
-
throw new SkillLoopLedgerError(capCode);
|
|
528
|
-
}
|
|
529
|
-
let handle;
|
|
530
|
-
try {
|
|
531
|
-
handle = opendirSync(directory);
|
|
532
|
-
let entry;
|
|
533
|
-
while ((entry = handle.readSync()) !== null) {
|
|
534
|
-
budget.directoryEntries += 1;
|
|
535
|
-
if (budget.directoryEntries > limits.maxDirectoryEntries) throw new SkillLoopLedgerError(capCode);
|
|
536
|
-
const path = join(directory, entry.name);
|
|
537
|
-
const details = lstatSync(path, { throwIfNoEntry: false });
|
|
538
|
-
if (!details || details.isSymbolicLink()) throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
539
|
-
if (details.isDirectory()) {
|
|
540
|
-
walk(path, depth + 1);
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
if (!details.isFile() || details.nlink !== 1) throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
544
|
-
const file = relative(target, path).replaceAll("\\", "/");
|
|
545
|
-
const identity = portablePathIdentity(file);
|
|
546
|
-
if (identity === null || identities.has(identity)) throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
547
|
-
identities.add(identity);
|
|
548
|
-
budget.files += 1;
|
|
549
|
-
if (budget.files > limits.maxFiles || details.size > 8 * 1024 * 1024
|
|
550
|
-
|| details.size > limits.maxBytes - budget.bytes) {
|
|
551
|
-
throw new SkillLoopLedgerError(capCode);
|
|
552
|
-
}
|
|
553
|
-
const bytes = assertRegularSingleLink(path, SkillLoopLedgerError, "SNAPSHOT_PATH_UNSAFE", {
|
|
554
|
-
maxBytes: 8 * 1024 * 1024,
|
|
555
|
-
});
|
|
556
|
-
budget.bytes += bytes.length;
|
|
557
|
-
if (budget.bytes > limits.maxBytes) {
|
|
558
|
-
throw new SkillLoopLedgerError(capCode);
|
|
559
|
-
}
|
|
560
|
-
files.push({ file, blob: persistBlob(root, bytes, createdBlobs) });
|
|
561
|
-
}
|
|
562
|
-
} finally {
|
|
563
|
-
handle?.closeSync();
|
|
564
|
-
}
|
|
565
|
-
};
|
|
566
|
-
walk(target, 0);
|
|
567
|
-
return files.sort((left, right) => left.file.localeCompare(right.file));
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
export function captureSkillSnapshot(root, targetRoot, targetSkill, options = {}) {
|
|
571
|
-
return captureDirectorySnapshot(root, safeTargetDirectory(root, targetRoot, targetSkill), options);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
export function captureStagedSnapshot(root, directory, options = {}) {
|
|
575
|
-
const skills = ensureSafeDirectoryChain(root, [".claude", "skills"], {
|
|
576
|
-
create: false,
|
|
577
|
-
ErrorType: SkillLoopLedgerError,
|
|
578
|
-
code: "SNAPSHOT_PATH_UNSAFE",
|
|
579
|
-
});
|
|
580
|
-
const path = resolve(directory);
|
|
581
|
-
const workspace = resolve(root);
|
|
582
|
-
const local = relative(workspace, path).replaceAll("\\", "/");
|
|
583
|
-
const temporary = skills !== null && dirname(path) === resolve(skills)
|
|
584
|
-
&& /^\.[a-z0-9][a-z0-9-]*\.(?:apply|rollback)-/u.test(path.split(/[\\/]/u).at(-1));
|
|
585
|
-
const archived = /^\.litclaude\/skill-archives\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\/[a-z0-9][a-z0-9-]*$/u.test(local);
|
|
586
|
-
if (!temporary && !archived) {
|
|
587
|
-
throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
588
|
-
}
|
|
589
|
-
const details = lstatSync(path, { throwIfNoEntry: false });
|
|
590
|
-
if (!details?.isDirectory() || details.isSymbolicLink()) throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
591
|
-
return captureDirectorySnapshot(root, path, options);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export function captureBackupSnapshot(root, directory, options = {}) {
|
|
595
|
-
const path = resolve(directory);
|
|
596
|
-
const local = relative(resolve(root), path).replaceAll("\\", "/");
|
|
597
|
-
if (!/^\.litclaude\/skill-backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\/[a-z0-9][a-z0-9-]*$/u.test(local)) {
|
|
598
|
-
throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
599
|
-
}
|
|
600
|
-
const details = lstatSync(path, { throwIfNoEntry: false });
|
|
601
|
-
if (!details?.isDirectory() || details.isSymbolicLink()) {
|
|
602
|
-
throw new SkillLoopLedgerError("SNAPSHOT_PATH_UNSAFE");
|
|
603
|
-
}
|
|
604
|
-
return captureDirectorySnapshot(root, path, options);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
export function verifySnapshotBlobs(root, snapshot) {
|
|
608
|
-
validateSnapshot(snapshot);
|
|
609
|
-
for (const item of snapshot) {
|
|
610
|
-
const path = ledgerBlobPath(root, item.blob.digest);
|
|
611
|
-
if (!existsSync(path)) throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
612
|
-
const bytes = assertRegularSingleLink(path, SkillLoopLedgerError, "LEDGER_BLOB_INVALID", {
|
|
613
|
-
maxBytes: 8 * 1024 * 1024,
|
|
614
|
-
});
|
|
615
|
-
if (bytes.length !== item.blob.size || sha256(bytes) !== item.blob.digest) {
|
|
616
|
-
throw new SkillLoopLedgerError("LEDGER_BLOB_INVALID");
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
export function materializeSnapshot(root, directory, snapshot) {
|
|
622
|
-
mkdirSync(directory, { mode: 0o700 });
|
|
623
|
-
for (const item of snapshot) {
|
|
624
|
-
const path = join(directory, ...item.file.split("/"));
|
|
625
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
626
|
-
const bytes = assertRegularSingleLink(ledgerBlobPath(root, item.blob.digest), SkillLoopLedgerError, "LEDGER_BLOB_INVALID");
|
|
627
|
-
atomicWriteBytes(path, bytes, SkillLoopLedgerError, "ROLLBACK_RESTORE_FAILED");
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function assertNoCycle(entry, entriesById) {
|
|
632
|
-
const seen = new Set();
|
|
633
|
-
let current = entry;
|
|
634
|
-
while (current?.operation === "rollback") {
|
|
635
|
-
if (seen.has(current.id)) throw new SkillLoopLedgerError("ROLLBACK_CYCLE");
|
|
636
|
-
seen.add(current.id);
|
|
637
|
-
const next = entriesById.get(current.restoresLedgerEntryId);
|
|
638
|
-
if (!next) throw new SkillLoopLedgerError("ROLLBACK_ENTRY_INELIGIBLE");
|
|
639
|
-
if (next.targetSkill !== entry.targetSkill || next.targetRoot !== entry.targetRoot) {
|
|
640
|
-
throw new SkillLoopLedgerError("ROLLBACK_TARGET_MISMATCH");
|
|
641
|
-
}
|
|
642
|
-
current = next;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
export function rollbackLedgerEntry(root, entryId, {
|
|
647
|
-
now = new Date().toISOString(),
|
|
648
|
-
beforeCommit,
|
|
649
|
-
afterIdentityCapture,
|
|
650
|
-
beforeProposalCommit,
|
|
651
|
-
afterTransactionBegin,
|
|
652
|
-
afterCurrentDisplaced,
|
|
653
|
-
afterLiveMutation,
|
|
654
|
-
afterUsageCommit,
|
|
655
|
-
afterLedgerCommit,
|
|
656
|
-
afterProposalCommit,
|
|
657
|
-
} = {}) {
|
|
658
|
-
if (!RECORD_ID.test(entryId) || parseTimestamp(now) === null) throw new SkillLoopLedgerError("ROLLBACK_ENTRY_INELIGIBLE");
|
|
659
|
-
return withSkillLoopLock(root, "transaction", () => {
|
|
660
|
-
recoverReviewTransactionUnlocked(root);
|
|
661
|
-
const mutationRecovery = recoverSkillMutationTransactionUnlocked(root);
|
|
662
|
-
if (mutationRecovery.status === "finalized" && mutationRecovery.kind === "rollback"
|
|
663
|
-
&& mutationRecovery.requestId === entryId) {
|
|
664
|
-
return { ...mutationRecovery.ledgerEntry, recovered: true };
|
|
665
|
-
}
|
|
666
|
-
const recoveredPreparedMutation = mutationRecovery.status === "rolled-back"
|
|
667
|
-
&& mutationRecovery.kind === "rollback" && mutationRecovery.requestId === entryId;
|
|
668
|
-
const ledger = readLedger(root);
|
|
669
|
-
const byId = new Map(ledger.map((entry) => [entry.id, entry]));
|
|
670
|
-
const target = byId.get(entryId);
|
|
671
|
-
if (!target || !["apply", "curator-transition", "rollback"].includes(target.operation)) {
|
|
672
|
-
throw new SkillLoopLedgerError("ROLLBACK_ENTRY_INELIGIBLE");
|
|
673
|
-
}
|
|
674
|
-
assertNoCycle(target, byId);
|
|
675
|
-
resolveAgentTarget(root, target.targetRoot, target.targetSkill, {
|
|
676
|
-
allowMissing: true,
|
|
677
|
-
requireOwned: false,
|
|
678
|
-
ErrorType: SkillLoopLedgerError,
|
|
679
|
-
notOwnedCode: "TARGET_NOT_AGENT_OWNED",
|
|
680
|
-
pathCode: "SNAPSHOT_PATH_UNSAFE",
|
|
681
|
-
});
|
|
682
|
-
verifySnapshotBlobs(root, target.before);
|
|
683
|
-
verifySnapshotBlobs(root, target.after);
|
|
684
|
-
const recovery = ledger.findLast((entry) => entry.operation === "rollback" && entry.restoresLedgerEntryId === target.id);
|
|
685
|
-
if (recovery) {
|
|
686
|
-
const current = captureSkillSnapshot(root, target.targetRoot, target.targetSkill);
|
|
687
|
-
if (snapshotsEqual(current, recovery.after)) {
|
|
688
|
-
if (target.proposalId) {
|
|
689
|
-
const proposal = readProposalRecord(root, target.proposalId);
|
|
690
|
-
transitionProposalRecordUnlocked(root, proposal.id, "rolled-back", recovery.id);
|
|
691
|
-
}
|
|
692
|
-
return { ...recovery, recovered: true };
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
const skills = ensureSafeDirectoryChain(root, [".claude", "skills"], {
|
|
697
|
-
create: true,
|
|
698
|
-
ErrorType: SkillLoopLedgerError,
|
|
699
|
-
code: "SNAPSHOT_PATH_UNSAFE",
|
|
700
|
-
});
|
|
701
|
-
const destination = join(skills, target.targetSkill);
|
|
702
|
-
const stage = join(skills, `.${target.targetSkill}.rollback-${randomUUID()}`);
|
|
703
|
-
const displaced = join(skills, `.${target.targetSkill}.rollback-before-${randomUUID()}`);
|
|
704
|
-
if (target.before.length > 0) materializeSnapshot(root, stage, target.before);
|
|
705
|
-
beforeCommit?.();
|
|
706
|
-
const current = captureSkillSnapshot(root, target.targetRoot, target.targetSkill);
|
|
707
|
-
if (!snapshotsEqual(current, target.after)) throw new SkillLoopLedgerError("ROLLBACK_CAS_MISMATCH");
|
|
708
|
-
const usageTransition = /^usage:(active|stale|archived)->(active|stale|archived)$/u.exec(target.reason ?? "");
|
|
709
|
-
const usageBefore = usageTransition ? readUsage(root) : null;
|
|
710
|
-
if (usageTransition && usageBefore.skills[target.targetSkill]?.state !== usageTransition[2]) {
|
|
711
|
-
throw new SkillLoopLedgerError("ROLLBACK_USAGE_CAS_MISMATCH");
|
|
712
|
-
}
|
|
713
|
-
let movedCurrent = false;
|
|
714
|
-
let installedStage = false;
|
|
715
|
-
let ledgerCommitted = false;
|
|
716
|
-
let usageCommitted = false;
|
|
717
|
-
try {
|
|
718
|
-
const expectedIdentity = captureDirectoryIdentity(destination, SkillLoopLedgerError, "SNAPSHOT_PATH_UNSAFE", {
|
|
719
|
-
allowMissing: current.length === 0,
|
|
720
|
-
});
|
|
721
|
-
afterIdentityCapture?.();
|
|
722
|
-
if (!directoryIdentityMatches(destination, expectedIdentity)) {
|
|
723
|
-
throw new SkillLoopLedgerError("ROLLBACK_IDENTITY_MISMATCH");
|
|
724
|
-
}
|
|
725
|
-
const restored = target.before.length > 0 ? captureStagedSnapshot(root, stage) : [];
|
|
726
|
-
const stageIdentity = captureDirectoryIdentity(stage, SkillLoopLedgerError, "SNAPSHOT_PATH_UNSAFE", {
|
|
727
|
-
allowMissing: target.before.length === 0,
|
|
728
|
-
});
|
|
729
|
-
if (!snapshotsEqual(restored, target.before)) throw new SkillLoopLedgerError("ROLLBACK_RESTORE_FAILED");
|
|
730
|
-
const rollbackId = `ledger-${randomUUID()}`;
|
|
731
|
-
const entry = {
|
|
732
|
-
schema: "litfamily.skill-ledger/v1",
|
|
733
|
-
id: rollbackId,
|
|
734
|
-
createdAt: now,
|
|
735
|
-
proposalId: target.proposalId,
|
|
736
|
-
actor: "user",
|
|
737
|
-
operation: "rollback",
|
|
738
|
-
targetSkill: target.targetSkill,
|
|
739
|
-
targetRoot: target.targetRoot,
|
|
740
|
-
before: current,
|
|
741
|
-
after: restored,
|
|
742
|
-
restoresLedgerEntryId: target.id,
|
|
743
|
-
...(usageTransition ? { reason: `usage:${usageTransition[2]}->${usageTransition[1]}` } : {}),
|
|
744
|
-
};
|
|
745
|
-
const proposalBefore = target.proposalId ? readProposalRecord(root, target.proposalId) : null;
|
|
746
|
-
const proposalAfter = proposalBefore === null
|
|
747
|
-
? null
|
|
748
|
-
: { ...proposalBefore, status: "rolled-back", ledgerEntryId: rollbackId };
|
|
749
|
-
const usageAfter = usageBefore === null ? null : structuredClone(usageBefore);
|
|
750
|
-
if (usageAfter) {
|
|
751
|
-
usageAfter.skills[target.targetSkill].state = usageTransition[1];
|
|
752
|
-
usageAfter.skills[target.targetSkill].archived_at = usageTransition[1] === "archived" ? now : null;
|
|
753
|
-
usageAfter.updatedAt = now;
|
|
754
|
-
}
|
|
755
|
-
beginSkillMutationTransactionUnlocked(root, {
|
|
756
|
-
schema: "litclaude.skill-mutation-transaction/v1",
|
|
757
|
-
id: rollbackId,
|
|
758
|
-
kind: "rollback",
|
|
759
|
-
requestId: entryId,
|
|
760
|
-
createdAt: now,
|
|
761
|
-
targetRoot: target.targetRoot,
|
|
762
|
-
targetSkill: target.targetSkill,
|
|
763
|
-
action: "rollback",
|
|
764
|
-
stageName: stage.split(/[\\/]/u).at(-1),
|
|
765
|
-
displacedName: displaced.split(/[\\/]/u).at(-1),
|
|
766
|
-
archiveParts: null,
|
|
767
|
-
beforeIdentity: expectedIdentity,
|
|
768
|
-
stageIdentity,
|
|
769
|
-
before: current,
|
|
770
|
-
after: restored,
|
|
771
|
-
ledgerEntry: entry,
|
|
772
|
-
proposalBefore,
|
|
773
|
-
proposalAfter,
|
|
774
|
-
usageBefore,
|
|
775
|
-
usageAfter,
|
|
776
|
-
configBefore: null,
|
|
777
|
-
configAfter: null,
|
|
778
|
-
});
|
|
779
|
-
afterTransactionBegin?.();
|
|
780
|
-
if (expectedIdentity !== null) {
|
|
781
|
-
renameSync(destination, displaced);
|
|
782
|
-
movedCurrent = true;
|
|
783
|
-
if (!directoryIdentityMatches(displaced, expectedIdentity)) {
|
|
784
|
-
renameSync(displaced, destination);
|
|
785
|
-
movedCurrent = false;
|
|
786
|
-
throw new SkillLoopLedgerError("ROLLBACK_IDENTITY_MISMATCH");
|
|
787
|
-
}
|
|
788
|
-
} else if (!directoryIdentityMatches(destination, null)) {
|
|
789
|
-
throw new SkillLoopLedgerError("ROLLBACK_IDENTITY_MISMATCH");
|
|
790
|
-
}
|
|
791
|
-
afterCurrentDisplaced?.();
|
|
792
|
-
if (target.before.length > 0) {
|
|
793
|
-
renameSync(stage, destination);
|
|
794
|
-
installedStage = true;
|
|
795
|
-
}
|
|
796
|
-
afterLiveMutation?.();
|
|
797
|
-
const liveRestored = captureSkillSnapshot(root, target.targetRoot, target.targetSkill);
|
|
798
|
-
if (!snapshotsEqual(liveRestored, target.before)) throw new SkillLoopLedgerError("ROLLBACK_RESTORE_FAILED");
|
|
799
|
-
if (usageTransition) {
|
|
800
|
-
writeUsageUnlocked(root, usageAfter);
|
|
801
|
-
usageCommitted = true;
|
|
802
|
-
afterUsageCommit?.();
|
|
803
|
-
}
|
|
804
|
-
appendLedgerEntryUnlocked(root, entry);
|
|
805
|
-
ledgerCommitted = true;
|
|
806
|
-
afterLedgerCommit?.();
|
|
807
|
-
if (target.proposalId) {
|
|
808
|
-
beforeProposalCommit?.(proposalFilePath(root, target.proposalId, false));
|
|
809
|
-
transitionProposalRecordUnlocked(root, proposalBefore.id, "rolled-back", entry.id);
|
|
810
|
-
afterProposalCommit?.();
|
|
811
|
-
}
|
|
812
|
-
const finalized = recoverSkillMutationTransactionUnlocked(root);
|
|
813
|
-
if (finalized.status !== "finalized") throw new SkillLoopLedgerError("ROLLBACK_STATE_UNKNOWN");
|
|
814
|
-
return { ...entry, ...(recoveredPreparedMutation ? { recovered: true } : {}) };
|
|
815
|
-
} catch (error) {
|
|
816
|
-
if (ledgerCommitted) {
|
|
817
|
-
throw new SkillLoopLedgerError("ROLLBACK_COMMITTED_METADATA_PENDING",
|
|
818
|
-
"rollback and ledger are committed; retry rollback to repair proposal state");
|
|
819
|
-
}
|
|
820
|
-
try {
|
|
821
|
-
if (installedStage && existsSync(destination)) rmSync(destination, { recursive: true, force: true });
|
|
822
|
-
if (movedCurrent && existsSync(displaced)) renameSync(displaced, destination);
|
|
823
|
-
if (usageCommitted && usageBefore) writeUsageUnlocked(root, usageBefore);
|
|
824
|
-
} catch {
|
|
825
|
-
throw new SkillLoopLedgerError("ROLLBACK_STATE_UNKNOWN");
|
|
826
|
-
}
|
|
827
|
-
if (error instanceof SkillLoopLedgerError) throw error;
|
|
828
|
-
throw new SkillLoopLedgerError("ROLLBACK_RESTORE_FAILED");
|
|
829
|
-
} finally {
|
|
830
|
-
rmSync(stage, { recursive: true, force: true });
|
|
831
|
-
if (!movedCurrent) rmSync(displaced, { recursive: true, force: true });
|
|
832
|
-
}
|
|
833
|
-
}, SkillLoopLedgerError, "LEDGER_LOCK_FAILED");
|
|
834
|
-
}
|