@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,187 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { lstatSync, opendirSync, readFileSync, renameSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
4
-
5
- import {
6
- atomicWriteJson,
7
- canonicalSkillRootFor,
8
- ensureSafeDirectoryChain,
9
- PROPOSAL_MAX_BYTES,
10
- readStrictJsonFile,
11
- prettyJsonBytes,
12
- RECORD_ID,
13
- skillRootsEquivalent,
14
- withSkillLoopLock,
15
- } from "./common.mjs";
16
- import { directoryIdentity, pathIdentity, sameDirectoryIdentity, samePathIdentity } from "../secure-path-read.mjs";
17
- import { SkillLoopProposalError, validateProposal } from "./proposal-record.mjs";
18
- import { recoverReviewTransactionUnlocked } from "./review-transaction.mjs";
19
- import { recoverSkillMutationTransactionUnlocked } from "./mutation-transaction.mjs";
20
-
21
- export const MAX_PROPOSAL_RECORDS = 256;
22
- const schemaError = () => new SkillLoopProposalError("PROPOSAL_SCHEMA_INVALID", "proposal does not match v1 schema");
23
- const proposalDirectory = (root, create) => ensureSafeDirectoryChain(root, [".litclaude", "skill-proposals"], {
24
- create,
25
- ErrorType: SkillLoopProposalError,
26
- code: "PROPOSAL_STORE_UNSAFE",
27
- });
28
-
29
- export const proposalFilePath = (root, id, create = false) => {
30
- if (!RECORD_ID.test(id)) throw schemaError();
31
- const directory = proposalDirectory(root, create);
32
- return directory === null ? null : join(directory, `${id}.json`);
33
- };
34
-
35
- export function readProposalRecord(root, id) {
36
- const path = proposalFilePath(root, id, false);
37
- if (path === null || !lstatSync(path, { throwIfNoEntry: false })) {
38
- throw new SkillLoopProposalError("PROPOSAL_NOT_FOUND", "proposal was not found");
39
- }
40
- try {
41
- const proposal = validateProposal(readStrictJsonFile(path, SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE", {
42
- maxBytes: PROPOSAL_MAX_BYTES,
43
- parseCode: "PROPOSAL_STORE_INVALID",
44
- }));
45
- if (proposal.id !== id) throw new SkillLoopProposalError("PROPOSAL_STORE_INVALID", "proposal id does not match its file");
46
- return proposal;
47
- } catch (error) {
48
- if (error instanceof SkillLoopProposalError && error.code === "PROPOSAL_STORE_UNSAFE") throw error;
49
- throw new SkillLoopProposalError("PROPOSAL_STORE_INVALID", "proposal store contains an invalid record");
50
- }
51
- }
52
-
53
- const allowedTransitions = new Set([
54
- "pending->approved",
55
- "pending->rejected",
56
- "approved->applied",
57
- "applied->rolled-back",
58
- "rolled-back->rolled-back",
59
- ]);
60
-
61
- export function transitionProposalRecordUnlocked(root, id, status, ledgerEntryId) {
62
- const current = readProposalRecord(root, id);
63
- if (!allowedTransitions.has(`${current.status}->${status}`)) {
64
- throw new SkillLoopProposalError("PROPOSAL_TRANSITION_INVALID");
65
- }
66
- const next = { ...current, status };
67
- if (["applied", "rolled-back"].includes(status)) next.ledgerEntryId = ledgerEntryId;
68
- else delete next.ledgerEntryId;
69
- validateProposal(next);
70
- atomicWriteJson(proposalFilePath(root, id, false), next, SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE");
71
- return next;
72
- }
73
-
74
- export function transitionProposalRecord(root, id, status, ledgerEntryId) {
75
- return withSkillLoopLock(root, "transaction", () => {
76
- recoverReviewTransactionUnlocked(root);
77
- recoverSkillMutationTransactionUnlocked(root);
78
- return transitionProposalRecordUnlocked(root, id, status, ledgerEntryId);
79
- }, SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE");
80
- }
81
-
82
- export function queueProposal(root, proposal) {
83
- return withSkillLoopLock(root, "transaction", () => {
84
- recoverReviewTransactionUnlocked(root);
85
- recoverSkillMutationTransactionUnlocked(root);
86
- assertProposalCapacityUnlocked(root, 1);
87
- return queueProposalRecordUnlocked(root, proposal).proposal;
88
- },
89
- SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE");
90
- }
91
-
92
- export function queueProposalRecordUnlocked(root, proposal) {
93
- const persistedProposal = normalizeProposalTargetRoot(root, proposal);
94
- if (persistedProposal.status !== "pending") throw schemaError();
95
- const path = proposalFilePath(root, persistedProposal.id, true);
96
- if (lstatSync(path, { throwIfNoEntry: false })) throw new SkillLoopProposalError("PROPOSAL_EXISTS");
97
- atomicWriteJson(path, persistedProposal, SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE");
98
- const written = lstatSync(path, { throwIfNoEntry: false });
99
- if (!written?.isFile() || written.isSymbolicLink() || written.nlink !== 1) {
100
- throw new SkillLoopProposalError("PROPOSAL_STORE_UNSAFE");
101
- }
102
- const bytes = prettyJsonBytes(persistedProposal);
103
- return {
104
- proposal: persistedProposal,
105
- path,
106
- identity: pathIdentity(written),
107
- sha256: createHash("sha256").update(bytes).digest("hex"),
108
- };
109
- }
110
-
111
- export function normalizeProposalTargetRoot(root, proposal) {
112
- validateProposal(proposal);
113
- if (!skillRootsEquivalent(root, proposal.targetRoot)) return proposal;
114
- const targetRoot = canonicalSkillRootFor(root, SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE");
115
- if (proposal.targetRoot === targetRoot) return proposal;
116
- return validateProposal({ ...proposal, targetRoot });
117
- }
118
-
119
- export function removeQueuedProposalRecordUnlocked(record) {
120
- const current = lstatSync(record.path, { throwIfNoEntry: false });
121
- if (!current?.isFile() || current.isSymbolicLink() || current.nlink !== 1
122
- || !samePathIdentity(pathIdentity(current), record.identity)) {
123
- throw new SkillLoopProposalError("PROPOSAL_ROLLBACK_UNSAFE");
124
- }
125
- const quarantine = `${record.path}.remove.${randomUUID()}`;
126
- try {
127
- renameSync(record.path, quarantine);
128
- const moved = lstatSync(quarantine, { throwIfNoEntry: false });
129
- if (!moved?.isFile() || moved.isSymbolicLink() || moved.nlink !== 1
130
- || !sameDirectoryIdentity(directoryIdentity(moved), {
131
- dev: record.identity.dev,
132
- ino: record.identity.ino,
133
- birthtimeMs: record.identity.birthtimeMs,
134
- })) {
135
- throw new Error("proposal identity changed");
136
- }
137
- if (record.sha256
138
- && createHash("sha256").update(readFileSync(quarantine)).digest("hex") !== record.sha256) {
139
- throw new Error("proposal contents changed");
140
- }
141
- rmSync(quarantine, { force: true });
142
- } catch {
143
- try {
144
- if (!lstatSync(record.path, { throwIfNoEntry: false })
145
- && lstatSync(quarantine, { throwIfNoEntry: false })) renameSync(quarantine, record.path);
146
- } catch { /* Leave uncertain cleanup as a visible residue. */ }
147
- throw new SkillLoopProposalError("PROPOSAL_ROLLBACK_UNSAFE");
148
- }
149
- }
150
-
151
- function proposalEntriesBounded(root) {
152
- const directory = proposalDirectory(root, false);
153
- if (directory === null) return [];
154
- const entries = [];
155
- let handle;
156
- try {
157
- handle = opendirSync(directory);
158
- let entry;
159
- while ((entry = handle.readSync()) !== null) {
160
- entries.push(entry);
161
- if (entries.length > MAX_PROPOSAL_RECORDS) {
162
- throw new SkillLoopProposalError("PROPOSAL_STORE_TOO_LARGE");
163
- }
164
- }
165
- } finally {
166
- handle?.closeSync();
167
- }
168
- return entries.sort((left, right) => left.name.localeCompare(right.name));
169
- }
170
-
171
- export function assertProposalCapacityUnlocked(root, addition) {
172
- if (!Number.isSafeInteger(addition) || addition < 0 || addition > 3) {
173
- throw new SkillLoopProposalError("PROPOSAL_STORE_INVALID");
174
- }
175
- const count = proposalEntriesBounded(root).length;
176
- if (count + addition > MAX_PROPOSAL_RECORDS) throw new SkillLoopProposalError("PROPOSAL_STORE_FULL");
177
- return count;
178
- }
179
-
180
- export function listProposalRecords(root) {
181
- return proposalEntriesBounded(root).map((entry) => {
182
- if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json")) {
183
- throw new SkillLoopProposalError("PROPOSAL_STORE_INVALID");
184
- }
185
- return readProposalRecord(root, entry.name.slice(0, -5));
186
- });
187
- }
@@ -1,26 +0,0 @@
1
- import { withSkillLoopLock } from "./common.mjs";
2
- import { SkillLoopProposalError, validateProposal } from "./proposal-record.mjs";
3
- import { listProposalsUnlocked, readProposalUnlocked } from "./proposal-reader.mjs";
4
- import { queueProposal } from "./proposal-store.mjs";
5
- import { recoverReviewTransactionUnlocked } from "./review-transaction.mjs";
6
- import { recoverSkillMutationTransactionUnlocked } from "./mutation-transaction.mjs";
7
-
8
- export { SkillLoopProposalError, validateProposal, queueProposal };
9
-
10
- export function readProposal(root, id) {
11
- return withSkillLoopLock(root, "transaction", () => {
12
- recoverReviewTransactionUnlocked(root);
13
- recoverSkillMutationTransactionUnlocked(root);
14
- return readProposalUnlocked(root, id);
15
- },
16
- SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE");
17
- }
18
-
19
- export function listProposals(root, options) {
20
- return withSkillLoopLock(root, "transaction", () => {
21
- recoverReviewTransactionUnlocked(root);
22
- recoverSkillMutationTransactionUnlocked(root);
23
- return listProposalsUnlocked(root, options);
24
- },
25
- SkillLoopProposalError, "PROPOSAL_STORE_UNSAFE");
26
- }
@@ -1,13 +0,0 @@
1
- #!/usr/bin/env node
2
- import { runReview } from "./review.mjs";
3
-
4
- const root = process.argv[2];
5
- if (typeof root !== "string" || process.argv.length !== 3 || process.env.LITFAMILY_SKILL_REVIEW !== "1") {
6
- process.exitCode = 64;
7
- } else {
8
- try {
9
- runReview(root);
10
- } catch {
11
- process.exitCode = 1;
12
- }
13
- }
@@ -1,358 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import {
3
- closeSync,
4
- fsyncSync,
5
- lstatSync,
6
- openSync,
7
- renameSync,
8
- unlinkSync,
9
- } from "node:fs";
10
- import { dirname, join } from "node:path";
11
-
12
- import {
13
- assertRegularSingleLink,
14
- atomicWriteBytes,
15
- atomicWriteJson,
16
- ensureSafeDirectoryChain,
17
- isPlainObject,
18
- parseTimestamp,
19
- prettyJsonBytes,
20
- readStrictJsonFile,
21
- RECORD_ID,
22
- requireExactKeys,
23
- sha256,
24
- withSkillLoopLock,
25
- } from "./common.mjs";
26
- import { recoverSkillMutationTransactionUnlocked } from "./mutation-transaction.mjs";
27
-
28
- export class SkillLoopReviewTransactionError extends Error {
29
- constructor(code, message = code) {
30
- super(`${code}: ${message}`);
31
- this.name = "SkillLoopReviewTransactionError";
32
- this.code = code;
33
- }
34
- }
35
-
36
- const MAX_JOURNAL_BYTES = 768 * 1024;
37
- const MAX_RECORD_BYTES = 64 * 1024;
38
- const MAX_STATE_BYTES = 64 * 1024;
39
- const fail = (code, message) => { throw new SkillLoopReviewTransactionError(code, message); };
40
-
41
- function stateDirectory(root, create) {
42
- return ensureSafeDirectoryChain(root, [".litclaude"], {
43
- create,
44
- ErrorType: SkillLoopReviewTransactionError,
45
- code: "REVIEW_TRANSACTION_UNSAFE",
46
- });
47
- }
48
-
49
- function proposalDirectory(root, create) {
50
- return ensureSafeDirectoryChain(root, [".litclaude", "skill-proposals"], {
51
- create,
52
- ErrorType: SkillLoopReviewTransactionError,
53
- code: "REVIEW_TRANSACTION_UNSAFE",
54
- });
55
- }
56
-
57
- const journalPath = (root, create) => {
58
- const directory = stateDirectory(root, create);
59
- return directory === null ? null : join(directory, "review-transaction.json");
60
- };
61
-
62
- function strictBase64(value, maxBytes) {
63
- if (typeof value !== "string" || value.length === 0 || value.length > Math.ceil(maxBytes / 3) * 4 + 4
64
- || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) {
65
- fail("REVIEW_TRANSACTION_INVALID");
66
- }
67
- const bytes = Buffer.from(value, "base64");
68
- if (bytes.length === 0 || bytes.length > maxBytes || bytes.toString("base64") !== value) {
69
- fail("REVIEW_TRANSACTION_INVALID");
70
- }
71
- return bytes;
72
- }
73
-
74
- function validateIdentity(value) {
75
- return value === null || (isPlainObject(value) && requireExactKeys(value, ["dev", "ino"])
76
- && Number.isSafeInteger(value.dev) && value.dev >= 0
77
- && Number.isSafeInteger(value.ino) && value.ino > 0);
78
- }
79
-
80
- function validateJournal(value) {
81
- let bytes;
82
- try { bytes = prettyJsonBytes(value); } catch { fail("REVIEW_TRANSACTION_INVALID"); }
83
- if (bytes.length > MAX_JOURNAL_BYTES || !requireExactKeys(value, [
84
- "schema", "id", "createdAt", "phase", "beforeState", "activePending", "rollbackPending",
85
- "afterState", "afterPending", "proposals",
86
- ]) || value.schema !== "litclaude.review-transaction/v1" || !RECORD_ID.test(value.id)
87
- || parseTimestamp(value.createdAt) === null || !["prepared", "committed"].includes(value.phase)
88
- || !Array.isArray(value.proposals) || value.proposals.length > 3) {
89
- fail("REVIEW_TRANSACTION_INVALID");
90
- }
91
- for (const field of ["beforeState", "activePending", "rollbackPending", "afterState", "afterPending"]) {
92
- strictBase64(value[field], MAX_STATE_BYTES);
93
- }
94
- const ids = new Set();
95
- for (const record of value.proposals) {
96
- if (!requireExactKeys(record, ["id", "content", "digest", "stageName", "finalName", "stageState", "identity"])
97
- || !RECORD_ID.test(record.id) || ids.has(record.id)
98
- || record.stageName !== `.${value.id}.${record.id}.staged`
99
- || record.finalName !== `${record.id}.json`
100
- || !["absent", "creating", "ready"].includes(record.stageState)
101
- || !/^[a-f0-9]{64}$/u.test(record.digest) || !validateIdentity(record.identity)
102
- || (["absent", "creating"].includes(record.stageState) && record.identity !== null)
103
- || (record.stageState === "ready" && record.identity === null)
104
- || (value.phase === "committed" && record.stageState !== "ready")) {
105
- fail("REVIEW_TRANSACTION_INVALID");
106
- }
107
- const content = strictBase64(record.content, MAX_RECORD_BYTES);
108
- if (sha256(content) !== record.digest) fail("REVIEW_TRANSACTION_INVALID");
109
- ids.add(record.id);
110
- }
111
- return value;
112
- }
113
-
114
- function readJournalUnlocked(root) {
115
- const path = journalPath(root, false);
116
- if (path === null || !lstatSync(path, { throwIfNoEntry: false })) return null;
117
- return validateJournal(readStrictJsonFile(
118
- path,
119
- SkillLoopReviewTransactionError,
120
- "REVIEW_TRANSACTION_UNSAFE",
121
- { maxBytes: MAX_JOURNAL_BYTES, parseCode: "REVIEW_TRANSACTION_INVALID" },
122
- ));
123
- }
124
-
125
- function writeJournalUnlocked(root, journal) {
126
- validateJournal(journal);
127
- atomicWriteJson(journalPath(root, true), journal,
128
- SkillLoopReviewTransactionError, "REVIEW_TRANSACTION_UNSAFE");
129
- return journal;
130
- }
131
-
132
- function syncDirectory(path) {
133
- let descriptor;
134
- try {
135
- descriptor = openSync(path, "r");
136
- fsyncSync(descriptor);
137
- } catch {
138
- // Atomic writes already fsync their data; some hosts reject directory fsync.
139
- } finally {
140
- if (descriptor !== undefined) closeSync(descriptor);
141
- }
142
- }
143
-
144
- function currentBytes(path, maxBytes) {
145
- if (!lstatSync(path, { throwIfNoEntry: false })) return null;
146
- return assertRegularSingleLink(path, SkillLoopReviewTransactionError, "REVIEW_TRANSACTION_UNSAFE", { maxBytes });
147
- }
148
-
149
- function exactRecordAt(path, record) {
150
- const details = lstatSync(path, { throwIfNoEntry: false });
151
- if (!details) return false;
152
- if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1) {
153
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
154
- }
155
- if (record.identity === null
156
- || details.dev !== record.identity.dev || details.ino !== record.identity.ino) {
157
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
158
- }
159
- const expected = Buffer.from(record.content, "base64");
160
- const actual = assertRegularSingleLink(path, SkillLoopReviewTransactionError,
161
- "REVIEW_TRANSACTION_UNSAFE", { maxBytes: MAX_RECORD_BYTES });
162
- if (!actual.equals(expected)) fail("REVIEW_TRANSACTION_AMBIGUOUS");
163
- return true;
164
- }
165
-
166
- function exactUnidentifiedRecordAt(path, record) {
167
- const details = lstatSync(path, { throwIfNoEntry: false });
168
- if (!details) return false;
169
- if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1) {
170
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
171
- }
172
- const expected = Buffer.from(record.content, "base64");
173
- const actual = assertRegularSingleLink(path, SkillLoopReviewTransactionError,
174
- "REVIEW_TRANSACTION_UNSAFE", { maxBytes: MAX_RECORD_BYTES });
175
- if (!actual.equals(expected)) fail("REVIEW_TRANSACTION_AMBIGUOUS");
176
- return true;
177
- }
178
-
179
- function unlinkExactRecord(path, record) {
180
- if (!exactRecordAt(path, record)) return;
181
- unlinkSync(path);
182
- syncDirectory(dirname(path));
183
- }
184
-
185
- function removeJournalUnlocked(root) {
186
- const path = journalPath(root, false);
187
- const details = path === null ? null : lstatSync(path, { throwIfNoEntry: false });
188
- if (!details) return;
189
- if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1) {
190
- fail("REVIEW_TRANSACTION_UNSAFE");
191
- }
192
- unlinkSync(path);
193
- syncDirectory(dirname(path));
194
- }
195
-
196
- const oneOf = (actual, candidates) => candidates.some((candidate) => actual?.equals(candidate));
197
-
198
- export function recoverReviewTransactionUnlocked(root) {
199
- const journal = readJournalUnlocked(root);
200
- if (journal === null) return { status: "none" };
201
- const directory = proposalDirectory(root, false);
202
- if (directory === null && journal.proposals.length > 0) fail("REVIEW_TRANSACTION_AMBIGUOUS");
203
- const decoded = Object.fromEntries([
204
- "beforeState", "activePending", "rollbackPending", "afterState", "afterPending",
205
- ].map((field) => [field, Buffer.from(journal[field], "base64")]));
206
- const statePath = join(stateDirectory(root, false), "skill-loop-state.json");
207
- const pendingPath = join(stateDirectory(root, false), "pending-review.json");
208
- const state = currentBytes(statePath, MAX_STATE_BYTES);
209
- const pending = currentBytes(pendingPath, MAX_STATE_BYTES);
210
-
211
- if (journal.phase === "committed") {
212
- if (!state?.equals(decoded.afterState) || !pending?.equals(decoded.afterPending)) {
213
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
214
- }
215
- for (const record of journal.proposals) {
216
- const stage = join(directory, record.stageName);
217
- const final = join(directory, record.finalName);
218
- if (lstatSync(stage, { throwIfNoEntry: false }) || !exactRecordAt(final, record)) {
219
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
220
- }
221
- }
222
- removeJournalUnlocked(root);
223
- return { status: "finalized" };
224
- }
225
-
226
- if (!oneOf(state, [decoded.beforeState, decoded.afterState])
227
- || !oneOf(pending, [decoded.activePending, decoded.rollbackPending, decoded.afterPending])) {
228
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
229
- }
230
- for (const record of journal.proposals) {
231
- const stage = join(directory, record.stageName);
232
- const final = join(directory, record.finalName);
233
- if (record.stageState === "absent") {
234
- if (lstatSync(stage, { throwIfNoEntry: false }) || lstatSync(final, { throwIfNoEntry: false })) {
235
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
236
- }
237
- continue;
238
- }
239
- if (record.stageState === "creating") {
240
- if (lstatSync(final, { throwIfNoEntry: false })) fail("REVIEW_TRANSACTION_AMBIGUOUS");
241
- exactUnidentifiedRecordAt(stage, record);
242
- continue;
243
- }
244
- const hasStage = exactRecordAt(stage, record);
245
- const hasFinal = exactRecordAt(final, record);
246
- if (hasStage && hasFinal) fail("REVIEW_TRANSACTION_AMBIGUOUS");
247
- }
248
- for (const record of [...journal.proposals].reverse()) {
249
- unlinkExactRecord(join(directory, record.finalName), record);
250
- if (record.stageState === "creating") {
251
- const stage = join(directory, record.stageName);
252
- if (exactUnidentifiedRecordAt(stage, record)) {
253
- unlinkSync(stage);
254
- syncDirectory(directory);
255
- }
256
- } else if (record.stageState === "ready") unlinkExactRecord(join(directory, record.stageName), record);
257
- }
258
- atomicWriteBytes(statePath, decoded.beforeState,
259
- SkillLoopReviewTransactionError, "REVIEW_TRANSACTION_UNSAFE");
260
- atomicWriteBytes(pendingPath, decoded.rollbackPending,
261
- SkillLoopReviewTransactionError, "REVIEW_TRANSACTION_UNSAFE");
262
- removeJournalUnlocked(root);
263
- return { status: "rolled-back" };
264
- }
265
-
266
- export function recoverReviewTransaction(root) {
267
- return withSkillLoopLock(root, "transaction", () => {
268
- recoverSkillMutationTransactionUnlocked(root);
269
- return recoverReviewTransactionUnlocked(root);
270
- },
271
- SkillLoopReviewTransactionError, "REVIEW_TRANSACTION_LOCK_FAILED");
272
- }
273
-
274
- export function beginReviewTransactionUnlocked(root, {
275
- beforeState,
276
- activePending,
277
- rollbackPending,
278
- afterState,
279
- afterPending,
280
- proposals,
281
- now,
282
- }) {
283
- if (readJournalUnlocked(root) !== null) fail("REVIEW_TRANSACTION_ACTIVE");
284
- const id = randomUUID();
285
- const journal = {
286
- schema: "litclaude.review-transaction/v1",
287
- id,
288
- createdAt: now,
289
- phase: "prepared",
290
- beforeState: beforeState.toString("base64"),
291
- activePending: activePending.toString("base64"),
292
- rollbackPending: rollbackPending.toString("base64"),
293
- afterState: afterState.toString("base64"),
294
- afterPending: afterPending.toString("base64"),
295
- proposals: proposals.map((proposal) => {
296
- const content = prettyJsonBytes(proposal);
297
- return {
298
- id: proposal.id,
299
- content: content.toString("base64"),
300
- digest: sha256(content),
301
- stageName: `.${id}.${proposal.id}.staged`,
302
- finalName: `${proposal.id}.json`,
303
- stageState: "absent",
304
- identity: null,
305
- };
306
- }),
307
- };
308
- return writeJournalUnlocked(root, journal);
309
- }
310
-
311
- export function stageReviewTransactionUnlocked(root, journal, afterStageWrite, afterStageCreate) {
312
- const directory = proposalDirectory(root, true);
313
- for (const [index, record] of journal.proposals.entries()) {
314
- const path = join(directory, record.stageName);
315
- if (lstatSync(path, { throwIfNoEntry: false })) fail("REVIEW_TRANSACTION_AMBIGUOUS");
316
- journal.proposals[index] = { ...record, stageState: "creating" };
317
- writeJournalUnlocked(root, journal);
318
- atomicWriteBytes(path, Buffer.from(record.content, "base64"),
319
- SkillLoopReviewTransactionError, "REVIEW_TRANSACTION_UNSAFE");
320
- afterStageCreate?.(index);
321
- const details = lstatSync(path, { throwIfNoEntry: false });
322
- if (!details?.isFile() || details.isSymbolicLink() || details.nlink !== 1) {
323
- fail("REVIEW_TRANSACTION_UNSAFE");
324
- }
325
- journal.proposals[index] = {
326
- ...journal.proposals[index],
327
- stageState: "ready",
328
- identity: { dev: details.dev, ino: details.ino },
329
- };
330
- writeJournalUnlocked(root, journal);
331
- afterStageWrite?.(index);
332
- }
333
- return journal;
334
- }
335
-
336
- export function publishReviewTransactionUnlocked(root, journal, afterProposalWrite) {
337
- const directory = proposalDirectory(root, false);
338
- for (const [index, record] of journal.proposals.entries()) {
339
- const stage = join(directory, record.stageName);
340
- const final = join(directory, record.finalName);
341
- if (!exactRecordAt(stage, record) || lstatSync(final, { throwIfNoEntry: false })) {
342
- fail("REVIEW_TRANSACTION_AMBIGUOUS");
343
- }
344
- renameSync(stage, final);
345
- syncDirectory(directory);
346
- if (!exactRecordAt(final, record)) fail("REVIEW_TRANSACTION_AMBIGUOUS");
347
- afterProposalWrite?.(index);
348
- }
349
- }
350
-
351
- export function markReviewTransactionCommittedUnlocked(root, journal) {
352
- journal.phase = "committed";
353
- writeJournalUnlocked(root, journal);
354
- }
355
-
356
- export function clearReviewTransactionUnlocked(root) {
357
- removeJournalUnlocked(root);
358
- }