@litfamily/litgrok 1.0.2 → 1.0.3

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.
@@ -1,2087 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { createHash, randomUUID } from 'node:crypto';
4
- import {
5
- chmodSync,
6
- closeSync,
7
- constants,
8
- copyFileSync,
9
- existsSync,
10
- fstatSync,
11
- fsyncSync,
12
- lstatSync,
13
- linkSync,
14
- mkdirSync,
15
- openSync,
16
- readFileSync,
17
- readdirSync,
18
- realpathSync,
19
- renameSync,
20
- rmSync,
21
- statSync,
22
- unlinkSync,
23
- writeFileSync,
24
- writeSync,
25
- } from 'node:fs';
26
- import { homedir } from 'node:os';
27
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
28
- import { fileURLToPath } from 'node:url';
29
-
30
- const SCRIPT_PATH = fileURLToPath(import.meta.url);
31
- const RUNTIME_PLATFORM = process.platform;
32
- const SCRIPT_DIRECTORY = dirname(SCRIPT_PATH);
33
- const OBSERVER_DIRECTORY = dirname(SCRIPT_DIRECTORY);
34
- const PACKAGED_SKILLS_ROOT = dirname(OBSERVER_DIRECTORY);
35
- const PROPOSAL_SCHEMA = 'litfamily.skill-proposal/v1';
36
- const LEDGER_SCHEMA = 'litfamily.skill-ledger/v1';
37
- const USAGE_SCHEMA = 'litfamily.skill-usage/v1';
38
- const PENDING_SCHEMA = 'litgrok.pending-review/v1';
39
- const TRANSACTION_SCHEMA = 'litgrok.skill-transaction/v1';
40
- const TRANSACTION_AUTHORITY_SCHEMA = 'litgrok.skill-transaction-authority/v1';
41
- const TRANSACTION_COMPLETION_SCHEMA = 'litgrok.skill-transaction-completion/v1';
42
- const MARKER = 'metadata: { litgrokAgentGenerated: "true" }';
43
- const MAX_JSON_BYTES = 1024 * 1024;
44
- const MAX_PROPOSALS = 128;
45
- const MAX_SKILL_FILES = 256;
46
- const MAX_FILE_BYTES = 2 * 1024 * 1024;
47
- const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024;
48
- const LOCK_SCHEMA = 'litgrok.state-lock/v1';
49
- const LOCK_SCHEMA_V2 = 'litgrok.state-lock/v2';
50
- const STALE_LOCK_MS = 30_000;
51
- const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
52
- const SKILL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
53
- const DEVICE_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
54
- const SECRET_PATTERN = /(?:authorization\s*:\s*bearer\s+\S+|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|\bAKIA[0-9A-Z]{16}\b|\b(?:api[_ -]?key|password|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_./+:-]{8,}|\b(?:sk|xai)-[A-Za-z0-9_-]{12,}|\b(?:gh[pousr]_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255}|npm_[A-Za-z0-9]{20,255})\b|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b)/i;
55
- const INVISIBLE_SECRET_SEPARATOR_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
56
- const decoder = new TextDecoder('utf-8', { fatal: true });
57
-
58
- export class SkillLoopError extends Error {
59
- constructor(code, detail = '') {
60
- super(detail ? `${code}: ${detail}` : code);
61
- this.code = code;
62
- }
63
- }
64
-
65
- function fail(code, detail = '') {
66
- throw new SkillLoopError(code, detail);
67
- }
68
-
69
- export function isLearningLoopPlatformUnsupported(runtimePlatform, forcedPlatform) {
70
- return runtimePlatform === 'win32' || forcedPlatform === 'win32';
71
- }
72
-
73
- export function assertLearningLoopSupported(options = {}) {
74
- if (isLearningLoopPlatformUnsupported(RUNTIME_PLATFORM, options.platform)) fail('WINDOWS_SKILL_LOOP_UNSUPPORTED');
75
- }
76
-
77
- function isObject(value) {
78
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
79
- }
80
-
81
- function requireObject(value, code) {
82
- if (!isObject(value)) fail(code);
83
- return value;
84
- }
85
-
86
- function requireString(value, code, max = 8192) {
87
- if (typeof value !== 'string' || value.length === 0 || value.length > max || value.includes('\0')) fail(code);
88
- return value;
89
- }
90
-
91
- function requireExactKeys(value, allowed, required, code) {
92
- const object = requireObject(value, code);
93
- for (const key of Object.keys(object)) if (!allowed.includes(key)) fail(code, `unknown field ${key}`);
94
- for (const key of required) if (!Object.hasOwn(object, key)) fail(code, `missing field ${key}`);
95
- }
96
-
97
- function requireTimestamp(value, code = 'TIMESTAMP_INVALID') {
98
- requireString(value, code, 64);
99
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) fail(code);
100
- const time = Date.parse(value);
101
- if (!Number.isFinite(time)) fail(code);
102
- return time;
103
- }
104
-
105
- function posixOwnerToken() {
106
- const uid = process.getuid?.();
107
- if (!Number.isSafeInteger(uid) || uid < 0) fail('PLATFORM_IDENTITY_UNAVAILABLE');
108
- return `uid:${uid}`;
109
- }
110
-
111
- function platformOwnerForDirectory(path, options = {}) {
112
- if (process.platform === 'win32') {
113
- const status = lstatSync(path);
114
- if (!status.isDirectory() || status.isSymbolicLink() || realpathSync(path) !== resolve(path)) {
115
- fail(options.code ?? 'STATE_PATH_UNSAFE', path);
116
- }
117
- return 'windows-local';
118
- }
119
- const token = posixOwnerToken();
120
- const uid = Number(token.slice(4));
121
- const status = lstatSync(path);
122
- if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== uid || realpathSync(path) !== resolve(path)
123
- || (status.mode & 0o022) !== 0) {
124
- fail(options.code ?? 'STATE_PATH_UNSAFE', path);
125
- }
126
- return token;
127
- }
128
-
129
- export function validateStateLockOwner(record, identity) {
130
- requireObject(identity, 'STATE_LOCK_INVALID');
131
- const platform = requireString(identity.platform, 'STATE_LOCK_INVALID', 32);
132
- const ownerToken = requireString(identity.ownerToken, 'STATE_LOCK_INVALID', 256);
133
- if (platform === 'win32') fail('WINDOWS_SKILL_LOOP_UNSUPPORTED');
134
- if (record?.schema === LOCK_SCHEMA_V2) {
135
- requireExactKeys(record, ['schema', 'pid', 'ownerToken', 'createdAt', 'nonce'], ['schema', 'pid', 'ownerToken', 'createdAt', 'nonce'], 'STATE_LOCK_INVALID');
136
- if (!Number.isSafeInteger(record.pid) || record.pid < 1 || !ID_PATTERN.test(record.nonce ?? '')) fail('STATE_LOCK_INVALID');
137
- requireTimestamp(record.createdAt, 'STATE_LOCK_INVALID');
138
- if (!/^uid:\d+$/.test(record.ownerToken)) fail('STATE_LOCK_INVALID', 'POSIX owner token');
139
- if (record.ownerToken !== ownerToken) fail('STATE_LOCK_OWNER_MISMATCH');
140
- return record;
141
- }
142
- if (record?.schema === LOCK_SCHEMA) {
143
- requireExactKeys(record, ['schema', 'pid', 'ownerUid', 'createdAt', 'nonce'], ['schema', 'pid', 'ownerUid', 'createdAt', 'nonce'], 'STATE_LOCK_INVALID');
144
- if (!Number.isSafeInteger(record.pid) || record.pid < 1 || !Number.isSafeInteger(record.ownerUid) || record.ownerUid < 0
145
- || !ID_PATTERN.test(record.nonce ?? '')) fail('STATE_LOCK_INVALID');
146
- requireTimestamp(record.createdAt, 'STATE_LOCK_INVALID');
147
- if (`uid:${record.ownerUid}` !== ownerToken) fail('STATE_LOCK_OWNER_MISMATCH');
148
- return record;
149
- }
150
- fail('STATE_LOCK_INVALID', 'owner schema');
151
- }
152
-
153
- function safeMkdir(path, mode = 0o700) {
154
- if (!existsSync(path)) {
155
- const parent = dirname(path);
156
- if (parent !== path) safeMkdir(parent, mode);
157
- }
158
- try {
159
- mkdirSync(path, { mode });
160
- } catch (error) {
161
- if (error?.code !== 'EEXIST') throw error;
162
- }
163
- const status = lstatSync(path);
164
- if (!status.isDirectory() || status.isSymbolicLink()) fail('STATE_PATH_UNSAFE', path);
165
- }
166
-
167
- function ensureBelow(root, segments) {
168
- let current = root;
169
- const rootStatus = lstatSync(root);
170
- if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) fail('STATE_PATH_UNSAFE', root);
171
- for (const segment of segments) {
172
- current = join(current, segment);
173
- safeMkdir(current);
174
- }
175
- return current;
176
- }
177
-
178
- function canonicalExistingDirectory(path, code) {
179
- const resolved = resolve(path);
180
- const status = lstatSync(resolved);
181
- if (!status.isDirectory() || status.isSymbolicLink()) fail(code, resolved);
182
- return realpathSync(resolved);
183
- }
184
-
185
- export function createLoopContext(options = {}) {
186
- if (!options.stateOnly) assertLearningLoopSupported(options);
187
- const env = options.env ?? process.env;
188
- const cwd = options.cwd ?? process.cwd();
189
- const workspaceInput = env.LITGROK_WORKSPACE_ROOT || cwd;
190
- const workspaceRoot = canonicalExistingDirectory(workspaceInput, 'WORKSPACE_ROOT_UNSAFE');
191
- const homeInput = env.HOME || env.USERPROFILE || homedir();
192
- if (!isAbsolute(resolve(homeInput))) fail('HOME_UNSAFE');
193
- safeMkdir(resolve(homeInput));
194
- const home = canonicalExistingDirectory(homeInput, 'HOME_UNSAFE');
195
- const grokRoot = options.stateOnly ? join(home, '.grok') : ensureBelow(home, ['.grok']);
196
- const userRoot = options.stateOnly ? join(grokRoot, 'skills') : ensureBelow(grokRoot, ['skills']);
197
- const stateRoot = options.readOnlyState ? join(workspaceRoot, '.grok', 'litgrok') : ensureBelow(workspaceRoot, ['.grok', 'litgrok']);
198
- return { cwd, env, grokRoot, home, stateRoot, userRoot, workspaceRoot };
199
- }
200
-
201
- function readBytes(path, max = MAX_JSON_BYTES) {
202
- const status = lstatSync(path);
203
- if (!status.isFile() || status.isSymbolicLink() || status.size > max) fail('FILE_UNSAFE', path);
204
- return readFileSync(path);
205
- }
206
-
207
- function readText(path, max = MAX_JSON_BYTES) {
208
- try {
209
- return decoder.decode(readBytes(path, max));
210
- } catch (error) {
211
- if (error instanceof SkillLoopError) throw error;
212
- fail('UTF8_INVALID', path);
213
- }
214
- }
215
-
216
- function readJson(path, code = 'JSON_INVALID') {
217
- try {
218
- return JSON.parse(readText(path));
219
- } catch (error) {
220
- if (error instanceof SkillLoopError) throw error;
221
- fail(code, path);
222
- }
223
- }
224
-
225
- function assertSafeFileTarget(path) {
226
- if (!existsSync(path)) return;
227
- const status = lstatSync(path);
228
- if (!status.isFile() || status.isSymbolicLink()) fail('STATE_PATH_UNSAFE', path);
229
- }
230
-
231
- function pathEntryExists(path) {
232
- try {
233
- lstatSync(path);
234
- return true;
235
- } catch (error) {
236
- if (error?.code === 'ENOENT') return false;
237
- throw error;
238
- }
239
- }
240
-
241
- function syncDirectory(path) {
242
- if (process.platform === 'win32') return;
243
- const descriptor = openSync(path, constants.O_RDONLY);
244
- try {
245
- fsyncSync(descriptor);
246
- } finally {
247
- closeSync(descriptor);
248
- }
249
- }
250
-
251
- function atomicWrite(path, bytes, mode = 0o600) {
252
- safeMkdir(dirname(path));
253
- assertSafeFileTarget(path);
254
- const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
255
- const descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
256
- try {
257
- writeSync(descriptor, bytes);
258
- fsyncSync(descriptor);
259
- } finally {
260
- closeSync(descriptor);
261
- }
262
- renameSync(temporary, path);
263
- syncDirectory(dirname(path));
264
- }
265
-
266
- function atomicJson(path, value) {
267
- atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
268
- }
269
-
270
- function wait(milliseconds) {
271
- const buffer = new SharedArrayBuffer(4);
272
- Atomics.wait(new Int32Array(buffer), 0, 0, milliseconds);
273
- }
274
-
275
- function processIsAlive(pid) {
276
- if (!Number.isSafeInteger(pid) || pid < 1) fail('STATE_LOCK_INVALID', 'pid');
277
- try {
278
- process.kill(pid, 0);
279
- return true;
280
- } catch (error) {
281
- if (error?.code === 'ESRCH') return false;
282
- if (error?.code === 'EPERM') return true;
283
- fail('STATE_LOCK_INVALID', 'pid probe');
284
- }
285
- }
286
-
287
- function lockIdentity(path) {
288
- return {
289
- platform: process.platform,
290
- ownerToken: platformOwnerForDirectory(dirname(path), { code: 'STATE_LOCK_OWNERSHIP_UNVERIFIED' }),
291
- };
292
- }
293
-
294
- function validateLockFileSecurity(path, identity) {
295
- const status = lstatSync(path);
296
- if (!status.isFile() || status.isSymbolicLink() || status.size > 4096 || status.nlink !== 1) fail('STATE_LOCK_INVALID', path);
297
- if (identity.platform !== 'win32') {
298
- const ownerUid = Number(identity.ownerToken.slice(4));
299
- if (status.uid !== ownerUid) fail('STATE_LOCK_OWNER_MISMATCH', path);
300
- if ((status.mode & 0o7777) !== 0o600 || realpathSync(path) !== resolve(path)) fail('STATE_LOCK_INVALID', path);
301
- }
302
- return status;
303
- }
304
-
305
- function sameLockIdentity(left, right) {
306
- return left.isFile() === right.isFile()
307
- && left.isSymbolicLink() === right.isSymbolicLink()
308
- && left.dev === right.dev
309
- && left.ino === right.ino
310
- && left.uid === right.uid
311
- && left.mode === right.mode
312
- && left.nlink === right.nlink
313
- && left.size === right.size
314
- && left.ctimeMs === right.ctimeMs
315
- && left.birthtimeMs === right.birthtimeMs;
316
- }
317
-
318
- function sameLockHandle(left, right) {
319
- return left.isFile() === right.isFile()
320
- && left.isSymbolicLink() === right.isSymbolicLink()
321
- && left.dev === right.dev
322
- && left.ino === right.ino
323
- && left.uid === right.uid
324
- && left.mode === right.mode
325
- && left.nlink === right.nlink
326
- && left.size === right.size;
327
- }
328
-
329
- function sameLockLocation(left, right) {
330
- return left.isFile() === right.isFile()
331
- && left.isSymbolicLink() === right.isSymbolicLink()
332
- && left.dev === right.dev
333
- && left.ino === right.ino
334
- && left.uid === right.uid
335
- && left.nlink === right.nlink;
336
- }
337
-
338
- function inspectLockAgainstDescriptor(path, descriptor, identity, secure) {
339
- const named = secure ? validateLockFileSecurity(path, identity) : lstatSync(path);
340
- if (!named.isFile() || named.isSymbolicLink() || named.size > 4096 || named.nlink !== 1) fail('STATE_LOCK_INVALID', path);
341
- const held = fstatSync(descriptor);
342
- if (held.size > 4096) fail('STATE_LOCK_INVALID', path);
343
- let reader;
344
- try {
345
- reader = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
346
- const opened = fstatSync(reader);
347
- if (!sameLockLocation(opened, held) || opened.size !== named.size) fail('STATE_LOCK_RACE', path);
348
- const bytes = readFileSync(reader);
349
- const current = secure ? validateLockFileSecurity(path, identity) : lstatSync(path);
350
- if (!sameLockLocation(current, held) || current.size !== opened.size) fail('STATE_LOCK_RACE', path);
351
- return { bytes, digest: createHash('sha256').update(bytes).digest('hex'), status: current };
352
- } finally {
353
- if (reader !== undefined) closeSync(reader);
354
- }
355
- }
356
-
357
- function restoreMovedLock(path, tombstone) {
358
- try {
359
- linkSync(tombstone, path);
360
- unlinkSync(tombstone);
361
- } catch (error) {
362
- if (error?.code !== 'EEXIST') throw error;
363
- }
364
- }
365
-
366
- function removePinnedLock(path, descriptor, identity, expected, expectedBytes, code, {
367
- secure = false,
368
- tokenWritten = true,
369
- bytesWritten = Buffer.byteLength(expectedBytes, 'utf8'),
370
- faultInjector,
371
- phase,
372
- } = {}) {
373
- const bytes = Buffer.from(expectedBytes, 'utf8');
374
- const prefix = bytes.subarray(0, bytesWritten);
375
- const matches = (entry, beforeRename) => {
376
- const identityMatches = secure ? sameLockHandle(entry.status, expected) : sameLockLocation(entry.status, expected);
377
- if (!identityMatches) return false;
378
- if (tokenWritten ? !entry.bytes.equals(bytes) : !entry.bytes.equals(prefix)) return false;
379
- return !beforeRename || tokenWritten || bytesWritten > 0 || sameLockIdentity(entry.status, expected);
380
- };
381
- let checked;
382
- try {
383
- checked = inspectLockAgainstDescriptor(path, descriptor, identity, secure);
384
- } catch (error) {
385
- if (error?.code === 'ENOENT') return 'vanished';
386
- throw error;
387
- }
388
- if (!matches(checked, true)) fail(code, path);
389
- faultInjector?.(phase, { path, descriptor, expectedBytes });
390
- checked = inspectLockAgainstDescriptor(path, descriptor, identity, secure);
391
- if (!matches(checked, true)) fail(code, path);
392
- const tombstone = `${path}.cleanup-${process.pid}-${randomUUID()}`;
393
- try {
394
- renameSync(path, tombstone);
395
- } catch (error) {
396
- if (error?.code === 'ENOENT') return 'vanished';
397
- throw error;
398
- }
399
- let moved;
400
- try {
401
- moved = inspectLockAgainstDescriptor(tombstone, descriptor, identity, secure);
402
- } catch (error) {
403
- restoreMovedLock(path, tombstone);
404
- throw error;
405
- }
406
- if (!matches(moved, false)) {
407
- restoreMovedLock(path, tombstone);
408
- fail(code, path);
409
- }
410
- unlinkSync(tombstone);
411
- return 'removed';
412
- }
413
-
414
- function readSecureLock(path, identity, options = {}) {
415
- let expected;
416
- try {
417
- expected = options.expectedStatus ?? validateLockFileSecurity(path, identity);
418
- } catch (error) {
419
- if (options.missingLock === 'propagate' && error?.code === 'ENOENT') throw error;
420
- throw error instanceof SkillLoopError ? error : new SkillLoopError('STATE_LOCK_INVALID', path);
421
- }
422
- let descriptor;
423
- try {
424
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
425
- const opened = fstatSync(descriptor);
426
- if (!opened.isFile() || opened.size > 4096 || opened.nlink !== 1 || !sameLockIdentity(opened, expected)) {
427
- fail('STATE_LOCK_INVALID', path);
428
- }
429
- const bytes = readFileSync(descriptor);
430
- const contents = bytes.toString('utf8').trim();
431
- const digest = createHash('sha256').update(bytes).digest('hex');
432
- const current = validateLockFileSecurity(path, identity);
433
- if (!sameLockIdentity(current, opened)) fail('STATE_LOCK_RACE', path);
434
- return { contents, digest, status: current };
435
- } catch (error) {
436
- if (error instanceof SkillLoopError) throw error;
437
- if (options.missingLock === 'propagate' && error?.code === 'ENOENT') throw error;
438
- fail('STATE_LOCK_INVALID', path);
439
- } finally {
440
- if (descriptor !== undefined) closeSync(descriptor);
441
- }
442
- }
443
-
444
- function reclaimStaleLock(path, identity, options = {}) {
445
- if (identity.platform === 'win32') return false;
446
- options.faultInjector?.('reclaim-check', { path });
447
- let initial;
448
- try {
449
- initial = validateLockFileSecurity(path, identity);
450
- } catch (error) {
451
- if (error?.code === 'ENOENT') return 'vanished';
452
- throw error;
453
- }
454
- const fileAge = Date.now() - initial.mtimeMs;
455
- if (fileAge < -5_000) fail('STATE_LOCK_INVALID', 'future timestamp');
456
- if (fileAge < STALE_LOCK_MS) return false;
457
- options.faultInjector?.('reclaim-read', { path });
458
- let secureRead;
459
- try {
460
- secureRead = readSecureLock(path, identity, { missingLock: 'propagate', expectedStatus: initial });
461
- } catch (error) {
462
- if (error?.code === 'ENOENT') return 'vanished';
463
- throw error;
464
- }
465
- const { contents, digest, status } = secureRead;
466
- let record;
467
- if (/^[1-9][0-9]{0,14}$/.test(contents)) {
468
- if (identity.platform === 'win32') fail('STATE_LOCK_INVALID', 'legacy owner unavailable');
469
- record = {
470
- schema: LOCK_SCHEMA,
471
- pid: Number(contents),
472
- ownerUid: Number(identity.ownerToken.slice(4)),
473
- createdAt: new Date(status.mtimeMs).toISOString(),
474
- nonce: 'legacy-numeric-pid',
475
- };
476
- } else {
477
- try {
478
- record = JSON.parse(contents);
479
- } catch {
480
- fail('STATE_LOCK_INVALID', path);
481
- }
482
- validateStateLockOwner(record, identity);
483
- }
484
- const createdAt = requireTimestamp(record.createdAt, 'STATE_LOCK_INVALID');
485
- const age = Date.now() - createdAt;
486
- if (age < -5_000) fail('STATE_LOCK_INVALID', 'future timestamp');
487
- if (age < STALE_LOCK_MS || processIsAlive(record.pid)) return false;
488
-
489
- const tombstone = `${path}.stale-${process.pid}-${randomUUID()}`;
490
- try {
491
- renameSync(path, tombstone);
492
- } catch (error) {
493
- if (error?.code === 'ENOENT') return 'vanished';
494
- throw error;
495
- }
496
- const moved = readSecureLock(tombstone, identity);
497
- if (!sameLockHandle(moved.status, status) || moved.contents !== contents || moved.digest !== digest) {
498
- restoreMovedLock(path, tombstone);
499
- fail('STATE_LOCK_RACE', path);
500
- }
501
- unlinkSync(tombstone);
502
- return 'reclaimed';
503
- }
504
-
505
- function cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten, bytesWritten, identity, options = {}) {
506
- try {
507
- try {
508
- removePinnedLock(path, descriptor, identity, created, expectedBytes, 'STATE_LOCK_ACQUISITION_CLEANUP_FAILED', {
509
- tokenWritten,
510
- bytesWritten,
511
- faultInjector: options.faultInjector,
512
- phase: 'before-acquisition-cleanup-rename',
513
- });
514
- } catch (error) {
515
- if (error?.code === 'STATE_LOCK_ACQUISITION_CLEANUP_FAILED') throw error;
516
- fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', error?.code ?? 'retargeted');
517
- }
518
- } finally {
519
- try {
520
- closeSync(descriptor);
521
- } catch {
522
- fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'descriptor');
523
- }
524
- }
525
- }
526
-
527
- export function withStateLock(path, operation, attempts = 200, options = {}) {
528
- safeMkdir(dirname(path));
529
- const identity = lockIdentity(path);
530
- let descriptor;
531
- let ownerBytes;
532
- for (let index = 0; index < attempts; index += 1) {
533
- let created;
534
- let expectedBytes;
535
- let tokenWritten = false;
536
- let bytesWritten = 0;
537
- try {
538
- descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
539
- created = fstatSync(descriptor);
540
- expectedBytes = `${JSON.stringify({
541
- schema: LOCK_SCHEMA_V2,
542
- pid: process.pid,
543
- ownerToken: identity.ownerToken,
544
- createdAt: new Date().toISOString(),
545
- nonce: randomUUID(),
546
- })}\n`;
547
- ownerBytes = expectedBytes;
548
- const bytes = Buffer.from(expectedBytes, 'utf8');
549
- let offset = 0;
550
- while (offset < bytes.length) {
551
- options.faultInjector?.('before-token-write', { descriptor, path });
552
- const written = writeSync(descriptor, bytes, offset, bytes.length - offset);
553
- if (!Number.isSafeInteger(written) || written < 1) fail('STATE_LOCK_WRITE_FAILED');
554
- offset += written;
555
- bytesWritten = offset;
556
- }
557
- tokenWritten = true;
558
- fsyncSync(descriptor);
559
- options.faultInjector?.('after-create', { descriptor, path });
560
- validateLockFileSecurity(path, identity);
561
- break;
562
- } catch (error) {
563
- if (descriptor !== undefined) {
564
- cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten, bytesWritten, identity, options);
565
- descriptor = undefined;
566
- }
567
- if (error?.code !== 'EEXIST') throw error;
568
- const reclaimed = reclaimStaleLock(path, identity, options);
569
- if (reclaimed) {
570
- if (reclaimed === 'reclaimed') index -= 1;
571
- continue;
572
- }
573
- if (index === attempts - 1) fail('STATE_LOCK_BUSY', path);
574
- wait(5);
575
- }
576
- }
577
- if (descriptor === undefined) fail('STATE_LOCK_BUSY', path);
578
- const owned = fstatSync(descriptor);
579
- try {
580
- return operation();
581
- } finally {
582
- try {
583
- removePinnedLock(path, descriptor, identity, owned, ownerBytes, 'STATE_LOCK_OWNERSHIP_LOST', {
584
- secure: true,
585
- faultInjector: options.faultInjector,
586
- phase: 'before-release-rename',
587
- });
588
- } catch (error) {
589
- if (error?.code !== 'ENOENT') throw error;
590
- } finally {
591
- closeSync(descriptor);
592
- }
593
- }
594
- }
595
-
596
- function globalLock(context) {
597
- return join(context.stateRoot, 'skill-loop.lock');
598
- }
599
-
600
- function sessionKey(sessionId) {
601
- return createHash('sha256').update(requireString(sessionId, 'SESSION_ID_INVALID', 512)).digest('hex').slice(0, 32);
602
- }
603
-
604
- function pendingDirectory(context) {
605
- return ensureBelow(context.stateRoot, ['pending-review']);
606
- }
607
-
608
- function pendingPath(context, sessionId) {
609
- return join(pendingDirectory(context), `${sessionKey(sessionId)}.json`);
610
- }
611
-
612
- function pendingLockPath(context, sessionId) {
613
- return join(pendingDirectory(context), `${sessionKey(sessionId)}.lock`);
614
- }
615
-
616
- function readPendingAt(path, sessionId) {
617
- if (!existsSync(path)) {
618
- return {
619
- schema: PENDING_SCHEMA,
620
- sessionId,
621
- pending: false,
622
- turnEndedCount: 0,
623
- reviewedTurnCount: 0,
624
- lastProposalTurnCount: 0,
625
- turnsSinceLastProposal: 0,
626
- proposalsRecorded: 0,
627
- updatedAt: new Date(0).toISOString(),
628
- };
629
- }
630
- const state = readJson(path, 'PENDING_STATE_INVALID');
631
- requireExactKeys(
632
- state,
633
- ['schema', 'sessionId', 'pending', 'turnEndedCount', 'reviewedTurnCount', 'lastProposalTurnCount', 'turnsSinceLastProposal', 'proposalsRecorded', 'updatedAt'],
634
- ['schema', 'sessionId', 'pending', 'turnEndedCount', 'reviewedTurnCount', 'lastProposalTurnCount', 'turnsSinceLastProposal', 'proposalsRecorded', 'updatedAt'],
635
- 'PENDING_STATE_INVALID',
636
- );
637
- if (state.schema !== PENDING_SCHEMA || state.sessionId !== sessionId || typeof state.pending !== 'boolean') fail('PENDING_STATE_INVALID');
638
- for (const key of ['turnEndedCount', 'reviewedTurnCount', 'lastProposalTurnCount', 'turnsSinceLastProposal', 'proposalsRecorded']) {
639
- if (!Number.isSafeInteger(state[key]) || state[key] < 0) fail('PENDING_STATE_INVALID', key);
640
- }
641
- if (state.turnsSinceLastProposal !== state.turnEndedCount - state.lastProposalTurnCount) fail('PENDING_STATE_INVALID', 'counter mismatch');
642
- requireTimestamp(state.updatedAt);
643
- return state;
644
- }
645
-
646
- export function recordTurnEnded(options = {}) {
647
- const context = options.context ?? createLoopContext({ ...options, stateOnly: true });
648
- const sessionId = requireString(options.sessionId, 'SESSION_ID_INVALID', 512);
649
- const path = pendingPath(context, sessionId);
650
- return withStateLock(pendingLockPath(context, sessionId), () => {
651
- const state = readPendingAt(path, sessionId);
652
- state.pending = true;
653
- state.turnEndedCount += 1;
654
- state.turnsSinceLastProposal = state.turnEndedCount - state.lastProposalTurnCount;
655
- state.updatedAt = new Date().toISOString();
656
- atomicJson(path, state);
657
- return state;
658
- });
659
- }
660
-
661
- export function pendingReviewState(options = {}) {
662
- const context = options.context ?? createLoopContext({ ...options, readOnlyState: true, stateOnly: true });
663
- const sessionId = requireString(options.sessionId, 'SESSION_ID_INVALID', 512);
664
- const directory = join(context.stateRoot, 'pending-review');
665
- if (!existsSync(directory)) return readPendingAt(join(directory, `${sessionKey(sessionId)}.json`), sessionId);
666
- const status = lstatSync(directory);
667
- if (!status.isDirectory() || status.isSymbolicLink()) fail('STATE_PATH_UNSAFE', directory);
668
- return readPendingAt(join(directory, `${sessionKey(sessionId)}.json`), sessionId);
669
- }
670
-
671
- export function pendingSessions(options = {}) {
672
- assertLearningLoopSupported(options);
673
- const context = options.context ?? createLoopContext(options);
674
- const directory = pendingDirectory(context);
675
- const states = [];
676
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
677
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
678
- const value = readJson(join(directory, entry.name), 'PENDING_STATE_INVALID');
679
- states.push(readPendingAt(join(directory, entry.name), requireString(value.sessionId, 'PENDING_STATE_INVALID', 512)));
680
- }
681
- return states.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
682
- }
683
-
684
- export function finishReviewState(options = {}) {
685
- assertLearningLoopSupported(options);
686
- const context = options.context ?? createLoopContext(options);
687
- const sessionId = requireString(options.sessionId, 'SESSION_ID_INVALID', 512);
688
- const count = options.proposalCount ?? 0;
689
- if (!Number.isSafeInteger(count) || count < 0 || count > 3) fail('PROPOSAL_BATCH_INVALID');
690
- const path = pendingPath(context, sessionId);
691
- return withStateLock(pendingLockPath(context, sessionId), () => {
692
- const state = readPendingAt(path, sessionId);
693
- state.pending = false;
694
- state.reviewedTurnCount = state.turnEndedCount;
695
- if (count > 0) {
696
- state.lastProposalTurnCount = state.turnEndedCount;
697
- state.proposalsRecorded += count;
698
- }
699
- state.turnsSinceLastProposal = state.turnEndedCount - state.lastProposalTurnCount;
700
- state.updatedAt = new Date().toISOString();
701
- atomicJson(path, state);
702
- return state;
703
- });
704
- }
705
-
706
- function portableIdentity(path) {
707
- return path.split('/').map((segment) => segment.normalize('NFKC').toLowerCase().replace(/[. ]+$/g, '')).join('/');
708
- }
709
-
710
- function validateSkillName(value, code = 'TARGET_SKILL_INVALID') {
711
- requireString(value, code, 128);
712
- if (!SKILL_PATTERN.test(value) || value.normalize('NFKC') !== value || /[. ]$/.test(value) || DEVICE_PATTERN.test(value)) fail(code, value);
713
- return value;
714
- }
715
-
716
- function validateRelativeFile(value, targetSkill) {
717
- requireString(value, 'PROPOSAL_PATH_INVALID', 1024);
718
- if (value.includes('\\') || /^[A-Za-z]:/.test(value) || value.startsWith('/') || value.startsWith('//')) fail('PROPOSAL_PATH_INVALID', value);
719
- if (/[\u0000-\u001f\u007f-\u009f:*?"<>|]/.test(value) || value.includes('//')) fail('PROPOSAL_PATH_INVALID', value);
720
- const segments = value.split('/');
721
- if (segments.some((segment) => !segment || segment === '.' || segment === '..' || /[. ]$/.test(segment) || DEVICE_PATTERN.test(segment))) {
722
- fail('PROPOSAL_PATH_INVALID', value);
723
- }
724
- const identity = portableIdentity(value);
725
- const skillIdentity = portableIdentity(targetSkill);
726
- if (identity === skillIdentity || identity.startsWith(`${skillIdentity}/`)) fail('PROPOSAL_PATH_INVALID', 'file must be relative to the target skill');
727
- return value;
728
- }
729
-
730
- function assertNoSecret(value) {
731
- function visit(item) {
732
- if (typeof item === 'string' && SECRET_PATTERN.test(item.replace(INVISIBLE_SECRET_SEPARATOR_PATTERN, ''))) fail('SECRET_SHAPED_DATA');
733
- if (Array.isArray(item)) for (const child of item) visit(child);
734
- if (isObject(item)) for (const child of Object.values(item)) visit(child);
735
- }
736
- visit(value);
737
- }
738
-
739
- function shippedSkillIds() {
740
- return new Set(readdirSync(PACKAGED_SKILLS_ROOT, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name));
741
- }
742
-
743
- function markerPresent(content) {
744
- const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
745
- return Boolean(frontmatter && /^metadata:\s*\{[^}\n]*\blitgrokAgentGenerated:\s*["']true["'][^}\n]*\}\s*$/m.test(frontmatter));
746
- }
747
-
748
- function safeTargetDirectory(context, targetSkill) {
749
- validateSkillName(targetSkill);
750
- const identity = portableIdentity(targetSkill);
751
- if ([...shippedSkillIds()].some((skill) => portableIdentity(skill) === identity)) fail('TARGET_NOT_AGENT_OWNED', targetSkill);
752
- for (const entry of readdirSync(context.userRoot, { withFileTypes: true })) {
753
- if (entry.name !== targetSkill && portableIdentity(entry.name) === identity) fail('TARGET_PATH_COLLISION', targetSkill);
754
- }
755
- const target = join(context.userRoot, targetSkill);
756
- if (!existsSync(target)) return { exists: false, target };
757
- const status = lstatSync(target);
758
- if (!status.isDirectory() || status.isSymbolicLink()) fail('TARGET_PATH_UNSAFE', targetSkill);
759
- const skillFile = join(target, 'SKILL.md');
760
- if (!existsSync(skillFile)) fail('TARGET_NOT_AGENT_OWNED', targetSkill);
761
- const skillStatus = lstatSync(skillFile);
762
- if (!skillStatus.isFile() || skillStatus.isSymbolicLink()) fail('TARGET_PATH_UNSAFE', targetSkill);
763
- if (!markerPresent(readText(skillFile, MAX_FILE_BYTES))) fail('TARGET_NOT_AGENT_OWNED', targetSkill);
764
- return { exists: true, target };
765
- }
766
-
767
- function canonicalTargetRoot(context, proposed) {
768
- requireString(proposed, 'TARGET_ROOT_MISMATCH', 2048);
769
- let proposedReal;
770
- try {
771
- proposedReal = realpathSync(resolve(proposed));
772
- } catch {
773
- fail('TARGET_ROOT_MISMATCH');
774
- }
775
- const expected = realpathSync(context.userRoot);
776
- if (proposedReal !== expected) fail('TARGET_ROOT_MISMATCH');
777
- return expected;
778
- }
779
-
780
- const PROPOSAL_ALLOWED = [
781
- 'schema', 'id', 'createdAt', 'host', 'sessionRef', 'signal', 'targetSkill', 'targetRoot',
782
- 'action', 'patch', 'createSpec', 'rationale', 'evidenceRefs', 'status', 'ledgerEntryId',
783
- ];
784
- const PROPOSAL_REQUIRED = [
785
- 'schema', 'id', 'createdAt', 'host', 'sessionRef', 'signal', 'targetSkill', 'targetRoot',
786
- 'action', 'rationale', 'evidenceRefs', 'status',
787
- ];
788
-
789
- export function validateProposal(record, options = {}) {
790
- assertLearningLoopSupported(options);
791
- const context = options.context ?? createLoopContext(options);
792
- requireExactKeys(record, PROPOSAL_ALLOWED, PROPOSAL_REQUIRED, 'PROPOSAL_INVALID');
793
- if (record.schema !== PROPOSAL_SCHEMA || record.host !== 'litgrok') fail('PROPOSAL_INVALID', 'schema or host');
794
- if (!ID_PATTERN.test(record.id)) fail('PROPOSAL_INVALID', 'id');
795
- requireTimestamp(record.createdAt);
796
- requireString(record.sessionRef, 'PROPOSAL_INVALID', 512);
797
- requireString(record.signal, 'PROPOSAL_INVALID', 128);
798
- validateSkillName(record.targetSkill);
799
- const targetRoot = canonicalTargetRoot(context, record.targetRoot);
800
- requireString(record.rationale, 'PROPOSAL_INVALID', 8192);
801
- if (!Array.isArray(record.evidenceRefs) || record.evidenceRefs.length > 32 || new Set(record.evidenceRefs).size !== record.evidenceRefs.length) {
802
- fail('PROPOSAL_INVALID', 'evidenceRefs');
803
- }
804
- for (const value of record.evidenceRefs) requireString(value, 'PROPOSAL_INVALID', 2048);
805
- if (!['pending', 'approved', 'applied', 'rejected', 'rolled-back'].includes(record.status)) fail('PROPOSAL_INVALID', 'status');
806
- if (['applied', 'rolled-back'].includes(record.status)) {
807
- if (!ID_PATTERN.test(record.ledgerEntryId ?? '')) fail('PROPOSAL_INVALID', 'ledgerEntryId');
808
- } else if (Object.hasOwn(record, 'ledgerEntryId')) fail('PROPOSAL_INVALID', 'ledgerEntryId forbidden');
809
- if (options.queue === true && record.status !== 'pending') fail('PROPOSAL_NOT_PENDING');
810
-
811
- const targetState = safeTargetDirectory(context, record.targetSkill);
812
- if (!['patch', 'create', 'add-reference', 'archive'].includes(record.action)) fail('PROPOSAL_INVALID', 'action');
813
- if (record.action === 'patch') {
814
- requireExactKeys(record.patch, ['file', 'oldString', 'newString'], ['file', 'oldString', 'newString'], 'PROPOSAL_INVALID');
815
- validateRelativeFile(record.patch.file, record.targetSkill);
816
- requireString(record.patch.oldString, 'PROPOSAL_INVALID', 65536);
817
- if (typeof record.patch.newString !== 'string' || record.patch.newString.length > 65536) fail('PROPOSAL_INVALID');
818
- if (record.patch.oldString === record.patch.newString) fail('PATCH_NO_CHANGE');
819
- if (Object.hasOwn(record, 'createSpec') || !targetState.exists) fail('TARGET_NOT_AGENT_OWNED', record.targetSkill);
820
- } else if (record.action === 'create' || record.action === 'add-reference') {
821
- if (Object.hasOwn(record, 'patch')) fail('PROPOSAL_INVALID');
822
- requireExactKeys(record.createSpec, ['files'], ['files'], 'PROPOSAL_INVALID');
823
- const files = record.createSpec.files;
824
- if (!Array.isArray(files) || files.length < 1 || files.length > 64) fail('PROPOSAL_INVALID');
825
- const identities = new Set();
826
- for (const item of files) {
827
- requireExactKeys(item, ['file', 'content'], ['file', 'content'], 'PROPOSAL_INVALID');
828
- validateRelativeFile(item.file, record.targetSkill);
829
- if (typeof item.content !== 'string' || item.content.length > 65536) fail('PROPOSAL_INVALID');
830
- const identity = portableIdentity(item.file);
831
- if (identities.has(identity)) fail('PROPOSAL_PATH_COLLISION', item.file);
832
- identities.add(identity);
833
- }
834
- if (record.action === 'create') {
835
- if (targetState.exists && ['pending', 'approved'].includes(record.status)) fail('TARGET_ALREADY_EXISTS', record.targetSkill);
836
- if (!files.some((item) => portableIdentity(item.file) === 'skill.md')) fail('PROPOSAL_INVALID', 'create requires SKILL.md');
837
- } else if (!targetState.exists) fail('TARGET_NOT_AGENT_OWNED', record.targetSkill);
838
- } else {
839
- if (Object.hasOwn(record, 'patch') || Object.hasOwn(record, 'createSpec')) fail('PROPOSAL_INVALID');
840
- if (['pending', 'approved'].includes(record.status) && !targetState.exists) fail('TARGET_NOT_AGENT_OWNED', record.targetSkill);
841
- }
842
- assertNoSecret(record);
843
- return { ...record, targetRoot };
844
- }
845
-
846
- function proposalDirectory(context) {
847
- return ensureBelow(context.stateRoot, ['skill-proposals']);
848
- }
849
-
850
- function proposalPath(context, id) {
851
- if (!ID_PATTERN.test(id)) fail('PROPOSAL_ID_INVALID');
852
- return join(proposalDirectory(context), `${id}.json`);
853
- }
854
-
855
- function loadProposal(context, id) {
856
- const path = proposalPath(context, id);
857
- if (!existsSync(path)) fail('PROPOSAL_NOT_FOUND', id);
858
- return validateProposal(readJson(path, 'PROPOSAL_INVALID'), { context });
859
- }
860
-
861
- export function queueProposalBatch(records, options = {}) {
862
- assertLearningLoopSupported(options);
863
- const context = options.context ?? createLoopContext(options);
864
- if (!Array.isArray(records) || records.length < 1 || records.length > 3) fail('PROPOSAL_BATCH_INVALID');
865
- return withStateLock(globalLock(context), () => {
866
- const validated = records.map((record) => validateProposal(record, { context, queue: true }));
867
- if (new Set(validated.map((record) => record.id)).size !== validated.length) fail('PROPOSAL_ALREADY_EXISTS');
868
- for (const record of validated) if (existsSync(proposalPath(context, record.id))) fail('PROPOSAL_ALREADY_EXISTS', record.id);
869
- for (const record of validated) atomicJson(proposalPath(context, record.id), record);
870
- return validated;
871
- });
872
- }
873
-
874
- export function queueProposal(record, options = {}) {
875
- return queueProposalBatch([record], options)[0];
876
- }
877
-
878
- export function listProposals(options = {}) {
879
- assertLearningLoopSupported(options);
880
- const context = options.context ?? createLoopContext(options);
881
- const directory = proposalDirectory(context);
882
- const proposals = [];
883
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
884
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
885
- proposals.push(validateProposal(readJson(join(directory, entry.name), 'PROPOSAL_INVALID'), { context }));
886
- if (proposals.length > MAX_PROPOSALS) fail('PROPOSAL_STORE_TOO_LARGE');
887
- }
888
- return proposals.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
889
- }
890
-
891
- function canonicalFileIdentity(file) {
892
- validateRelativeFile(file, '__target__');
893
- return portableIdentity(file);
894
- }
895
-
896
- function regularFiles(root) {
897
- if (!existsSync(root)) return [];
898
- const output = [];
899
- function visit(directory, prefix = '') {
900
- const status = lstatSync(directory);
901
- if (!status.isDirectory() || status.isSymbolicLink()) fail('TARGET_PATH_UNSAFE', directory);
902
- for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) {
903
- const path = join(directory, entry.name);
904
- const file = prefix ? `${prefix}/${entry.name}` : entry.name;
905
- const childStatus = lstatSync(path);
906
- if (childStatus.isSymbolicLink()) fail('TARGET_PATH_UNSAFE', file);
907
- if (childStatus.isDirectory()) visit(path, file);
908
- else if (childStatus.isFile()) output.push({ file, path, size: childStatus.size });
909
- else fail('TARGET_PATH_UNSAFE', file);
910
- if (output.length > MAX_SKILL_FILES) fail('TARGET_TOO_LARGE');
911
- }
912
- }
913
- visit(root);
914
- const identities = new Set();
915
- let bytes = 0;
916
- for (const item of output) {
917
- if (item.size > MAX_FILE_BYTES) fail('TARGET_TOO_LARGE', item.file);
918
- bytes += item.size;
919
- if (bytes > MAX_SNAPSHOT_BYTES) fail('TARGET_TOO_LARGE');
920
- const identity = canonicalFileIdentity(item.file);
921
- if (identities.has(identity)) fail('TARGET_PATH_COLLISION', item.file);
922
- identities.add(identity);
923
- }
924
- return output;
925
- }
926
-
927
- function blobDirectory(context) {
928
- return ensureBelow(context.stateRoot, ['skill-ledger-blobs']);
929
- }
930
-
931
- function persistBlob(context, bytes) {
932
- const digest = createHash('sha256').update(bytes).digest('hex');
933
- const path = join(blobDirectory(context), digest);
934
- if (existsSync(path)) {
935
- const existing = readBytes(path, MAX_FILE_BYTES);
936
- if (createHash('sha256').update(existing).digest('hex') !== digest) fail('BLOB_CORRUPT', digest);
937
- } else {
938
- atomicWrite(path, bytes);
939
- }
940
- return { algorithm: 'sha256', digest, size: bytes.length };
941
- }
942
-
943
- export function captureSnapshot(context, root) {
944
- assertLearningLoopSupported();
945
- return regularFiles(root).map(({ file, path }) => ({ file, blob: persistBlob(context, readBytes(path, MAX_FILE_BYTES)) }));
946
- }
947
-
948
- function snapshotsEqual(left, right) {
949
- return JSON.stringify(left) === JSON.stringify(right);
950
- }
951
-
952
- function jsonEqual(left, right) {
953
- return JSON.stringify(left) === JSON.stringify(right);
954
- }
955
-
956
- function jsonClone(value) {
957
- return value === null || value === undefined ? value : JSON.parse(JSON.stringify(value));
958
- }
959
-
960
- function ledgerPath(context) {
961
- return join(context.stateRoot, 'skill-ledger.jsonl');
962
- }
963
-
964
- function validateSnapshot(context, snapshot, code = 'LEDGER_INVALID') {
965
- if (!Array.isArray(snapshot) || snapshot.length > MAX_SKILL_FILES) fail(code);
966
- const identities = new Set();
967
- let total = 0;
968
- for (const item of snapshot) {
969
- requireExactKeys(item, ['file', 'blob'], ['file', 'blob'], code);
970
- validateRelativeFile(item.file, '__target__');
971
- const identity = portableIdentity(item.file);
972
- if (identities.has(identity)) fail(code, 'duplicate snapshot path');
973
- identities.add(identity);
974
- requireExactKeys(item.blob, ['algorithm', 'digest', 'size'], ['algorithm', 'digest', 'size'], code);
975
- if (item.blob.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/.test(item.blob.digest) || !Number.isSafeInteger(item.blob.size) || item.blob.size < 0) fail(code);
976
- total += item.blob.size;
977
- if (item.blob.size > MAX_FILE_BYTES || total > MAX_SNAPSHOT_BYTES) fail(code);
978
- const blob = join(blobDirectory(context), item.blob.digest);
979
- if (!existsSync(blob)) fail('BLOB_MISSING', item.blob.digest);
980
- const bytes = readBytes(blob, MAX_FILE_BYTES);
981
- if (bytes.length !== item.blob.size || createHash('sha256').update(bytes).digest('hex') !== item.blob.digest) fail('BLOB_CORRUPT', item.blob.digest);
982
- }
983
- }
984
-
985
- function validateLedgerEntry(context, entry) {
986
- const allowed = ['schema', 'id', 'createdAt', 'proposalId', 'actor', 'operation', 'targetSkill', 'targetRoot', 'before', 'after', 'usageBefore', 'usageAfter', 'restoresLedgerEntryId', 'reason'];
987
- const required = ['schema', 'id', 'createdAt', 'proposalId', 'actor', 'operation', 'targetSkill', 'targetRoot', 'before', 'after'];
988
- requireExactKeys(entry, allowed, required, 'LEDGER_INVALID');
989
- if (entry.schema !== LEDGER_SCHEMA || !ID_PATTERN.test(entry.id ?? '')) fail('LEDGER_INVALID');
990
- requireTimestamp(entry.createdAt, 'LEDGER_INVALID');
991
- validateSkillName(entry.targetSkill, 'LEDGER_INVALID');
992
- canonicalTargetRoot(context, entry.targetRoot);
993
- if (!['user', 'curator'].includes(entry.actor) || !['apply', 'reject', 'rollback', 'curator-transition'].includes(entry.operation)) fail('LEDGER_INVALID');
994
- if (entry.operation === 'curator-transition') {
995
- if (entry.actor !== 'curator' || entry.proposalId !== null) fail('LEDGER_INVALID');
996
- } else {
997
- if (entry.actor !== 'user') fail('LEDGER_INVALID');
998
- if (entry.proposalId !== null && !ID_PATTERN.test(entry.proposalId ?? '')) fail('LEDGER_INVALID');
999
- if (['apply', 'reject'].includes(entry.operation) && !ID_PATTERN.test(entry.proposalId ?? '')) fail('LEDGER_INVALID');
1000
- }
1001
- if (entry.operation === 'rollback') {
1002
- if (!ID_PATTERN.test(entry.restoresLedgerEntryId ?? '')) fail('LEDGER_INVALID');
1003
- } else if (Object.hasOwn(entry, 'restoresLedgerEntryId')) fail('LEDGER_INVALID');
1004
- if (Object.hasOwn(entry, 'reason') && (typeof entry.reason !== 'string' || entry.reason.length > 8192)) fail('LEDGER_INVALID');
1005
- validateSnapshot(context, entry.before);
1006
- validateSnapshot(context, entry.after);
1007
- if (Object.hasOwn(entry, 'usageBefore') || Object.hasOwn(entry, 'usageAfter')) {
1008
- if (!Object.hasOwn(entry, 'usageBefore') || !Object.hasOwn(entry, 'usageAfter')) fail('LEDGER_INVALID', 'incomplete usage transition');
1009
- validateUsageEntry(entry.usageBefore, 'LEDGER_INVALID');
1010
- validateUsageEntry(entry.usageAfter, 'LEDGER_INVALID');
1011
- }
1012
- return entry;
1013
- }
1014
-
1015
- export function readLedger(context) {
1016
- assertLearningLoopSupported();
1017
- const path = ledgerPath(context);
1018
- if (!existsSync(path)) return [];
1019
- const status = lstatSync(path);
1020
- if (!status.isFile() || status.isSymbolicLink() || status.size > 8 * 1024 * 1024) fail('LEDGER_UNSAFE');
1021
- const entries = [];
1022
- for (const line of readText(path, 8 * 1024 * 1024).split(/\r?\n/).filter(Boolean)) {
1023
- let entry;
1024
- try { entry = JSON.parse(line); } catch { fail('LEDGER_INVALID'); }
1025
- entries.push(validateLedgerEntry(context, entry));
1026
- }
1027
- if (new Set(entries.map((entry) => entry.id)).size !== entries.length) fail('LEDGER_INVALID', 'duplicate id');
1028
- const prior = new Map();
1029
- for (const entry of entries) {
1030
- if (entry.operation === 'rollback') {
1031
- const source = prior.get(entry.restoresLedgerEntryId);
1032
- if (!source || !['apply', 'curator-transition', 'rollback'].includes(source.operation)) fail('LEDGER_INVALID', 'rollback reference');
1033
- if (source.targetSkill !== entry.targetSkill || source.targetRoot !== entry.targetRoot) fail('LEDGER_INVALID', 'rollback target');
1034
- }
1035
- prior.set(entry.id, entry);
1036
- }
1037
- return entries;
1038
- }
1039
-
1040
- export function appendLedger(context, entry) {
1041
- assertLearningLoopSupported();
1042
- const existing = readLedger(context);
1043
- if (existing.some((item) => item.id === entry.id)) fail('LEDGER_INVALID', 'duplicate id');
1044
- const path = ledgerPath(context);
1045
- const prefix = existsSync(path) ? readText(path, 8 * 1024 * 1024) : '';
1046
- const normalized = prefix.length === 0 || prefix.endsWith('\n') ? prefix : `${prefix}\n`;
1047
- const bytes = `${normalized}${JSON.stringify(entry)}\n`;
1048
- if (Buffer.byteLength(bytes) > 8 * 1024 * 1024) fail('LEDGER_UNSAFE', 'size');
1049
- atomicWrite(path, bytes);
1050
- return entry;
1051
- }
1052
-
1053
- function appendLedgerIdempotent(context, entry) {
1054
- const existing = readLedger(context).find((item) => item.id === entry.id);
1055
- if (existing) {
1056
- if (!jsonEqual(existing, entry)) fail('LEDGER_INVALID', 'id collision');
1057
- return existing;
1058
- }
1059
- return appendLedger(context, entry);
1060
- }
1061
-
1062
- function newLedgerEntry({ actor, after, before, createdAt, operation, proposalId, reason, restoresLedgerEntryId, targetRoot, targetSkill, usageAfter, usageBefore }) {
1063
- const entry = {
1064
- schema: LEDGER_SCHEMA,
1065
- id: `ledger-${randomUUID()}`,
1066
- createdAt: createdAt ?? new Date().toISOString(),
1067
- proposalId,
1068
- actor,
1069
- operation,
1070
- targetSkill,
1071
- targetRoot,
1072
- before,
1073
- after,
1074
- };
1075
- if (usageBefore !== undefined || usageAfter !== undefined) {
1076
- entry.usageBefore = jsonClone(usageBefore ?? null);
1077
- entry.usageAfter = jsonClone(usageAfter ?? null);
1078
- }
1079
- if (restoresLedgerEntryId) entry.restoresLedgerEntryId = restoresLedgerEntryId;
1080
- if (reason) entry.reason = reason;
1081
- return entry;
1082
- }
1083
-
1084
- function copyTree(source, destination) {
1085
- safeMkdir(destination);
1086
- for (const item of regularFiles(source)) {
1087
- const target = join(destination, ...item.file.split('/'));
1088
- safeMkdir(dirname(target));
1089
- copyFileSync(item.path, target, constants.COPYFILE_EXCL);
1090
- if (item.file.startsWith('scripts/')) chmodSync(target, 0o755);
1091
- }
1092
- }
1093
-
1094
- function materializeSnapshot(context, snapshot, destination) {
1095
- safeMkdir(destination);
1096
- for (const item of snapshot) {
1097
- validateRelativeFile(item.file, '__target__');
1098
- const blob = join(blobDirectory(context), item.blob.digest);
1099
- const bytes = readBytes(blob, MAX_FILE_BYTES);
1100
- if (bytes.length !== item.blob.size || createHash('sha256').update(bytes).digest('hex') !== item.blob.digest) fail('BLOB_CORRUPT', item.blob.digest);
1101
- const target = join(destination, ...item.file.split('/'));
1102
- safeMkdir(dirname(target));
1103
- writeFileSync(target, bytes, { flag: 'wx', mode: item.file.startsWith('scripts/') ? 0o755 : 0o600 });
1104
- }
1105
- }
1106
-
1107
- function stampMarker(content, targetSkill) {
1108
- if (markerPresent(content)) return content;
1109
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
1110
- if (!match) fail('SKILL_FRONTMATTER_INVALID');
1111
- const declared = match[1].match(/^name:\s*(["']?)([^\n"']+)\1\s*$/m)?.[2]?.trim();
1112
- if (declared !== targetSkill) fail('SKILL_FRONTMATTER_INVALID', 'name mismatch');
1113
- if (/^metadata:/m.test(match[1])) fail('SKILL_FRONTMATTER_INVALID', 'metadata must be an inline string map');
1114
- const replacement = `---\n${match[1]}\n${MARKER}\n---\n`;
1115
- return `${replacement}${content.slice(match[0].length)}`;
1116
- }
1117
-
1118
- function applyProposalToStage(record, stage) {
1119
- if (record.action === 'patch') {
1120
- const path = join(stage, ...record.patch.file.split('/'));
1121
- if (!existsSync(path)) fail('PATCH_PREIMAGE_MISMATCH');
1122
- const status = lstatSync(path);
1123
- if (!status.isFile() || status.isSymbolicLink()) fail('TARGET_PATH_UNSAFE');
1124
- const mode = status.mode & 0o777;
1125
- const content = readText(path, MAX_FILE_BYTES);
1126
- const first = content.indexOf(record.patch.oldString);
1127
- if (first < 0 || content.indexOf(record.patch.oldString, first + record.patch.oldString.length) >= 0) fail('PATCH_PREIMAGE_MISMATCH');
1128
- atomicWrite(path, content.slice(0, first) + record.patch.newString + content.slice(first + record.patch.oldString.length));
1129
- chmodSync(path, mode);
1130
- } else if (record.action === 'create' || record.action === 'add-reference') {
1131
- for (const item of record.createSpec.files) {
1132
- const path = join(stage, ...item.file.split('/'));
1133
- if (existsSync(path)) fail('TARGET_ALREADY_EXISTS', item.file);
1134
- safeMkdir(dirname(path));
1135
- const content = portableIdentity(item.file) === 'skill.md' ? stampMarker(item.content, record.targetSkill) : item.content;
1136
- atomicWrite(path, content, item.file.startsWith('scripts/') ? 0o755 : 0o600);
1137
- if (item.file.startsWith('scripts/')) chmodSync(path, 0o755);
1138
- }
1139
- } else {
1140
- fail('ARCHIVE_REQUIRES_CURATOR');
1141
- }
1142
- const skillFile = join(stage, 'SKILL.md');
1143
- if (!existsSync(skillFile) || !markerPresent(readText(skillFile, MAX_FILE_BYTES))) fail('TARGET_NOT_AGENT_OWNED');
1144
- }
1145
-
1146
- function temporaryDirectory(context, label) {
1147
- const root = ensureBelow(context.grokRoot, ['litgrok', 'staging']);
1148
- const path = join(root, `${label}-${randomUUID()}`);
1149
- safeMkdir(path);
1150
- return path;
1151
- }
1152
-
1153
- function transactionDirectory(context) {
1154
- return ensureBelow(context.stateRoot, ['skill-transactions']);
1155
- }
1156
-
1157
- function transactionPath(context, id) {
1158
- if (!ID_PATTERN.test(id)) fail('TRANSACTION_INVALID', 'id');
1159
- return join(transactionDirectory(context), `${id}.json`);
1160
- }
1161
-
1162
- function transactionScratch(context, id) {
1163
- if (!ID_PATTERN.test(id)) fail('TRANSACTION_INVALID', 'id');
1164
- return join(context.grokRoot, 'litgrok', 'transactions', id);
1165
- }
1166
-
1167
- function transactionAuthorityPath(context, id) {
1168
- return join(transactionScratch(context, id), 'authority.json');
1169
- }
1170
-
1171
- function transactionCompletionPath(context, id) {
1172
- if (!ID_PATTERN.test(id)) fail('TRANSACTION_INVALID', 'id');
1173
- return join(transactionDirectory(context), `${id}.complete`);
1174
- }
1175
-
1176
- function transactionDigest(transaction) {
1177
- return createHash('sha256').update(JSON.stringify(transaction)).digest('hex');
1178
- }
1179
-
1180
- function approvedTransactionAction(transaction) {
1181
- if (['apply', 'archive', 'reject'].includes(transaction.kind)) {
1182
- return `${transaction.kind}:${transaction.proposalBefore?.id ?? 'missing'}:${transaction.proposalBefore?.action ?? 'missing'}`;
1183
- }
1184
- if (transaction.kind === 'rollback') return `rollback:${transaction.ledger.restoresLedgerEntryId ?? 'missing'}`;
1185
- return transaction.kind;
1186
- }
1187
-
1188
- function transactionReceipt(context, transaction, schema) {
1189
- return {
1190
- schema,
1191
- transactionId: transaction.id,
1192
- transactionDigest: transactionDigest(transaction),
1193
- workspaceRoot: realpathSync(context.workspaceRoot),
1194
- stateRoot: realpathSync(context.stateRoot),
1195
- targetSkill: transaction.targetSkill,
1196
- targetIdentity: portableIdentity(transaction.targetSkill),
1197
- targetRoot: transaction.targetRoot,
1198
- proposalId: transaction.proposalBefore?.id ?? null,
1199
- proposalAction: transaction.proposalBefore?.action ?? transaction.kind,
1200
- approvedAction: approvedTransactionAction(transaction),
1201
- createdAt: transaction.createdAt,
1202
- };
1203
- }
1204
-
1205
- function transactionAuthority(context, transaction) {
1206
- return transactionReceipt(context, transaction, TRANSACTION_AUTHORITY_SCHEMA);
1207
- }
1208
-
1209
- function transactionCompletion(context, transaction) {
1210
- return transactionReceipt(context, transaction, TRANSACTION_COMPLETION_SCHEMA);
1211
- }
1212
-
1213
- const TRANSACTION_RECEIPT_FIELDS = [
1214
- 'schema', 'transactionId', 'transactionDigest', 'workspaceRoot', 'stateRoot', 'targetSkill',
1215
- 'targetIdentity', 'targetRoot', 'proposalId', 'proposalAction', 'approvedAction', 'createdAt',
1216
- ];
1217
-
1218
- function authorityDirectoryChain(context, transaction) {
1219
- const expectedGrokRoot = join(context.home, '.grok');
1220
- if (resolve(context.grokRoot) !== expectedGrokRoot) fail('TRANSACTION_AUTHORITY_PATH_UNTRUSTED', 'grok root');
1221
- return [
1222
- { path: context.home, private: false },
1223
- { path: context.grokRoot, private: false },
1224
- { path: join(context.grokRoot, 'litgrok'), private: true },
1225
- { path: join(context.grokRoot, 'litgrok', 'transactions'), private: true },
1226
- { path: transactionScratch(context, transaction.id), private: true },
1227
- ];
1228
- }
1229
-
1230
- function validateAuthorityDirectoryChain(context, transaction) {
1231
- assertLearningLoopSupported();
1232
- const ownerToken = posixOwnerToken();
1233
- const ownerUid = Number(ownerToken.slice(4));
1234
- for (const item of authorityDirectoryChain(context, transaction)) {
1235
- let status;
1236
- try {
1237
- status = lstatSync(item.path);
1238
- } catch {
1239
- fail('TRANSACTION_AUTHORITY_PATH_UNTRUSTED', item.path);
1240
- }
1241
- const mode = status.mode & 0o7777;
1242
- let canonical;
1243
- try {
1244
- canonical = realpathSync(item.path);
1245
- } catch {
1246
- fail('TRANSACTION_AUTHORITY_PATH_UNTRUSTED', item.path);
1247
- }
1248
- if (!status.isDirectory() || status.isSymbolicLink() || canonical !== resolve(item.path) || status.uid !== ownerUid) {
1249
- fail('TRANSACTION_AUTHORITY_PATH_UNTRUSTED', item.path);
1250
- }
1251
- if ((item.private && mode !== 0o700) || (!item.private && (mode & 0o022) !== 0)) {
1252
- fail('TRANSACTION_AUTHORITY_PATH_UNTRUSTED', item.path);
1253
- }
1254
- }
1255
- return ownerToken;
1256
- }
1257
-
1258
- function readTrustedAuthority(context, transaction) {
1259
- const ownerToken = validateAuthorityDirectoryChain(context, transaction);
1260
- const path = transactionAuthorityPath(context, transaction.id);
1261
- const ownerUid = Number(ownerToken.slice(4));
1262
- let expected;
1263
- try {
1264
- expected = lstatSync(path);
1265
- } catch {
1266
- fail('TRANSACTION_AUTHORITY_FILE_UNTRUSTED', path);
1267
- }
1268
- if (!expected.isFile() || expected.isSymbolicLink() || expected.uid !== ownerUid
1269
- || (expected.mode & 0o7777) !== 0o600 || expected.nlink !== 1 || expected.size > MAX_JSON_BYTES) {
1270
- fail('TRANSACTION_AUTHORITY_FILE_UNTRUSTED', path);
1271
- }
1272
- let descriptor;
1273
- try {
1274
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
1275
- const opened = fstatSync(descriptor);
1276
- if (!opened.isFile() || opened.uid !== ownerUid || (opened.mode & 0o7777) !== 0o600 || opened.nlink !== 1
1277
- || opened.size > MAX_JSON_BYTES || opened.dev !== expected.dev || opened.ino !== expected.ino) {
1278
- fail('TRANSACTION_AUTHORITY_FILE_UNTRUSTED', path);
1279
- }
1280
- const bytes = readFileSync(descriptor);
1281
- const current = lstatSync(path);
1282
- if (!current.isFile() || current.isSymbolicLink() || current.dev !== opened.dev || current.ino !== opened.ino
1283
- || current.uid !== ownerUid || (current.mode & 0o7777) !== 0o600 || current.nlink !== 1
1284
- || bytes.length !== opened.size) fail('TRANSACTION_AUTHORITY_FILE_UNTRUSTED', path);
1285
- try {
1286
- return JSON.parse(decoder.decode(bytes));
1287
- } catch {
1288
- fail('TRANSACTION_AUTHORITY_INVALID', path);
1289
- }
1290
- } catch (error) {
1291
- if (error instanceof SkillLoopError) throw error;
1292
- fail('TRANSACTION_AUTHORITY_FILE_UNTRUSTED', path);
1293
- } finally {
1294
- if (descriptor !== undefined) closeSync(descriptor);
1295
- }
1296
- }
1297
-
1298
- function validateTransactionAuthority(context, transaction) {
1299
- const scratch = transactionScratch(context, transaction.id);
1300
- const path = transactionAuthorityPath(context, transaction.id);
1301
- if (!existsSync(scratch) || !existsSync(path)) fail('TRANSACTION_AUTHORITY_MISSING', transaction.id);
1302
- const authority = readTrustedAuthority(context, transaction);
1303
- requireExactKeys(
1304
- authority,
1305
- TRANSACTION_RECEIPT_FIELDS,
1306
- TRANSACTION_RECEIPT_FIELDS,
1307
- 'TRANSACTION_AUTHORITY_INVALID',
1308
- );
1309
- requireTimestamp(authority.createdAt, 'TRANSACTION_AUTHORITY_INVALID');
1310
- const expected = transactionAuthority(context, transaction);
1311
- if (!jsonEqual(authority, expected)) fail('TRANSACTION_AUTHORITY_MISMATCH', transaction.id);
1312
- return authority;
1313
- }
1314
-
1315
- function validateTransactionCompletion(context, transaction) {
1316
- const path = transactionCompletionPath(context, transaction.id);
1317
- if (!existsSync(path)) return null;
1318
- const completion = readJson(path, 'TRANSACTION_COMPLETION_INVALID');
1319
- requireExactKeys(completion, TRANSACTION_RECEIPT_FIELDS, TRANSACTION_RECEIPT_FIELDS, 'TRANSACTION_COMPLETION_INVALID');
1320
- requireTimestamp(completion.createdAt, 'TRANSACTION_COMPLETION_INVALID');
1321
- if (!jsonEqual(completion, transactionCompletion(context, transaction))) fail('TRANSACTION_COMPLETION_MISMATCH', transaction.id);
1322
- return completion;
1323
- }
1324
-
1325
- function removeOwnedTree(path) {
1326
- if (!existsSync(path)) return;
1327
- const status = lstatSync(path);
1328
- if (!status.isDirectory() || status.isSymbolicLink()) fail('TRANSACTION_PATH_UNSAFE', path);
1329
- rmSync(path, { recursive: true, force: true });
1330
- syncDirectory(dirname(path));
1331
- }
1332
-
1333
- function validateTransaction(context, transaction) {
1334
- const allowed = [
1335
- 'schema', 'id', 'createdAt', 'kind', 'targetSkill', 'targetRoot', 'before', 'after',
1336
- 'ledger', 'proposalBefore', 'proposalAfter', 'usageBefore', 'usageAfter', 'archivePath',
1337
- ];
1338
- requireExactKeys(transaction, allowed, allowed, 'TRANSACTION_INVALID');
1339
- if (transaction.schema !== TRANSACTION_SCHEMA || !ID_PATTERN.test(transaction.id ?? '')) fail('TRANSACTION_INVALID');
1340
- requireTimestamp(transaction.createdAt, 'TRANSACTION_INVALID');
1341
- if (!['apply', 'archive', 'reject', 'rollback', 'curator-stale', 'curator-archive'].includes(transaction.kind)) fail('TRANSACTION_INVALID', 'kind');
1342
- validateSkillName(transaction.targetSkill, 'TRANSACTION_INVALID');
1343
- canonicalTargetRoot(context, transaction.targetRoot);
1344
- validateSnapshot(context, transaction.before, 'TRANSACTION_INVALID');
1345
- validateSnapshot(context, transaction.after, 'TRANSACTION_INVALID');
1346
- validateLedgerEntry(context, transaction.ledger);
1347
- validateUsageEntry(transaction.usageBefore, 'TRANSACTION_INVALID');
1348
- validateUsageEntry(transaction.usageAfter, 'TRANSACTION_INVALID');
1349
- if (!jsonEqual(transaction.ledger.usageBefore, transaction.usageBefore) || !jsonEqual(transaction.ledger.usageAfter, transaction.usageAfter)) {
1350
- fail('TRANSACTION_INVALID', 'usage mismatch');
1351
- }
1352
- if (!jsonEqual(transaction.ledger.before, transaction.before) || !jsonEqual(transaction.ledger.after, transaction.after)) {
1353
- fail('TRANSACTION_INVALID', 'snapshot mismatch');
1354
- }
1355
- if (transaction.ledger.targetSkill !== transaction.targetSkill || transaction.ledger.targetRoot !== transaction.targetRoot) {
1356
- fail('TRANSACTION_INVALID', 'ledger target');
1357
- }
1358
- const hasProposal = transaction.proposalBefore !== null || transaction.proposalAfter !== null;
1359
- if (hasProposal) {
1360
- const before = requireObject(transaction.proposalBefore, 'TRANSACTION_INVALID');
1361
- const after = requireObject(transaction.proposalAfter, 'TRANSACTION_INVALID');
1362
- if (before.id !== transaction.ledger.proposalId || after.id !== before.id || before.targetSkill !== transaction.targetSkill || after.targetSkill !== transaction.targetSkill) {
1363
- fail('TRANSACTION_INVALID', 'proposal target');
1364
- }
1365
- if (!['pending', 'approved', 'applied', 'rolled-back'].includes(before.status) || !['applied', 'rejected', 'rolled-back'].includes(after.status)) fail('TRANSACTION_INVALID', 'proposal status');
1366
- if (after.status === 'rejected') {
1367
- if (Object.hasOwn(after, 'ledgerEntryId')) fail('TRANSACTION_INVALID', 'rejected proposal ledger field');
1368
- } else if (after.ledgerEntryId !== transaction.ledger.id) fail('TRANSACTION_INVALID', 'proposal ledger');
1369
- } else if (transaction.proposalBefore !== null || transaction.proposalAfter !== null || transaction.ledger.proposalId !== null) {
1370
- fail('TRANSACTION_INVALID', 'proposal fields');
1371
- }
1372
- const archiveKind = ['archive', 'curator-archive'].includes(transaction.kind);
1373
- if (archiveKind) {
1374
- requireString(transaction.archivePath, 'TRANSACTION_INVALID', 4096);
1375
- const archiveRoot = resolve(context.grokRoot, 'litgrok', 'archive');
1376
- const resolvedArchive = resolve(transaction.archivePath);
1377
- const fromRoot = relative(archiveRoot, resolvedArchive);
1378
- if (!fromRoot || fromRoot.startsWith(`..${sep}`) || fromRoot === '..' || isAbsolute(fromRoot)) fail('TRANSACTION_INVALID', 'archive path');
1379
- } else if (transaction.archivePath !== null) fail('TRANSACTION_INVALID', 'archive path forbidden');
1380
- return transaction;
1381
- }
1382
-
1383
- function validateSnapshotOwnership(context, snapshot, targetSkill) {
1384
- if (snapshot.length === 0) return;
1385
- const skillFile = snapshot.find((item) => portableIdentity(item.file) === 'skill.md');
1386
- if (!skillFile) fail('TRANSACTION_OWNERSHIP_INVALID', 'SKILL.md missing');
1387
- const content = readText(join(blobDirectory(context), skillFile.blob.digest), MAX_FILE_BYTES);
1388
- if (!markerPresent(content)) fail('TRANSACTION_OWNERSHIP_INVALID', targetSkill);
1389
- const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
1390
- const declared = frontmatter?.match(/^name:\s*(["']?)([^\n"']+)\1\s*$/m)?.[2]?.trim();
1391
- if (declared !== targetSkill) fail('TRANSACTION_TARGET_IDENTITY_MISMATCH', targetSkill);
1392
- }
1393
-
1394
- function validateProposalTransition(context, transaction, beforeStatuses, afterStatus) {
1395
- const before = requireObject(transaction.proposalBefore, 'TRANSACTION_AUTHORITY_INVALID');
1396
- const after = requireObject(transaction.proposalAfter, 'TRANSACTION_AUTHORITY_INVALID');
1397
- if (!beforeStatuses.includes(before.status) || after.status !== afterStatus) fail('TRANSACTION_AUTHORITY_INVALID', 'proposal status');
1398
- const path = proposalPath(context, before.id);
1399
- if (!existsSync(path)) fail('TRANSACTION_PROPOSAL_MISSING', before.id);
1400
- const current = readJson(path, 'PROPOSAL_INVALID');
1401
- if (!jsonEqual(current, before) && !jsonEqual(current, after)) fail('TRANSACTION_PROPOSAL_CAS_MISMATCH', before.id);
1402
- return { after, before };
1403
- }
1404
-
1405
- function validateUsagePinInvariant(transaction) {
1406
- const before = transaction.usageBefore;
1407
- const after = transaction.usageAfter;
1408
- if (before !== null && after !== null && before.pinned !== after.pinned) fail('TRANSACTION_PIN_INVARIANT');
1409
- if (before === null && after !== null && after.pinned !== false) fail('TRANSACTION_PIN_INVARIANT');
1410
- }
1411
-
1412
- function validateApplyTransaction(context, transaction) {
1413
- const { before: proposal } = validateProposalTransition(context, transaction, ['approved'], 'applied');
1414
- if (transaction.ledger.operation !== 'apply' || transaction.ledger.actor !== 'user') fail('TRANSACTION_AUTHORITY_INVALID', 'apply ledger');
1415
- if (transaction.kind === 'archive') {
1416
- if (proposal.action !== 'archive' || transaction.before.length === 0 || transaction.after.length !== 0) fail('TRANSACTION_AUTHORITY_INVALID', 'archive action');
1417
- const expectedUsage = jsonClone(transaction.usageBefore);
1418
- if (expectedUsage !== null) {
1419
- expectedUsage.state = 'archived';
1420
- expectedUsage.archived_at = transaction.ledger.createdAt;
1421
- }
1422
- if (!jsonEqual(expectedUsage, transaction.usageAfter)) fail('TRANSACTION_AUTHORITY_INVALID', 'archive usage');
1423
- return;
1424
- }
1425
- if (!['patch', 'create', 'add-reference'].includes(proposal.action)) fail('TRANSACTION_AUTHORITY_INVALID', 'apply action');
1426
- if (proposal.action === 'create' && transaction.before.length !== 0) fail('TRANSACTION_AUTHORITY_INVALID', 'create preimage');
1427
- if (proposal.action !== 'create' && transaction.before.length === 0) fail('TRANSACTION_AUTHORITY_INVALID', 'owned preimage');
1428
- if (transaction.after.length === 0) fail('TRANSACTION_AUTHORITY_INVALID', 'apply result');
1429
- const expectedUsage = usageAfterApply(transaction.usageBefore, transaction.ledger.createdAt);
1430
- if (!jsonEqual(expectedUsage, transaction.usageAfter)) fail('TRANSACTION_AUTHORITY_INVALID', 'apply usage');
1431
- }
1432
-
1433
- function expectedAppliedSnapshot(context, proposal, before) {
1434
- const stage = temporaryDirectory(context, 'verify-applied');
1435
- try {
1436
- if (before.length > 0) materializeSnapshot(context, before, stage);
1437
- applyProposalToStage(proposal, stage);
1438
- return captureSnapshot(context, stage);
1439
- } finally {
1440
- if (existsSync(stage)) rmSync(stage, { recursive: true, force: true });
1441
- }
1442
- }
1443
-
1444
- function resolveAppliedLedger(context, proposal) {
1445
- if (proposal.status !== 'applied' || !ID_PATTERN.test(proposal.ledgerEntryId ?? '')) fail('PROPOSAL_INVALID', 'applied decision shape');
1446
- const ledger = readLedger(context).find((entry) => entry.id === proposal.ledgerEntryId);
1447
- if (!ledger) fail('APPLIED_LEDGER_UNRESOLVED', proposal.id);
1448
- if (ledger.operation !== 'apply' || ledger.actor !== 'user' || ledger.proposalId !== proposal.id
1449
- || ledger.targetSkill !== proposal.targetSkill || ledger.targetRoot !== proposal.targetRoot
1450
- || !Object.hasOwn(ledger, 'usageBefore') || !Object.hasOwn(ledger, 'usageAfter')
1451
- || Date.parse(ledger.createdAt) < Date.parse(proposal.createdAt)) {
1452
- fail('APPLIED_LEDGER_MISMATCH', proposal.id);
1453
- }
1454
- validateUsagePinInvariant({ usageBefore: ledger.usageBefore, usageAfter: ledger.usageAfter });
1455
- const target = join(context.userRoot, proposal.targetSkill);
1456
- if (proposal.action === 'archive') {
1457
- if (ledger.before.length === 0 || ledger.after.length !== 0) fail('APPLIED_LEDGER_MISMATCH', 'archive shape');
1458
- if (pathEntryExists(target)) fail('APPLIED_STATE_MISMATCH', 'archived target');
1459
- validateSnapshotOwnership(context, ledger.before, proposal.targetSkill);
1460
- const expectedUsage = jsonClone(ledger.usageBefore);
1461
- if (expectedUsage !== null) {
1462
- expectedUsage.state = 'archived';
1463
- expectedUsage.archived_at = ledger.createdAt;
1464
- }
1465
- if (!jsonEqual(expectedUsage, ledger.usageAfter)) fail('APPLIED_LEDGER_MISMATCH', 'archive usage');
1466
- const archive = join(context.grokRoot, 'litgrok', 'archive', `${proposal.targetSkill}-${ledger.id}`);
1467
- if (!existsSync(archive) || !snapshotsEqual(captureSnapshot(context, archive), ledger.before)) {
1468
- fail('APPLIED_STATE_MISMATCH', 'archive');
1469
- }
1470
- } else {
1471
- if ((proposal.action === 'create' && ledger.before.length !== 0)
1472
- || (proposal.action !== 'create' && ledger.before.length === 0) || ledger.after.length === 0) {
1473
- fail('APPLIED_LEDGER_MISMATCH', 'action shape');
1474
- }
1475
- validateSnapshotOwnership(context, ledger.before, proposal.targetSkill);
1476
- validateSnapshotOwnership(context, ledger.after, proposal.targetSkill);
1477
- let expectedAfter;
1478
- try {
1479
- expectedAfter = expectedAppliedSnapshot(context, proposal, ledger.before);
1480
- } catch (error) {
1481
- if (error instanceof SkillLoopError && ['BLOB_MISSING', 'BLOB_CORRUPT'].includes(error.code)) throw error;
1482
- fail('APPLIED_LEDGER_MISMATCH', 'action replay');
1483
- }
1484
- if (!snapshotsEqual(expectedAfter, ledger.after)) fail('APPLIED_LEDGER_MISMATCH', 'action result');
1485
- const expectedUsage = usageAfterApply(ledger.usageBefore, ledger.createdAt);
1486
- if (!jsonEqual(expectedUsage, ledger.usageAfter)) fail('APPLIED_LEDGER_MISMATCH', 'apply usage');
1487
- if (!snapshotsEqual(captureSnapshot(context, target), ledger.after)) fail('APPLIED_STATE_MISMATCH', 'target');
1488
- }
1489
- const terminalUsage = readUsage(context).skills[proposal.targetSkill] ?? null;
1490
- if (!jsonEqual(terminalUsage, ledger.usageAfter)) fail('APPLIED_STATE_MISMATCH', 'usage');
1491
- return ledger;
1492
- }
1493
-
1494
- function resolveRejectDecisionLedger(context, proposal) {
1495
- if (proposal.status !== 'rejected' || Object.hasOwn(proposal, 'ledgerEntryId')) fail('PROPOSAL_INVALID', 'rejected decision shape');
1496
- const matches = readLedger(context).filter((entry) => entry.operation === 'reject'
1497
- && entry.actor === 'user'
1498
- && entry.proposalId === proposal.id
1499
- && entry.targetSkill === proposal.targetSkill
1500
- && entry.targetRoot === proposal.targetRoot
1501
- && entry.before.length === 0
1502
- && entry.after.length === 0
1503
- && (!Object.hasOwn(entry, 'usageBefore') || (entry.usageBefore === null && entry.usageAfter === null))
1504
- && Date.parse(entry.createdAt) >= Date.parse(proposal.createdAt));
1505
- if (matches.length !== 1) fail(matches.length === 0 ? 'REJECT_LEDGER_UNRESOLVED' : 'REJECT_LEDGER_AMBIGUOUS', proposal.id);
1506
- return matches[0];
1507
- }
1508
-
1509
- function validateRejectTransaction(context, transaction) {
1510
- validateProposalTransition(context, transaction, ['pending'], 'rejected');
1511
- if (transaction.ledger.operation !== 'reject' || transaction.ledger.actor !== 'user') fail('TRANSACTION_AUTHORITY_INVALID', 'reject ledger');
1512
- if (transaction.before.length !== 0 || transaction.after.length !== 0 || transaction.usageBefore !== null || transaction.usageAfter !== null) {
1513
- fail('TRANSACTION_AUTHORITY_INVALID', 'reject mutation');
1514
- }
1515
- }
1516
-
1517
- function validateRollbackTransaction(context, transaction) {
1518
- if (transaction.ledger.operation !== 'rollback' || transaction.ledger.actor !== 'user') fail('TRANSACTION_AUTHORITY_INVALID', 'rollback ledger');
1519
- const entries = readLedger(context);
1520
- const source = entries.find((entry) => entry.id === transaction.ledger.restoresLedgerEntryId);
1521
- if (!source || !['apply', 'curator-transition', 'rollback'].includes(source.operation)) fail('TRANSACTION_AUTHORITY_INVALID', 'rollback source');
1522
- if (source.targetSkill !== transaction.targetSkill || source.targetRoot !== transaction.targetRoot
1523
- || !jsonEqual(transaction.before, source.after) || !jsonEqual(transaction.after, source.before)
1524
- || !jsonEqual(transaction.usageBefore, source.usageAfter) || !jsonEqual(transaction.usageAfter, source.usageBefore)) {
1525
- fail('TRANSACTION_AUTHORITY_INVALID', 'rollback inverse');
1526
- }
1527
- if (source.proposalId === null) {
1528
- if (transaction.proposalBefore !== null || transaction.proposalAfter !== null) fail('TRANSACTION_AUTHORITY_INVALID', 'rollback proposal');
1529
- } else {
1530
- validateProposalTransition(context, transaction, ['applied', 'rolled-back'], 'rolled-back');
1531
- }
1532
- }
1533
-
1534
- function validateCuratorTransaction(transaction) {
1535
- if (transaction.ledger.operation !== 'curator-transition' || transaction.ledger.actor !== 'curator'
1536
- || transaction.proposalBefore !== null || transaction.proposalAfter !== null) fail('TRANSACTION_AUTHORITY_INVALID', 'curator ledger');
1537
- if (transaction.usageBefore === null || transaction.usageAfter === null || transaction.usageBefore.pinned || transaction.usageAfter.pinned) {
1538
- fail('TRANSACTION_PIN_INVARIANT');
1539
- }
1540
- const expectedUsage = jsonClone(transaction.usageBefore);
1541
- if (transaction.kind === 'curator-stale') {
1542
- if (transaction.usageBefore.state !== 'active' || !jsonEqual(transaction.before, transaction.after)) fail('TRANSACTION_AUTHORITY_INVALID', 'curator stale source');
1543
- expectedUsage.state = 'stale';
1544
- expectedUsage.archived_at = null;
1545
- } else {
1546
- if (transaction.usageBefore.state !== 'stale' || transaction.before.length === 0 || transaction.after.length !== 0) fail('TRANSACTION_AUTHORITY_INVALID', 'curator archive source');
1547
- expectedUsage.state = 'archived';
1548
- expectedUsage.archived_at = transaction.ledger.createdAt;
1549
- }
1550
- if (!jsonEqual(expectedUsage, transaction.usageAfter)) fail('TRANSACTION_AUTHORITY_INVALID', 'curator usage');
1551
- }
1552
-
1553
- function validateTransactionIntent(context, transaction) {
1554
- if (transaction.kind !== 'reject') {
1555
- safeTargetDirectory(context, transaction.targetSkill);
1556
- validateSnapshotOwnership(context, transaction.before, transaction.targetSkill);
1557
- validateSnapshotOwnership(context, transaction.after, transaction.targetSkill);
1558
- validateUsagePinInvariant(transaction);
1559
- }
1560
- if (['apply', 'archive'].includes(transaction.kind)) validateApplyTransaction(context, transaction);
1561
- if (transaction.kind === 'reject') validateRejectTransaction(context, transaction);
1562
- if (transaction.kind === 'rollback') validateRollbackTransaction(context, transaction);
1563
- if (['curator-stale', 'curator-archive'].includes(transaction.kind)) validateCuratorTransaction(transaction);
1564
- }
1565
-
1566
- function validateAuthorizedTransaction(context, transaction) {
1567
- validateTransactionAuthority(context, transaction);
1568
- validateTransactionIntent(context, transaction);
1569
- }
1570
-
1571
- function checkpoint(options, phase, transaction) {
1572
- if (typeof options.faultInjector === 'function') options.faultInjector(phase, transaction);
1573
- }
1574
-
1575
- function cleanupScratch(context, transaction) {
1576
- readTrustedAuthority(context, transaction);
1577
- removeOwnedTree(transactionScratch(context, transaction.id));
1578
- }
1579
-
1580
- function recoverArchiveFilesystem(context, transaction) {
1581
- const target = join(context.userRoot, transaction.targetSkill);
1582
- const archive = transaction.archivePath;
1583
- safeMkdir(dirname(archive));
1584
- if (existsSync(target)) {
1585
- if (!snapshotsEqual(captureSnapshot(context, target), transaction.before)) fail('TRANSACTION_CAS_MISMATCH', transaction.targetSkill);
1586
- if (existsSync(archive)) fail('TRANSACTION_ARCHIVE_CONFLICT', archive);
1587
- renameSync(target, archive);
1588
- syncDirectory(context.userRoot);
1589
- syncDirectory(dirname(archive));
1590
- }
1591
- if (!existsSync(archive) || !snapshotsEqual(captureSnapshot(context, archive), transaction.before)) {
1592
- fail('TRANSACTION_RECOVERY_FAILED', transaction.targetSkill);
1593
- }
1594
- if (!snapshotsEqual(captureSnapshot(context, target), transaction.after)) fail('TRANSACTION_RECOVERY_FAILED', transaction.targetSkill);
1595
- }
1596
-
1597
- function recoverSnapshotFilesystem(context, transaction) {
1598
- const target = join(context.userRoot, transaction.targetSkill);
1599
- const scratch = transactionScratch(context, transaction.id);
1600
- const stage = join(scratch, 'stage');
1601
- const backup = join(scratch, 'backup');
1602
- const current = captureSnapshot(context, target);
1603
- if (snapshotsEqual(current, transaction.after)) {
1604
- if (existsSync(backup)) removeOwnedTree(backup);
1605
- if (existsSync(stage)) removeOwnedTree(stage);
1606
- return;
1607
- }
1608
- const hasBackup = existsSync(backup);
1609
- if (!snapshotsEqual(current, transaction.before)) {
1610
- if (!existsSync(target) && hasBackup && snapshotsEqual(captureSnapshot(context, backup), transaction.before)) {
1611
- // Resume after the original target was moved to the deterministic backup.
1612
- } else {
1613
- fail('TRANSACTION_CAS_MISMATCH', transaction.targetSkill);
1614
- }
1615
- }
1616
- safeMkdir(scratch);
1617
- if (transaction.after.length > 0) {
1618
- if (existsSync(stage)) {
1619
- if (!snapshotsEqual(captureSnapshot(context, stage), transaction.after)) fail('TRANSACTION_STAGE_CORRUPT', transaction.id);
1620
- } else {
1621
- materializeSnapshot(context, transaction.after, stage);
1622
- }
1623
- }
1624
- if (existsSync(target)) {
1625
- if (existsSync(backup)) fail('TRANSACTION_BACKUP_CONFLICT', transaction.id);
1626
- renameSync(target, backup);
1627
- syncDirectory(context.userRoot);
1628
- syncDirectory(scratch);
1629
- } else if (transaction.before.length > 0 && !existsSync(backup)) {
1630
- fail('TRANSACTION_RECOVERY_FAILED', transaction.targetSkill);
1631
- }
1632
- if (transaction.after.length > 0 && !existsSync(target)) {
1633
- renameSync(stage, target);
1634
- syncDirectory(scratch);
1635
- syncDirectory(context.userRoot);
1636
- }
1637
- const restored = captureSnapshot(context, target);
1638
- if (!snapshotsEqual(restored, transaction.after)) fail('TRANSACTION_RECOVERY_FAILED', transaction.targetSkill);
1639
- if (existsSync(backup)) removeOwnedTree(backup);
1640
- if (existsSync(stage)) removeOwnedTree(stage);
1641
- }
1642
-
1643
- function transitionUsage(context, transaction) {
1644
- const usage = readUsage(context);
1645
- const current = usage.skills[transaction.targetSkill] ?? null;
1646
- if (jsonEqual(current, transaction.usageAfter)) return;
1647
- if (!jsonEqual(current, transaction.usageBefore)) fail('TRANSACTION_USAGE_CAS_MISMATCH', transaction.targetSkill);
1648
- if (transaction.usageAfter === null) delete usage.skills[transaction.targetSkill];
1649
- else usage.skills[transaction.targetSkill] = jsonClone(transaction.usageAfter);
1650
- const updated = Math.max(Date.parse(usage.updatedAt), Date.parse(transaction.ledger.createdAt));
1651
- usage.updatedAt = new Date(updated).toISOString();
1652
- writeUsage(context, usage);
1653
- }
1654
-
1655
- function transitionProposal(context, transaction) {
1656
- if (transaction.proposalBefore === null) return;
1657
- const path = proposalPath(context, transaction.proposalBefore.id);
1658
- if (!existsSync(path)) fail('TRANSACTION_PROPOSAL_MISSING', transaction.proposalBefore.id);
1659
- const current = readJson(path, 'PROPOSAL_INVALID');
1660
- if (jsonEqual(current, transaction.proposalAfter)) return;
1661
- if (!jsonEqual(current, transaction.proposalBefore)) fail('TRANSACTION_PROPOSAL_CAS_MISMATCH', transaction.proposalBefore.id);
1662
- atomicJson(path, transaction.proposalAfter);
1663
- }
1664
-
1665
- function validateCommittedTransactionState(context, transaction) {
1666
- validateTransactionIntent(context, transaction);
1667
- if (transaction.kind !== 'reject') {
1668
- const current = captureSnapshot(context, join(context.userRoot, transaction.targetSkill));
1669
- if (!snapshotsEqual(current, transaction.after)) fail('TRANSACTION_COMPLETION_STATE_MISMATCH', 'filesystem');
1670
- if (['archive', 'curator-archive'].includes(transaction.kind)) {
1671
- if (!existsSync(transaction.archivePath)
1672
- || !snapshotsEqual(captureSnapshot(context, transaction.archivePath), transaction.before)) {
1673
- fail('TRANSACTION_COMPLETION_STATE_MISMATCH', 'archive');
1674
- }
1675
- }
1676
- const usage = readUsage(context).skills[transaction.targetSkill] ?? null;
1677
- if (!jsonEqual(usage, transaction.usageAfter)) fail('TRANSACTION_COMPLETION_STATE_MISMATCH', 'usage');
1678
- }
1679
- const ledger = readLedger(context).filter((entry) => entry.id === transaction.ledger.id);
1680
- if (ledger.length !== 1 || !jsonEqual(ledger[0], transaction.ledger)) fail('TRANSACTION_COMPLETION_STATE_MISMATCH', 'ledger');
1681
- if (transaction.proposalAfter !== null) {
1682
- const proposal = readJson(proposalPath(context, transaction.proposalAfter.id), 'PROPOSAL_INVALID');
1683
- if (!jsonEqual(proposal, transaction.proposalAfter)) fail('TRANSACTION_COMPLETION_STATE_MISMATCH', 'proposal');
1684
- }
1685
- }
1686
-
1687
- function finalizeProjectTransaction(context, transaction) {
1688
- const journal = transactionPath(context, transaction.id);
1689
- const completion = transactionCompletionPath(context, transaction.id);
1690
- if (existsSync(journal)) unlinkSync(journal);
1691
- if (existsSync(completion)) unlinkSync(completion);
1692
- syncDirectory(dirname(journal));
1693
- }
1694
-
1695
- function recoverTransaction(context, transaction, options = {}) {
1696
- validateTransaction(context, transaction);
1697
- const completion = validateTransactionCompletion(context, transaction);
1698
- if (!existsSync(transactionAuthorityPath(context, transaction.id))) {
1699
- if (completion === null) fail('TRANSACTION_AUTHORITY_MISSING', transaction.id);
1700
- validateCommittedTransactionState(context, transaction);
1701
- finalizeProjectTransaction(context, transaction);
1702
- return transaction;
1703
- }
1704
- validateAuthorizedTransaction(context, transaction);
1705
- if (['archive', 'curator-archive'].includes(transaction.kind)) recoverArchiveFilesystem(context, transaction);
1706
- else if (transaction.kind !== 'reject') recoverSnapshotFilesystem(context, transaction);
1707
- checkpoint(options, 'after-filesystem', transaction);
1708
- if (transaction.kind !== 'reject') transitionUsage(context, transaction);
1709
- checkpoint(options, 'after-usage', transaction);
1710
- appendLedgerIdempotent(context, transaction.ledger);
1711
- checkpoint(options, 'after-ledger', transaction);
1712
- transitionProposal(context, transaction);
1713
- checkpoint(options, 'after-proposal', transaction);
1714
- atomicJson(transactionCompletionPath(context, transaction.id), transactionCompletion(context, transaction));
1715
- cleanupScratch(context, transaction);
1716
- checkpoint(options, 'after-authority-cleanup', transaction);
1717
- finalizeProjectTransaction(context, transaction);
1718
- return transaction;
1719
- }
1720
-
1721
- export function recoverTransactions(context, options = {}) {
1722
- assertLearningLoopSupported(options);
1723
- const directory = transactionDirectory(context);
1724
- for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
1725
- if (!entry.name.endsWith('.json')) continue;
1726
- if (!entry.isFile()) fail('TRANSACTION_INVALID', entry.name);
1727
- recoverTransaction(context, validateTransaction(context, readJson(join(directory, entry.name), 'TRANSACTION_INVALID')));
1728
- }
1729
- }
1730
-
1731
- function executeTransaction(context, transaction, options = {}) {
1732
- validateTransaction(context, transaction);
1733
- validateTransactionIntent(context, transaction);
1734
- const path = transactionPath(context, transaction.id);
1735
- if (existsSync(path)) fail('TRANSACTION_ALREADY_EXISTS', transaction.id);
1736
- const scratch = transactionScratch(context, transaction.id);
1737
- if (existsSync(scratch)) fail('TRANSACTION_AUTHORITY_CONFLICT', transaction.id);
1738
- safeMkdir(scratch);
1739
- validateAuthorityDirectoryChain(context, transaction);
1740
- atomicJson(transactionAuthorityPath(context, transaction.id), transactionAuthority(context, transaction));
1741
- validateTransactionAuthority(context, transaction);
1742
- try {
1743
- atomicJson(path, transaction);
1744
- } catch (error) {
1745
- cleanupScratch(context, transaction);
1746
- throw error;
1747
- }
1748
- return recoverTransaction(context, transaction, options);
1749
- }
1750
-
1751
- function makeTransaction({ after, archivePath = null, before, kind, ledger, proposalAfter = null, proposalBefore = null, targetRoot, targetSkill, usageAfter, usageBefore }) {
1752
- return {
1753
- schema: TRANSACTION_SCHEMA,
1754
- id: `transaction-${randomUUID()}`,
1755
- createdAt: new Date().toISOString(),
1756
- kind,
1757
- targetSkill,
1758
- targetRoot,
1759
- before,
1760
- after,
1761
- ledger,
1762
- proposalBefore,
1763
- proposalAfter,
1764
- usageBefore: jsonClone(usageBefore ?? null),
1765
- usageAfter: jsonClone(usageAfter ?? null),
1766
- archivePath,
1767
- };
1768
- }
1769
-
1770
- function usageAfterApply(usageBefore, now) {
1771
- const entry = jsonClone(usageBefore) ?? {
1772
- state: 'active', use_count: 0, view_count: 0, patch_count: 0, created_at: now,
1773
- created_by: 'agent', last_used_at: null, last_viewed_at: null, last_patched_at: null,
1774
- archived_at: null, pinned: false,
1775
- };
1776
- entry.state = 'active';
1777
- entry.patch_count += 1;
1778
- entry.last_patched_at = now;
1779
- entry.archived_at = null;
1780
- return entry;
1781
- }
1782
-
1783
- function applyArchiveProposal(context, record, proposalBefore, options) {
1784
- const target = safeTargetDirectory(context, record.targetSkill).target;
1785
- const before = captureSnapshot(context, target);
1786
- const usageBefore = jsonClone(readUsage(context).skills[record.targetSkill] ?? null);
1787
- const createdAt = new Date().toISOString();
1788
- const usageAfter = jsonClone(usageBefore);
1789
- if (usageAfter) {
1790
- usageAfter.state = 'archived';
1791
- usageAfter.archived_at = createdAt;
1792
- }
1793
- const ledger = newLedgerEntry({
1794
- actor: 'user', createdAt, operation: 'apply', proposalId: record.id, targetSkill: record.targetSkill,
1795
- targetRoot: record.targetRoot, before, after: [], reason: 'approved archive; package preserved outside discovery',
1796
- usageBefore, usageAfter,
1797
- });
1798
- const proposalAfter = { ...record, status: 'applied', ledgerEntryId: ledger.id };
1799
- const destination = join(ensureBelow(context.grokRoot, ['litgrok', 'archive']), `${record.targetSkill}-${ledger.id}`);
1800
- executeTransaction(context, makeTransaction({
1801
- kind: 'archive', targetSkill: record.targetSkill, targetRoot: record.targetRoot,
1802
- before, after: [], ledger, proposalBefore, proposalAfter, usageBefore, usageAfter,
1803
- archivePath: destination,
1804
- }), options);
1805
- return { ok: true, id: record.id, ledgerEntryId: ledger.id, targetSkill: record.targetSkill };
1806
- }
1807
-
1808
- export function applyProposal(id, options = {}) {
1809
- assertLearningLoopSupported(options);
1810
- const context = options.context ?? createLoopContext(options);
1811
- return withStateLock(globalLock(context), () => {
1812
- recoverTransactions(context);
1813
- let record = loadProposal(context, id);
1814
- if (record.status === 'applied') {
1815
- const decision = resolveAppliedLedger(context, record);
1816
- return { ok: true, id, ledgerEntryId: decision.id, targetSkill: record.targetSkill, recovered: true };
1817
- }
1818
- if (!['pending', 'approved'].includes(record.status)) fail('PROPOSAL_NOT_APPLICABLE', record.status);
1819
- if (record.status === 'pending') {
1820
- record = validateProposal({ ...record, status: 'approved' }, { context });
1821
- atomicJson(proposalPath(context, id), record);
1822
- } else record = validateProposal(record, { context });
1823
- const proposalBefore = record;
1824
- if (record.action === 'archive') return applyArchiveProposal(context, record, proposalBefore, options);
1825
- const targetState = safeTargetDirectory(context, record.targetSkill);
1826
- const before = captureSnapshot(context, targetState.target);
1827
- const stage = temporaryDirectory(context, 'apply');
1828
- try {
1829
- if (targetState.exists) copyTree(targetState.target, stage);
1830
- applyProposalToStage(record, stage);
1831
- const after = captureSnapshot(context, stage);
1832
- const usageBefore = jsonClone(readUsage(context).skills[record.targetSkill] ?? null);
1833
- const createdAt = new Date().toISOString();
1834
- const usageAfter = usageAfterApply(usageBefore, createdAt);
1835
- const ledger = newLedgerEntry({
1836
- actor: 'user', createdAt, operation: 'apply', proposalId: record.id, targetSkill: record.targetSkill,
1837
- targetRoot: record.targetRoot, before, after, usageBefore, usageAfter,
1838
- });
1839
- const proposalAfter = { ...record, status: 'applied', ledgerEntryId: ledger.id };
1840
- executeTransaction(context, makeTransaction({
1841
- kind: 'apply', targetSkill: record.targetSkill, targetRoot: record.targetRoot,
1842
- before, after, ledger, proposalBefore, proposalAfter, usageBefore, usageAfter,
1843
- }), options);
1844
- return { ok: true, id, ledgerEntryId: ledger.id, targetSkill: record.targetSkill };
1845
- } finally {
1846
- if (existsSync(stage)) rmSync(stage, { recursive: true, force: true });
1847
- }
1848
- });
1849
- }
1850
-
1851
- export function rejectProposal(id, options = {}) {
1852
- assertLearningLoopSupported(options);
1853
- const context = options.context ?? createLoopContext(options);
1854
- return withStateLock(globalLock(context), () => {
1855
- recoverTransactions(context);
1856
- const record = loadProposal(context, id);
1857
- if (record.status === 'rejected') {
1858
- const decision = resolveRejectDecisionLedger(context, record);
1859
- return { ok: true, id, ledgerEntryId: decision.id, recovered: true };
1860
- }
1861
- if (record.status !== 'pending') fail('PROPOSAL_NOT_REJECTABLE', record.status);
1862
- const ledger = newLedgerEntry({
1863
- actor: 'user', operation: 'reject', proposalId: record.id, targetSkill: record.targetSkill,
1864
- targetRoot: record.targetRoot, before: [], after: [], reason: options.reason ?? 'rejected by user',
1865
- usageBefore: null, usageAfter: null,
1866
- });
1867
- const proposalAfter = { ...record, status: 'rejected' };
1868
- executeTransaction(context, makeTransaction({
1869
- kind: 'reject', targetSkill: record.targetSkill, targetRoot: record.targetRoot,
1870
- before: [], after: [], ledger, proposalBefore: record, proposalAfter,
1871
- usageBefore: null, usageAfter: null,
1872
- }), options);
1873
- return { ok: true, id, ledgerEntryId: ledger.id };
1874
- });
1875
- }
1876
-
1877
- export function rollbackLedger(id, options = {}) {
1878
- assertLearningLoopSupported(options);
1879
- const context = options.context ?? createLoopContext(options);
1880
- return withStateLock(globalLock(context), () => {
1881
- recoverTransactions(context);
1882
- const entries = readLedger(context);
1883
- const source = entries.find((entry) => entry.id === id);
1884
- if (!source || !['apply', 'curator-transition', 'rollback'].includes(source.operation)) fail('LEDGER_NOT_ROLLBACK_ELIGIBLE', id);
1885
- const priorRollback = entries.find((entry) => entry.operation === 'rollback' && entry.restoresLedgerEntryId === source.id);
1886
- if (priorRollback) {
1887
- const currentUsage = readUsage(context).skills[source.targetSkill] ?? null;
1888
- if (!snapshotsEqual(captureSnapshot(context, join(context.userRoot, source.targetSkill)), source.before)
1889
- || !jsonEqual(currentUsage, source.usageBefore ?? null)) fail('ROLLBACK_RECOVERY_MISMATCH');
1890
- return { ok: true, ledgerEntryId: priorRollback.id, restoresLedgerEntryId: source.id, targetSkill: source.targetSkill, recovered: true };
1891
- }
1892
- if (!Object.hasOwn(source, 'usageBefore') || !Object.hasOwn(source, 'usageAfter')) fail('LEDGER_USAGE_TRANSITION_MISSING', id);
1893
- const targetRoot = canonicalTargetRoot(context, source.targetRoot);
1894
- const target = join(context.userRoot, source.targetSkill);
1895
- const current = captureSnapshot(context, target);
1896
- if (!snapshotsEqual(current, source.after)) fail('ROLLBACK_CAS_MISMATCH');
1897
- const usageBefore = jsonClone(readUsage(context).skills[source.targetSkill] ?? null);
1898
- if (!jsonEqual(usageBefore, source.usageAfter)) fail('ROLLBACK_USAGE_CAS_MISMATCH');
1899
- const usageAfter = jsonClone(source.usageBefore);
1900
- const createdAt = new Date().toISOString();
1901
- const ledger = newLedgerEntry({
1902
- actor: 'user', createdAt, operation: 'rollback', proposalId: source.proposalId ?? null,
1903
- targetSkill: source.targetSkill, targetRoot, before: current, after: source.before,
1904
- restoresLedgerEntryId: source.id, usageBefore, usageAfter,
1905
- });
1906
- let proposalBefore = null;
1907
- let proposalAfter = null;
1908
- if (source.proposalId) {
1909
- proposalBefore = loadProposal(context, source.proposalId);
1910
- proposalAfter = { ...proposalBefore, status: 'rolled-back', ledgerEntryId: ledger.id };
1911
- }
1912
- executeTransaction(context, makeTransaction({
1913
- kind: 'rollback', targetSkill: source.targetSkill, targetRoot,
1914
- before: current, after: source.before, ledger, proposalBefore, proposalAfter,
1915
- usageBefore, usageAfter,
1916
- }), options);
1917
- return { ok: true, ledgerEntryId: ledger.id, restoresLedgerEntryId: source.id, targetSkill: source.targetSkill };
1918
- });
1919
- }
1920
-
1921
- export function applyCuratorTransition(options = {}) {
1922
- assertLearningLoopSupported(options);
1923
- const context = options.context ?? createLoopContext(options);
1924
- const skill = validateSkillName(options.skill);
1925
- const action = options.action;
1926
- if (!['stale', 'archive'].includes(action)) fail('CURATOR_TRANSITION_INVALID');
1927
- const createdAt = requireString(options.now, 'TIMESTAMP_INVALID', 64);
1928
- requireTimestamp(createdAt);
1929
- const target = safeTargetDirectory(context, skill).target;
1930
- const before = captureSnapshot(context, target);
1931
- const usageBefore = jsonClone(readUsage(context).skills[skill] ?? null);
1932
- if (!usageBefore) fail('CURATOR_USAGE_MISSING', skill);
1933
- if (action === 'stale' && usageBefore.state !== 'active') fail('CURATOR_TRANSITION_INVALID', 'stale source');
1934
- if (action === 'archive' && usageBefore.state !== 'stale') fail('CURATOR_TRANSITION_INVALID', 'archive source');
1935
- const usageAfter = jsonClone(usageBefore);
1936
- usageAfter.state = action === 'stale' ? 'stale' : 'archived';
1937
- usageAfter.archived_at = action === 'archive' ? createdAt : null;
1938
- const after = action === 'stale' ? before : [];
1939
- const ledger = newLedgerEntry({
1940
- actor: 'curator', createdAt, operation: 'curator-transition', proposalId: null,
1941
- targetSkill: skill, targetRoot: realpathSync(context.userRoot), before, after,
1942
- reason: action === 'stale' ? 'marked stale after 30 idle days' : 'archived after 90 idle days; package preserved',
1943
- usageBefore, usageAfter,
1944
- });
1945
- const archivePath = action === 'archive'
1946
- ? (options.archivePath ?? join(ensureBelow(context.grokRoot, ['litgrok', 'archive']), `${skill}-${ledger.id}`))
1947
- : null;
1948
- executeTransaction(context, makeTransaction({
1949
- kind: action === 'archive' ? 'curator-archive' : 'curator-stale',
1950
- targetSkill: skill, targetRoot: ledger.targetRoot, before, after, ledger,
1951
- usageBefore, usageAfter, archivePath,
1952
- }), options);
1953
- return ledger;
1954
- }
1955
-
1956
- function usagePath(context) {
1957
- return join(context.stateRoot, 'skill-usage.json');
1958
- }
1959
-
1960
- function validateUsageEntry(entry, code = 'USAGE_INVALID', updated = null) {
1961
- if (entry === null) return null;
1962
- requireExactKeys(
1963
- entry,
1964
- ['state', 'use_count', 'view_count', 'patch_count', 'created_at', 'created_by', 'last_used_at', 'last_viewed_at', 'last_patched_at', 'archived_at', 'pinned'],
1965
- ['state', 'use_count', 'view_count', 'patch_count', 'created_at', 'created_by', 'last_used_at', 'last_viewed_at', 'last_patched_at', 'archived_at', 'pinned'],
1966
- code,
1967
- );
1968
- if (!['active', 'stale', 'archived'].includes(entry.state) || !['agent', 'user', 'installed', 'curator', null].includes(entry.created_by)) fail(code);
1969
- if (typeof entry.pinned !== 'boolean') fail(code);
1970
- const created = requireTimestamp(entry.created_at, code);
1971
- if (updated !== null && created > updated) fail(code);
1972
- for (const [countKey, timeKey] of [['use_count', 'last_used_at'], ['view_count', 'last_viewed_at'], ['patch_count', 'last_patched_at']]) {
1973
- if (!Number.isSafeInteger(entry[countKey]) || entry[countKey] < 0) fail(code);
1974
- if (entry[timeKey] !== null) {
1975
- const time = requireTimestamp(entry[timeKey], code);
1976
- if (time < created || (updated !== null && time > updated)) fail(code);
1977
- } else if (entry[countKey] > 0) fail(code);
1978
- }
1979
- if (entry.state === 'archived') {
1980
- if (entry.archived_at === null) fail(code);
1981
- const archived = requireTimestamp(entry.archived_at, code);
1982
- if (archived < created || (updated !== null && archived > updated)) fail(code);
1983
- } else if (entry.archived_at !== null) fail(code);
1984
- return entry;
1985
- }
1986
-
1987
- export function readUsage(context) {
1988
- assertLearningLoopSupported();
1989
- const path = usagePath(context);
1990
- if (!existsSync(path)) return { schema: USAGE_SCHEMA, updatedAt: new Date(0).toISOString(), skills: {} };
1991
- const usage = readJson(path, 'USAGE_INVALID');
1992
- requireExactKeys(usage, ['schema', 'updatedAt', 'skills'], ['schema', 'updatedAt', 'skills'], 'USAGE_INVALID');
1993
- if (usage.schema !== USAGE_SCHEMA || !isObject(usage.skills)) fail('USAGE_INVALID');
1994
- const updated = requireTimestamp(usage.updatedAt, 'USAGE_INVALID');
1995
- for (const [skill, entry] of Object.entries(usage.skills)) {
1996
- validateSkillName(skill, 'USAGE_INVALID');
1997
- validateUsageEntry(entry, 'USAGE_INVALID', updated);
1998
- }
1999
- return usage;
2000
- }
2001
-
2002
- export function writeUsage(context, usage) {
2003
- assertLearningLoopSupported();
2004
- atomicJson(usagePath(context), usage);
2005
- }
2006
-
2007
- export function copySkillTree(source, destination) {
2008
- assertLearningLoopSupported();
2009
- return copyTree(source, destination);
2010
- }
2011
-
2012
- export function isAgentOwnedSkill(context, skill) {
2013
- assertLearningLoopSupported();
2014
- return safeTargetDirectory(context, skill).exists;
2015
- }
2016
-
2017
- export function agentSkillCatalog(options = {}) {
2018
- assertLearningLoopSupported(options);
2019
- const context = options.context ?? createLoopContext(options);
2020
- const catalog = [];
2021
- for (const entry of readdirSync(context.userRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
2022
- if (!entry.isDirectory() || shippedSkillIds().has(entry.name)) continue;
2023
- try {
2024
- const state = safeTargetDirectory(context, entry.name);
2025
- if (!state.exists) continue;
2026
- catalog.push({ name: entry.name, skill: readText(join(state.target, 'SKILL.md'), 65536) });
2027
- } catch (error) {
2028
- if (!(error instanceof SkillLoopError)) throw error;
2029
- }
2030
- if (catalog.length >= 32) break;
2031
- }
2032
- return catalog;
2033
- }
2034
-
2035
- function parseCli(args) {
2036
- const [command, ...rest] = args;
2037
- if (command === 'propose' && rest.length === 1) return { command, file: rest[0] };
2038
- if (command === 'list' && rest.length === 0) return { command };
2039
- if (['apply', 'reject', 'rollback'].includes(command) && rest.length === 1) return { command, id: rest[0] };
2040
- if (command === 'review' && rest.length <= 1) return { command, sessionId: rest[0] };
2041
- if (command === 'curator') {
2042
- let now;
2043
- let force = false;
2044
- for (let index = 0; index < rest.length; index += 1) {
2045
- if (rest[index] === '--force') force = true;
2046
- else if (rest[index] === '--now' && rest[index + 1]) now = rest[++index];
2047
- else return null;
2048
- }
2049
- return { command, force, now };
2050
- }
2051
- return null;
2052
- }
2053
-
2054
- export async function runSkillLoop(args, options = {}) {
2055
- const stdout = options.stdout ?? process.stdout;
2056
- const stderr = options.stderr ?? process.stderr;
2057
- try {
2058
- const parsed = parseCli(args);
2059
- if (!parsed) fail('USAGE', 'skill-loop propose <file>|list|apply <id>|reject <id>|rollback <ledger-id>|review [session-id]|curator [--now <RFC3339>] [--force]');
2060
- assertLearningLoopSupported(options);
2061
- const context = createLoopContext(options);
2062
- let result;
2063
- if (parsed.command === 'propose') result = { ok: true, id: queueProposal(readJson(resolve(options.cwd ?? process.cwd(), parsed.file), 'PROPOSAL_INVALID'), { context }).id };
2064
- if (parsed.command === 'list') result = { proposals: listProposals({ context }) };
2065
- if (parsed.command === 'apply') result = applyProposal(parsed.id, { context });
2066
- if (parsed.command === 'reject') result = rejectProposal(parsed.id, { context });
2067
- if (parsed.command === 'rollback') result = rollbackLedger(parsed.id, { context });
2068
- if (parsed.command === 'review') {
2069
- const { runForegroundReview } = await import('./review.mjs');
2070
- result = await runForegroundReview({ context, sessionId: parsed.sessionId });
2071
- }
2072
- if (parsed.command === 'curator') {
2073
- const { runCurator } = await import('./curator.mjs');
2074
- result = runCurator({ context, force: parsed.force, now: parsed.now });
2075
- }
2076
- stdout.write(`${JSON.stringify(result)}\n`);
2077
- return 0;
2078
- } catch (error) {
2079
- const code = error instanceof SkillLoopError ? error.code : 'SKILL_LOOP_FAILED';
2080
- stderr.write(`LitGrok skill loop: ${code}${error instanceof SkillLoopError && error.message !== code ? ` (${error.message.slice(code.length + 2)})` : ''}\n`);
2081
- return error instanceof SkillLoopError ? 2 : 1;
2082
- }
2083
- }
2084
-
2085
- if (process.argv[1] && realpathSync(resolve(process.argv[1])) === SCRIPT_PATH) {
2086
- void runSkillLoop(process.argv.slice(2)).then((code) => { process.exitCode = code; });
2087
- }