@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.
- package/CHANGELOG.md +13 -7
- package/README.md +18 -38
- package/README_ko-KR.md +18 -37
- package/docs/assets/cover-motion.webp +0 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +10 -10
- 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 +71 -65
- package/plugins/litclaude/bin/litclaude-hud.js +5 -1
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/commands/lit-handoff.md +3 -3
- package/plugins/litclaude/commands/lit-scientific-visualization.md +2 -2
- 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 +6 -28
- package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
- package/plugins/litclaude/lib/hud-ignition.mjs +74 -0
- 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-handoff/SKILL.md +2 -2
- package/plugins/litclaude/skills/lit-scientific-visualization/SKILL.md +3 -3
- package/plugins/litclaude/skills/lit-team/scripts/team.mjs +4 -3
- package/plugins/litclaude/skills/litwork/SKILL.md +2 -2
- package/scripts/prepublish-test-gate.mjs +26 -0
- package/scripts/qa-real-surface-lib.mjs +3 -2
- package/scripts/strip-ansi.mjs +1 -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,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
|
-
}
|