@litfamily/litclaude 1.0.6 → 1.0.8

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 (56) hide show
  1. package/CHANGELOG.md +13 -7
  2. package/README.md +18 -38
  3. package/README_ko-KR.md +18 -37
  4. package/docs/assets/cover-motion.webp +0 -0
  5. package/docs/assets/readme/badge-version.svg +1 -1
  6. package/docs/hooks.md +10 -10
  7. package/docs/privacy.md +12 -17
  8. package/package.json +3 -1
  9. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  10. package/plugins/litclaude/bin/litclaude-hook.js +71 -65
  11. package/plugins/litclaude/bin/litclaude-hud.js +5 -1
  12. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  13. package/plugins/litclaude/commands/lit-handoff.md +3 -3
  14. package/plugins/litclaude/commands/lit-scientific-visualization.md +2 -2
  15. package/plugins/litclaude/hooks/hooks.json +4 -4
  16. package/plugins/litclaude/lib/canonical-skill-catalog.mjs +0 -1
  17. package/plugins/litclaude/lib/canonical-skill-resources.mjs +6 -28
  18. package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
  19. package/plugins/litclaude/lib/hud-ignition.mjs +74 -0
  20. package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
  21. package/plugins/litclaude/lib/litgoal/paths.mjs +10 -8
  22. package/plugins/litclaude/lib/project-state-root.mjs +22 -0
  23. package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
  24. package/plugins/litclaude/lib/start-work-cli.mjs +3 -3
  25. package/plugins/litclaude/lib/wikify-knowledge-cli.mjs +3 -1
  26. package/plugins/litclaude/lib/wikify-knowledge.mjs +6 -5
  27. package/plugins/litclaude/scripts/scaffold-plan.mjs +9 -3
  28. package/plugins/litclaude/skills/lit-handoff/SKILL.md +2 -2
  29. package/plugins/litclaude/skills/lit-scientific-visualization/SKILL.md +3 -3
  30. package/plugins/litclaude/skills/lit-team/scripts/team.mjs +4 -3
  31. package/plugins/litclaude/skills/litwork/SKILL.md +2 -2
  32. package/scripts/prepublish-test-gate.mjs +26 -0
  33. package/scripts/qa-real-surface-lib.mjs +3 -2
  34. package/scripts/strip-ansi.mjs +1 -0
  35. package/plugins/litclaude/lib/skill-loop/apply.mjs +0 -391
  36. package/plugins/litclaude/lib/skill-loop/authority.mjs +0 -129
  37. package/plugins/litclaude/lib/skill-loop/cli.mjs +0 -67
  38. package/plugins/litclaude/lib/skill-loop/common.mjs +0 -280
  39. package/plugins/litclaude/lib/skill-loop/config.mjs +0 -91
  40. package/plugins/litclaude/lib/skill-loop/curator.mjs +0 -670
  41. package/plugins/litclaude/lib/skill-loop/hook-runtime.mjs +0 -188
  42. package/plugins/litclaude/lib/skill-loop/hook-state.mjs +0 -484
  43. package/plugins/litclaude/lib/skill-loop/ledger.mjs +0 -834
  44. package/plugins/litclaude/lib/skill-loop/mutation-transaction.mjs +0 -529
  45. package/plugins/litclaude/lib/skill-loop/proposal-reader.mjs +0 -26
  46. package/plugins/litclaude/lib/skill-loop/proposal-record.mjs +0 -104
  47. package/plugins/litclaude/lib/skill-loop/proposal-store.mjs +0 -187
  48. package/plugins/litclaude/lib/skill-loop/proposals.mjs +0 -26
  49. package/plugins/litclaude/lib/skill-loop/review-runner.mjs +0 -13
  50. package/plugins/litclaude/lib/skill-loop/review-transaction.mjs +0 -358
  51. package/plugins/litclaude/lib/skill-loop/review.mjs +0 -223
  52. package/plugins/litclaude/lib/skill-loop/usage.mjs +0 -218
  53. package/plugins/litclaude/lib/skill-observer.mjs +0 -693
  54. package/plugins/litclaude/skills/skill-observer/SKILL.md +0 -155
  55. package/plugins/litclaude/skills/skill-observer/references/review-contract.md +0 -77
  56. package/plugins/litclaude/skills/skill-observer/scripts/skill-loop.mjs +0 -10
@@ -1,188 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { lstatSync, realpathSync } from "node:fs";
3
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
-
5
- import { containsSecret } from "../secret-shapes.mjs";
6
- import { recordObservation } from "../skill-observer.mjs";
7
- import { SKILL_ID } from "./common.mjs";
8
- import { readSkillLoopConfig } from "./config.mjs";
9
- import { runCurator, SESSION_START_CURATOR_LIMITS } from "./curator.mjs";
10
- import {
11
- disableSessionRecording,
12
- readLatestHookActivity,
13
- readHookState,
14
- recordCorrectionSignal,
15
- recordToolIteration,
16
- requestPendingReview,
17
- } from "./hook-state.mjs";
18
- import { listProposals } from "./proposals.mjs";
19
- import { recordSkillConsult } from "./usage.mjs";
20
- import {
21
- createSnapshotWorkBudget,
22
- SESSION_START_LEDGER_WORK_LIMITS,
23
- } from "./ledger.mjs";
24
-
25
- const CORRECTION_SIGNAL = /\b(?:don't|do not|stop|never|why\s+(?:do\s+you\s+)?keep)\b|그만|하지\s*마|왜\s*자꾸/iu;
26
- const SESSION_RECORDING_OPTOUT = /^\s*(?:don't\s+record\s+this\s+session|do\s+not\s+record\s+this\s+session|이\s+세션(?:을)?\s+기록하지\s+마(?:세요)?)\s*[.!]?\s*$/iu;
27
- const MAX_CORRECTION_CHARS = 1000;
28
- const MAX_READ_PATH_CHARS = 4096;
29
-
30
- const contained = (root, child) => {
31
- const rel = relative(root, child);
32
- return rel !== "" && !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`);
33
- };
34
-
35
- function lexicalAliasFor(realRoot, requested) {
36
- let current = dirname(requested);
37
- while (true) {
38
- try {
39
- if (realpathSync.native(current) === realRoot) return current;
40
- } catch {
41
- return null;
42
- }
43
- const parent = dirname(current);
44
- if (parent === current) return null;
45
- current = parent;
46
- }
47
- }
48
-
49
- function resolveInsideSkillRoot(skillRoot, requested) {
50
- const rootDetails = lstatSync(skillRoot, { throwIfNoEntry: false });
51
- if (!rootDetails?.isDirectory() || rootDetails.isSymbolicLink()) return null;
52
- const absolute = resolve(requested);
53
- let realRoot;
54
- let realFile;
55
- try {
56
- realRoot = realpathSync.native(skillRoot);
57
- realFile = realpathSync.native(absolute);
58
- } catch {
59
- return null;
60
- }
61
- if (!contained(realRoot, realFile)) return null;
62
- const lexicalRoot = lexicalAliasFor(realRoot, absolute);
63
- if (lexicalRoot === null || !contained(lexicalRoot, absolute)) return null;
64
- const rel = relative(lexicalRoot, absolute);
65
- let current = lexicalRoot;
66
- for (const component of rel.split(/[\\/]/u)) {
67
- current = join(current, component);
68
- const details = lstatSync(current, { throwIfNoEntry: false });
69
- if (!details || details.isSymbolicLink()) return null;
70
- }
71
- const details = lstatSync(absolute, { throwIfNoEntry: false });
72
- if (!details?.isFile() || details.isSymbolicLink() || details.nlink !== 1) return null;
73
- const [skillId] = relative(realRoot, realFile).split(/[\\/]/u);
74
- return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(skillId) ? skillId : null;
75
- }
76
-
77
- function skillIdFromSkillTool(raw) {
78
- if (typeof raw !== "string") return null;
79
- const separator = raw.indexOf(":");
80
- if (separator === -1) return SKILL_ID.test(raw) ? raw : null;
81
- const plugin = raw.slice(0, separator);
82
- const name = raw.slice(separator + 1);
83
- if (plugin.length === 0 || name.length === 0 || name.includes(":")) return null;
84
- return SKILL_ID.test(name) ? name : null;
85
- }
86
-
87
- export function skillIdFromToolUse(input, workspaceRoot, pluginRoot) {
88
- if (input?.tool_name === "Skill") {
89
- return skillIdFromSkillTool(input?.tool_input?.skill);
90
- }
91
- if (input?.tool_name !== "Read" || typeof input?.tool_input?.file_path !== "string"
92
- || input.tool_input.file_path.length < 1 || input.tool_input.file_path.length > MAX_READ_PATH_CHARS) return null;
93
- const requested = isAbsolute(input.tool_input.file_path)
94
- ? input.tool_input.file_path
95
- : resolve(workspaceRoot, input.tool_input.file_path);
96
- return resolveInsideSkillRoot(join(workspaceRoot, ".claude", "skills"), requested)
97
- ?? resolveInsideSkillRoot(join(pluginRoot, "skills"), requested);
98
- }
99
-
100
- export function observePreToolUse(root, input, pluginRoot) {
101
- if (typeof input?.session_id !== "string") return null;
102
- const skillId = skillIdFromToolUse(input, root, pluginRoot);
103
- const recorded = recordToolIteration(root, input.session_id, skillId ?? undefined);
104
- if (recorded && skillId) recordSkillConsult(root, skillId);
105
- return recorded ? skillId : null;
106
- }
107
-
108
- export function observeCorrection(root, input) {
109
- const prompt = typeof input?.prompt === "string" ? input.prompt : "";
110
- if (typeof input?.session_id !== "string") return false;
111
- if (SESSION_RECORDING_OPTOUT.test(prompt)) {
112
- disableSessionRecording(root, input.session_id);
113
- return false;
114
- }
115
- if (!CORRECTION_SIGNAL.test(prompt) || containsSecret(prompt)) return false;
116
- const state = readHookState(root, input.session_id);
117
- if (state.recordingDisabled || state.consultedSkills.length === 0 || state.proposalCount >= 3) return false;
118
- const skillId = state.consultedSkills.at(-1);
119
- const observed = prompt.slice(0, MAX_CORRECTION_CHARS);
120
- recordObservation(root, {
121
- signal: "correction",
122
- skillId,
123
- observed,
124
- proposal: `Review ${skillId} against this bounded correction and queue only a reusable pending change.`,
125
- });
126
- return recordCorrectionSignal(root, input.session_id);
127
- }
128
-
129
- export function scheduleStopReview(root, input, runnerPath) {
130
- if (typeof input?.session_id !== "string" || typeof input?.transcript_path !== "string") return false;
131
- const state = readHookState(root, input.session_id);
132
- if (state.recordingDisabled) return false;
133
- const config = readSkillLoopConfig(root);
134
- const reasons = [];
135
- if (state.correctionCount > 0) reasons.push("correction");
136
- if (state.toolIterations >= config.nudgeInterval) reasons.push("nudge-interval");
137
- if (reasons.length === 0 || state.proposalCount >= 3) return false;
138
- const request = requestPendingReview(root, input.session_id, input.transcript_path, reasons);
139
- if (!request.created) return false;
140
- const child = spawn(process.execPath, [runnerPath, root], {
141
- detached: true,
142
- stdio: "ignore",
143
- cwd: root,
144
- env: { ...process.env, LITFAMILY_SKILL_REVIEW: "1" },
145
- });
146
- child.unref();
147
- return true;
148
- }
149
-
150
- export function pendingProposalNotice(root, {
151
- ledgerWorkLimits = SESSION_START_LEDGER_WORK_LIMITS,
152
- } = {}) {
153
- const count = listProposals(root, { ledgerWorkLimits }).filter(({ status }) => status === "pending").length;
154
- return count > 0
155
- ? `${count} skill proposals pending — \`/litclaude:skill-observer list\``
156
- : "";
157
- }
158
-
159
- export function runSessionStartCurator(root, {
160
- now = new Date().toISOString(),
161
- workLimits = SESSION_START_CURATOR_LIMITS,
162
- } = {}) {
163
- const usagePath = join(resolve(root), ".litclaude", "skill-usage.json");
164
- const usage = lstatSync(usagePath, { throwIfNoEntry: false });
165
- if (!usage) return { reason: "NO_USAGE_STATE", transitions: [] };
166
- if (!usage.isFile() || usage.isSymbolicLink() || usage.nlink !== 1) {
167
- return { reason: "USAGE_STATE_UNSAFE", transitions: [] };
168
- }
169
- const snapshotWork = createSnapshotWorkBudget();
170
- let lastActivityAt;
171
- try {
172
- lastActivityAt = readLatestHookActivity(root, {
173
- mutationRecoveryOptions: workLimits === null ? undefined : {
174
- snapshotOptions: { workLimits, work: snapshotWork },
175
- },
176
- });
177
- } catch (error) {
178
- if (error?.code === "SNAPSHOT_WORK_CAP_EXCEEDED") {
179
- return {
180
- reason: "WORK_CAP_EXCEEDED",
181
- transitions: [],
182
- advisory: "Skill curator deferred: SessionStart work cap exceeded; run `/litclaude:skill-observer curator run` explicitly.",
183
- };
184
- }
185
- throw error;
186
- }
187
- return runCurator(root, { now, lastActivityAt, workLimits, snapshotWork });
188
- }
@@ -1,484 +0,0 @@
1
- import { lstatSync, unlinkSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
- import {
5
- assertNoSecretStrings,
6
- assertRegularSingleLink,
7
- atomicWriteJson,
8
- ensureSafeDirectoryChain,
9
- isPlainObject,
10
- parseTimestamp,
11
- prettyJsonBytes,
12
- readStrictJsonFile,
13
- RECORD_ID,
14
- requireExactKeys,
15
- SKILL_ID,
16
- withSkillLoopLock,
17
- } from "./common.mjs";
18
- import { validateProposal } from "./proposal-record.mjs";
19
- import {
20
- assertProposalCapacityUnlocked,
21
- listProposalRecords,
22
- normalizeProposalTargetRoot,
23
- } from "./proposal-store.mjs";
24
- import {
25
- beginReviewTransactionUnlocked,
26
- clearReviewTransactionUnlocked,
27
- markReviewTransactionCommittedUnlocked,
28
- publishReviewTransactionUnlocked,
29
- recoverReviewTransactionUnlocked,
30
- stageReviewTransactionUnlocked,
31
- } from "./review-transaction.mjs";
32
- import { recoverSkillMutationTransactionUnlocked } from "./mutation-transaction.mjs";
33
-
34
- export class SkillLoopHookStateError extends Error {
35
- constructor(code, message = code) {
36
- super(`${code}: ${message}`);
37
- this.name = "SkillLoopHookStateError";
38
- this.code = code;
39
- }
40
- }
41
-
42
- const MAX_STATE_BYTES = 64 * 1024;
43
- const MAX_SESSIONS = 32;
44
- const MAX_CONSULTED_SKILLS = 32;
45
- const MAX_PROPOSALS_PER_SESSION = 3;
46
- const REVIEW_REASONS = new Set(["correction", "nudge-interval"]);
47
- const SKIP_SESSION_WRITE = Symbol("skip-session-write");
48
- const skipSessionWrite = (value) => ({ [SKIP_SESSION_WRITE]: true, value });
49
- const stateFile = (root, name, create) => {
50
- const state = ensureSafeDirectoryChain(root, [".litclaude"], {
51
- create,
52
- ErrorType: SkillLoopHookStateError,
53
- code: "HOOK_STATE_UNSAFE",
54
- });
55
- return state === null ? null : join(state, name);
56
- };
57
- const nowIso = () => new Date().toISOString();
58
- const emptyStore = () => ({
59
- schema: "litclaude.skill-loop-state/v1",
60
- updatedAt: "1970-01-01T00:00:00.000Z",
61
- sessions: {},
62
- });
63
- const freshSession = (sessionId, now = nowIso()) => ({
64
- sessionId,
65
- toolIterations: 0,
66
- consultedSkills: [],
67
- correctionCount: 0,
68
- proposalCount: 0,
69
- pendingReview: false,
70
- recordingDisabled: false,
71
- lastActivityAt: now,
72
- });
73
- const fail = (code, message) => { throw new SkillLoopHookStateError(code, message); };
74
-
75
- function validateSession(entry, key) {
76
- if (!requireExactKeys(entry, [
77
- "sessionId", "toolIterations", "consultedSkills", "correctionCount", "proposalCount",
78
- "pendingReview", "lastActivityAt",
79
- ], ["recordingDisabled"]) || entry.sessionId !== key || !RECORD_ID.test(entry.sessionId)
80
- || !Number.isSafeInteger(entry.toolIterations) || entry.toolIterations < 0 || entry.toolIterations > 1_000_000
81
- || !Array.isArray(entry.consultedSkills) || entry.consultedSkills.length > MAX_CONSULTED_SKILLS
82
- || entry.consultedSkills.some((skill) => !SKILL_ID.test(skill))
83
- || new Set(entry.consultedSkills).size !== entry.consultedSkills.length
84
- || !Number.isSafeInteger(entry.correctionCount) || entry.correctionCount < 0 || entry.correctionCount > 1_000_000
85
- || !Number.isSafeInteger(entry.proposalCount) || entry.proposalCount < 0
86
- || entry.proposalCount > MAX_PROPOSALS_PER_SESSION
87
- || typeof entry.pendingReview !== "boolean"
88
- || (Object.hasOwn(entry, "recordingDisabled") && typeof entry.recordingDisabled !== "boolean")
89
- || parseTimestamp(entry.lastActivityAt) === null) {
90
- fail("HOOK_STATE_INVALID", "session state is invalid");
91
- }
92
- }
93
-
94
- function validateStore(store) {
95
- let bytes;
96
- try { bytes = prettyJsonBytes(store); } catch { fail("HOOK_STATE_INVALID"); }
97
- if (bytes.length > MAX_STATE_BYTES) fail("HOOK_STATE_TOO_LARGE");
98
- if (!requireExactKeys(store, ["schema", "updatedAt", "sessions"])
99
- || store.schema !== "litclaude.skill-loop-state/v1" || parseTimestamp(store.updatedAt) === null
100
- || !isPlainObject(store.sessions) || Object.keys(store.sessions).length > MAX_SESSIONS) {
101
- fail("HOOK_STATE_INVALID", "session store is invalid");
102
- }
103
- for (const [key, entry] of Object.entries(store.sessions)) validateSession(entry, key);
104
- assertNoSecretStrings(store, () => fail("HOOK_STATE_SECRET_REFUSED"));
105
- return store;
106
- }
107
-
108
- function readStoreUnlocked(root) {
109
- const path = stateFile(root, "skill-loop-state.json", false);
110
- if (path === null || !lstatSync(path, { throwIfNoEntry: false })) return emptyStore();
111
- try {
112
- return validateStore(readStrictJsonFile(path, SkillLoopHookStateError, "HOOK_STATE_UNSAFE", {
113
- maxBytes: MAX_STATE_BYTES,
114
- parseCode: "HOOK_STATE_INVALID",
115
- }));
116
- } catch (error) {
117
- if (error instanceof SkillLoopHookStateError) throw error;
118
- fail("HOOK_STATE_INVALID");
119
- }
120
- }
121
-
122
- function writeStoreUnlocked(root, store) {
123
- validateStore(store);
124
- atomicWriteJson(stateFile(root, "skill-loop-state.json", true), store,
125
- SkillLoopHookStateError, "HOOK_STATE_UNSAFE");
126
- return store;
127
- }
128
-
129
- function updateSession(root, sessionId, operation, now = nowIso()) {
130
- if (!RECORD_ID.test(sessionId) || parseTimestamp(now) === null) fail("HOOK_SESSION_INVALID");
131
- return withSkillLoopLock(root, "transaction", () => {
132
- recoverReviewTransactionUnlocked(root);
133
- recoverSkillMutationTransactionUnlocked(root);
134
- const store = readStoreUnlocked(root);
135
- const session = structuredClone(store.sessions[sessionId] ?? freshSession(sessionId, now));
136
- const result = operation(session);
137
- if (result?.[SKIP_SESSION_WRITE]) return result.value;
138
- session.lastActivityAt = now;
139
- store.sessions[sessionId] = session;
140
- const ordered = Object.values(store.sessions)
141
- .sort((left, right) => parseTimestamp(right.lastActivityAt) - parseTimestamp(left.lastActivityAt));
142
- store.sessions = Object.fromEntries(ordered.slice(0, MAX_SESSIONS).map((entry) => [entry.sessionId, entry]));
143
- store.updatedAt = now;
144
- writeStoreUnlocked(root, store);
145
- return result ?? structuredClone(session);
146
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
147
- }
148
-
149
- export function readHookState(root, sessionId) {
150
- if (!RECORD_ID.test(sessionId)) fail("HOOK_SESSION_INVALID");
151
- return withSkillLoopLock(root, "transaction", () => {
152
- recoverReviewTransactionUnlocked(root);
153
- recoverSkillMutationTransactionUnlocked(root);
154
- const store = readStoreUnlocked(root);
155
- return structuredClone(store.sessions[sessionId] ?? freshSession(sessionId, store.updatedAt));
156
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
157
- }
158
-
159
- export function readLatestHookActivity(root, { mutationRecoveryOptions } = {}) {
160
- return withSkillLoopLock(root, "transaction", () => {
161
- recoverReviewTransactionUnlocked(root);
162
- recoverSkillMutationTransactionUnlocked(root, mutationRecoveryOptions);
163
- const store = readStoreUnlocked(root);
164
- return Object.values(store.sessions).reduce(
165
- (latest, session) => parseTimestamp(session.lastActivityAt) > parseTimestamp(latest)
166
- ? session.lastActivityAt
167
- : latest,
168
- "1970-01-01T00:00:00.000Z",
169
- );
170
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
171
- }
172
-
173
- export function recordToolIteration(root, sessionId, skillId, { now = nowIso() } = {}) {
174
- if (skillId !== undefined && !SKILL_ID.test(skillId)) fail("HOOK_SKILL_INVALID");
175
- return updateSession(root, sessionId, (session) => {
176
- if (session.recordingDisabled) return skipSessionWrite(false);
177
- session.toolIterations += 1;
178
- if (skillId && !session.consultedSkills.includes(skillId)) {
179
- session.consultedSkills.push(skillId);
180
- if (session.consultedSkills.length > MAX_CONSULTED_SKILLS) session.consultedSkills.shift();
181
- }
182
- return true;
183
- }, now);
184
- }
185
-
186
- export function recordCorrectionSignal(root, sessionId, { now = nowIso() } = {}) {
187
- return updateSession(root, sessionId, (session) => {
188
- if (session.recordingDisabled || session.consultedSkills.length === 0
189
- || session.proposalCount >= MAX_PROPOSALS_PER_SESSION) return skipSessionWrite(false);
190
- session.correctionCount += 1;
191
- return true;
192
- }, now);
193
- }
194
-
195
- export function disableSessionRecording(root, sessionId, { now = nowIso() } = {}) {
196
- if (!RECORD_ID.test(sessionId) || parseTimestamp(now) === null) fail("HOOK_SESSION_INVALID");
197
- return withSkillLoopLock(root, "transaction", () => {
198
- recoverReviewTransactionUnlocked(root);
199
- recoverSkillMutationTransactionUnlocked(root);
200
- const store = readStoreUnlocked(root);
201
- const session = structuredClone(store.sessions[sessionId] ?? freshSession(sessionId, now));
202
- session.recordingDisabled = true;
203
- session.toolIterations = 0;
204
- session.consultedSkills = [];
205
- session.correctionCount = 0;
206
- session.pendingReview = false;
207
- session.lastActivityAt = now;
208
- store.sessions[sessionId] = session;
209
- const pending = readPendingUnlocked(root);
210
- const ordered = Object.values(store.sessions)
211
- .sort((left, right) => parseTimestamp(right.lastActivityAt) - parseTimestamp(left.lastActivityAt));
212
- store.sessions = Object.fromEntries(ordered.slice(0, MAX_SESSIONS).map((entry) => [entry.sessionId, entry]));
213
- store.updatedAt = now;
214
- writeStoreUnlocked(root, store);
215
- if (pending?.sessionId === sessionId && ["pending", "claimed"].includes(pending.status)) {
216
- removePendingUnlocked(root);
217
- }
218
- return true;
219
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
220
- }
221
-
222
- export function pendingProposalAllowance(root, sessionId) {
223
- return Math.max(0, MAX_PROPOSALS_PER_SESSION - readHookState(root, sessionId).proposalCount);
224
- }
225
-
226
- function validatePending(value) {
227
- let bytes;
228
- try { bytes = prettyJsonBytes(value); } catch { fail("PENDING_REVIEW_INVALID"); }
229
- if (bytes.length > MAX_STATE_BYTES) fail("PENDING_REVIEW_TOO_LARGE");
230
- if (!requireExactKeys(value, [
231
- "schema", "sessionId", "createdAt", "transcriptPath", "reasons", "consultedSkills",
232
- "remainingAllowance", "status", "claimedAt", "completedAt", "queuedProposalIds",
233
- ]) || value.schema !== "litclaude.pending-skill-review/v1" || !RECORD_ID.test(value.sessionId)
234
- || parseTimestamp(value.createdAt) === null || typeof value.transcriptPath !== "string"
235
- || value.transcriptPath.length < 1 || value.transcriptPath.length > 4096
236
- || !Array.isArray(value.reasons) || value.reasons.length < 1 || value.reasons.length > 2
237
- || value.reasons.some((reason) => !REVIEW_REASONS.has(reason)) || new Set(value.reasons).size !== value.reasons.length
238
- || !Array.isArray(value.consultedSkills) || value.consultedSkills.length > MAX_CONSULTED_SKILLS
239
- || value.consultedSkills.some((skill) => !SKILL_ID.test(skill))
240
- || new Set(value.consultedSkills).size !== value.consultedSkills.length
241
- || !Number.isSafeInteger(value.remainingAllowance) || value.remainingAllowance < 0
242
- || value.remainingAllowance > MAX_PROPOSALS_PER_SESSION
243
- || !["pending", "claimed", "completed"].includes(value.status)
244
- || !(value.claimedAt === null || parseTimestamp(value.claimedAt) !== null)
245
- || !(value.completedAt === null || parseTimestamp(value.completedAt) !== null)
246
- || !Array.isArray(value.queuedProposalIds) || value.queuedProposalIds.length > MAX_PROPOSALS_PER_SESSION
247
- || value.queuedProposalIds.some((id) => !RECORD_ID.test(id))
248
- || new Set(value.queuedProposalIds).size !== value.queuedProposalIds.length) fail("PENDING_REVIEW_INVALID");
249
- if ((value.status === "pending" && (value.claimedAt !== null || value.completedAt !== null))
250
- || (value.status === "claimed" && (value.claimedAt === null || value.completedAt !== null))
251
- || (value.status === "completed" && value.completedAt === null)) fail("PENDING_REVIEW_INVALID");
252
- assertNoSecretStrings(value, () => fail("PENDING_REVIEW_SECRET_REFUSED"));
253
- return value;
254
- }
255
-
256
- function readPendingUnlocked(root) {
257
- const path = stateFile(root, "pending-review.json", false);
258
- if (path === null || !lstatSync(path, { throwIfNoEntry: false })) return null;
259
- return validatePending(readStrictJsonFile(path, SkillLoopHookStateError, "HOOK_STATE_UNSAFE", {
260
- maxBytes: MAX_STATE_BYTES,
261
- parseCode: "PENDING_REVIEW_INVALID",
262
- }));
263
- }
264
-
265
- function writePendingUnlocked(root, value) {
266
- validatePending(value);
267
- atomicWriteJson(stateFile(root, "pending-review.json", true), value,
268
- SkillLoopHookStateError, "HOOK_STATE_UNSAFE");
269
- return value;
270
- }
271
-
272
- function removePendingUnlocked(root) {
273
- const path = stateFile(root, "pending-review.json", false);
274
- const before = path === null ? null : lstatSync(path, { throwIfNoEntry: false });
275
- if (!before) return null;
276
- assertRegularSingleLink(path, SkillLoopHookStateError, "HOOK_STATE_UNSAFE", { maxBytes: MAX_STATE_BYTES });
277
- const after = lstatSync(path, { throwIfNoEntry: false });
278
- if (!after?.isFile() || after.isSymbolicLink() || after.nlink !== 1
279
- || after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size
280
- || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) {
281
- fail("HOOK_STATE_UNSAFE");
282
- }
283
- try { unlinkSync(path); } catch { fail("HOOK_STATE_UNSAFE"); }
284
- return null;
285
- }
286
-
287
- function cancelDisabledPendingUnlocked(root, store, pending) {
288
- if (pending && ["pending", "claimed"].includes(pending.status)
289
- && store.sessions[pending.sessionId]?.recordingDisabled) {
290
- return removePendingUnlocked(root);
291
- }
292
- return pending;
293
- }
294
-
295
- export function readPendingReview(root) {
296
- return withSkillLoopLock(root, "transaction", () => {
297
- recoverReviewTransactionUnlocked(root);
298
- recoverSkillMutationTransactionUnlocked(root);
299
- const store = readStoreUnlocked(root);
300
- return structuredClone(cancelDisabledPendingUnlocked(root, store, readPendingUnlocked(root)));
301
- },
302
- SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
303
- }
304
-
305
- export function requestPendingReview(root, sessionId, transcriptPath, reasons, { now = nowIso() } = {}) {
306
- if (!RECORD_ID.test(sessionId) || parseTimestamp(now) === null) fail("PENDING_REVIEW_INVALID");
307
- return withSkillLoopLock(root, "transaction", () => {
308
- recoverReviewTransactionUnlocked(root);
309
- recoverSkillMutationTransactionUnlocked(root);
310
- const store = readStoreUnlocked(root);
311
- const current = cancelDisabledPendingUnlocked(root, store, readPendingUnlocked(root));
312
- if (current && current.status !== "completed") return { created: false, pending: current };
313
- const session = store.sessions[sessionId];
314
- if (!session || session.recordingDisabled) return { created: false, pending: null };
315
- const remainingAllowance = Math.max(0, MAX_PROPOSALS_PER_SESSION - session.proposalCount);
316
- if (remainingAllowance === 0) return { created: false, pending: null };
317
- const pending = validatePending({
318
- schema: "litclaude.pending-skill-review/v1",
319
- sessionId,
320
- createdAt: now,
321
- transcriptPath,
322
- reasons: [...new Set(reasons)],
323
- consultedSkills: [...session.consultedSkills],
324
- remainingAllowance,
325
- status: "pending",
326
- claimedAt: null,
327
- completedAt: null,
328
- queuedProposalIds: [],
329
- });
330
- session.pendingReview = true;
331
- session.lastActivityAt = now;
332
- store.updatedAt = now;
333
- writeStoreUnlocked(root, store);
334
- writePendingUnlocked(root, pending);
335
- return { created: true, pending };
336
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
337
- }
338
-
339
- export function claimPendingReview(root, { now = nowIso() } = {}) {
340
- return withSkillLoopLock(root, "transaction", () => {
341
- recoverReviewTransactionUnlocked(root);
342
- recoverSkillMutationTransactionUnlocked(root);
343
- const store = readStoreUnlocked(root);
344
- const pending = cancelDisabledPendingUnlocked(root, store, readPendingUnlocked(root));
345
- const reclaimable = pending?.status === "claimed"
346
- && parseTimestamp(now) - parseTimestamp(pending.claimedAt) >= 10 * 60 * 1000;
347
- if (!pending || store.sessions[pending.sessionId]?.recordingDisabled
348
- || (pending.status !== "pending" && !reclaimable)) fail("PENDING_REVIEW_NOT_AVAILABLE");
349
- return writePendingUnlocked(root, { ...pending, status: "claimed", claimedAt: now });
350
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
351
- }
352
-
353
- export function releasePendingReview(root) {
354
- return withSkillLoopLock(root, "transaction", () => {
355
- recoverReviewTransactionUnlocked(root);
356
- recoverSkillMutationTransactionUnlocked(root);
357
- const store = readStoreUnlocked(root);
358
- const pending = cancelDisabledPendingUnlocked(root, store, readPendingUnlocked(root));
359
- if (!pending || pending.status !== "claimed") return pending;
360
- return writePendingUnlocked(root, { ...pending, status: "pending", claimedAt: null });
361
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
362
- }
363
-
364
- export function completePendingReview(root, queuedProposalIds, { now = nowIso() } = {}) {
365
- return withSkillLoopLock(root, "transaction", () => {
366
- recoverReviewTransactionUnlocked(root);
367
- recoverSkillMutationTransactionUnlocked(root);
368
- const store = readStoreUnlocked(root);
369
- const pending = cancelDisabledPendingUnlocked(root, store, readPendingUnlocked(root));
370
- if (!pending || pending.status !== "claimed" || queuedProposalIds.length > pending.remainingAllowance) {
371
- fail("PENDING_REVIEW_INVALID");
372
- }
373
- const session = store.sessions[pending.sessionId];
374
- if (!session || session.proposalCount + queuedProposalIds.length > MAX_PROPOSALS_PER_SESSION) {
375
- fail("HOOK_STATE_INVALID");
376
- }
377
- session.proposalCount += queuedProposalIds.length;
378
- session.toolIterations = 0;
379
- session.correctionCount = 0;
380
- session.pendingReview = false;
381
- session.lastActivityAt = now;
382
- store.updatedAt = now;
383
- writeStoreUnlocked(root, store);
384
- return writePendingUnlocked(root, {
385
- ...pending,
386
- status: "completed",
387
- completedAt: now,
388
- queuedProposalIds: [...queuedProposalIds],
389
- });
390
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
391
- }
392
-
393
- export function commitPendingReviewBatch(root, proposals, {
394
- now = nowIso(),
395
- afterStageCreate,
396
- afterStageWrite,
397
- afterProposalWrite,
398
- afterStateWrite,
399
- afterPendingWrite,
400
- afterTransactionCommit,
401
- } = {}) {
402
- if (!Array.isArray(proposals) || parseTimestamp(now) === null) fail("PENDING_REVIEW_INVALID");
403
- return withSkillLoopLock(root, "transaction", () => {
404
- recoverReviewTransactionUnlocked(root);
405
- recoverSkillMutationTransactionUnlocked(root);
406
- const store = readStoreUnlocked(root);
407
- const pending = cancelDisabledPendingUnlocked(root, store, readPendingUnlocked(root));
408
- if (!pending || pending.status !== "claimed" || proposals.length > pending.remainingAllowance) {
409
- fail("PENDING_REVIEW_INVALID");
410
- }
411
- const persistedProposals = proposals.map((proposal) => normalizeProposalTargetRoot(root, proposal));
412
- const ids = new Set();
413
- for (const proposal of persistedProposals) {
414
- if (proposal.status !== "pending" || proposal.sessionRef !== pending.sessionId || ids.has(proposal.id)) {
415
- fail("PENDING_REVIEW_INVALID");
416
- }
417
- ids.add(proposal.id);
418
- }
419
- const existingIds = new Set(listProposalRecords(root).map(({ id }) => id));
420
- if (persistedProposals.some(({ id }) => existingIds.has(id))) fail("PENDING_REVIEW_INVALID");
421
- assertProposalCapacityUnlocked(root, persistedProposals.length);
422
-
423
- const session = store.sessions[pending.sessionId];
424
- if (!session || session.recordingDisabled
425
- || session.proposalCount + persistedProposals.length > MAX_PROPOSALS_PER_SESSION) {
426
- fail("HOOK_STATE_INVALID");
427
- }
428
- const nextStore = structuredClone(store);
429
- const nextSession = nextStore.sessions[pending.sessionId];
430
- nextSession.proposalCount += persistedProposals.length;
431
- nextSession.toolIterations = 0;
432
- nextSession.correctionCount = 0;
433
- nextSession.pendingReview = false;
434
- nextSession.lastActivityAt = now;
435
- nextStore.updatedAt = now;
436
- validateStore(nextStore);
437
- const nextPending = validatePending({
438
- ...pending,
439
- status: "completed",
440
- completedAt: now,
441
- queuedProposalIds: persistedProposals.map(({ id }) => id),
442
- });
443
-
444
- const storePath = stateFile(root, "skill-loop-state.json", false);
445
- const pendingPath = stateFile(root, "pending-review.json", false);
446
- if (storePath === null || pendingPath === null) fail("HOOK_STATE_INVALID");
447
- const originalStoreBytes = assertRegularSingleLink(storePath, SkillLoopHookStateError, "HOOK_STATE_UNSAFE", {
448
- maxBytes: MAX_STATE_BYTES,
449
- });
450
- const originalPendingBytes = assertRegularSingleLink(pendingPath, SkillLoopHookStateError, "HOOK_STATE_UNSAFE", {
451
- maxBytes: MAX_STATE_BYTES,
452
- });
453
- const rollbackPending = validatePending({ ...pending, status: "pending", claimedAt: null });
454
- let transaction;
455
- try {
456
- transaction = beginReviewTransactionUnlocked(root, {
457
- beforeState: originalStoreBytes,
458
- activePending: originalPendingBytes,
459
- rollbackPending: prettyJsonBytes(rollbackPending),
460
- afterState: prettyJsonBytes(nextStore),
461
- afterPending: prettyJsonBytes(nextPending),
462
- proposals: persistedProposals,
463
- now,
464
- });
465
- stageReviewTransactionUnlocked(root, transaction, afterStageWrite, afterStageCreate);
466
- publishReviewTransactionUnlocked(root, transaction, afterProposalWrite);
467
- writeStoreUnlocked(root, nextStore);
468
- afterStateWrite?.();
469
- writePendingUnlocked(root, nextPending);
470
- afterPendingWrite?.();
471
- markReviewTransactionCommittedUnlocked(root, transaction);
472
- afterTransactionCommit?.();
473
- clearReviewTransactionUnlocked(root);
474
- return nextPending;
475
- } catch (error) {
476
- if (transaction) {
477
- let recovery;
478
- try { recovery = recoverReviewTransactionUnlocked(root); } catch { fail("REVIEW_TRANSACTION_ROLLBACK_FAILED"); }
479
- if (recovery.status === "finalized") return nextPending;
480
- }
481
- throw error;
482
- }
483
- }, SkillLoopHookStateError, "HOOK_STATE_LOCK_FAILED");
484
- }