litgrok-ai 0.2.5 → 0.2.6

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.
@@ -6,8 +6,8 @@
6
6
  "selection": "25 companion files retained; SKILL.md is a Grok-native adapter"
7
7
  },
8
8
  "file_count": 25,
9
- "byte_count": 54924,
10
- "aggregate_sha256": "13060670bae942a618c1c905243ca6cda158f153fab25ffa6e46d55a42778170",
9
+ "byte_count": 55711,
10
+ "aggregate_sha256": "406a7ca96354e3b863577cdbcb783122031fd451f967d0a2bfadfcdd1949fd34",
11
11
  "files": [
12
12
  {
13
13
  "path": "assets/conference_template.md",
@@ -101,8 +101,8 @@
101
101
  },
102
102
  {
103
103
  "path": "scripts/init_conference.py",
104
- "size": 21078,
105
- "sha256": "cb83623c1338b7e3a5be7a7beac7a17b8ae362fc24fbc01d1caff634db290dfc"
104
+ "size": 21865,
105
+ "sha256": "58aaaa91d3f31bdacfccac4c3a736efb62cb89563f7d37281be0e49f84651187"
106
106
  },
107
107
  {
108
108
  "path": "templates/code-performance.md",
@@ -209,6 +209,23 @@ def preflight_capabilities() -> None:
209
209
  )
210
210
 
211
211
 
212
+ def same_directory_identity(left: os.stat_result, right: os.stat_result) -> bool:
213
+ """Compare path identity, including ctime to reject recreated directories."""
214
+ return (
215
+ left.st_dev,
216
+ left.st_ino,
217
+ left.st_mode,
218
+ left.st_uid,
219
+ left.st_ctime_ns,
220
+ ) == (
221
+ right.st_dev,
222
+ right.st_ino,
223
+ right.st_mode,
224
+ right.st_uid,
225
+ right.st_ctime_ns,
226
+ )
227
+
228
+
212
229
  def open_safe_directory(path: Path) -> Tuple[int, Path]:
213
230
  """Create/open path one component at a time without following symlinks."""
214
231
  absolute = Path(os.path.abspath(os.fspath(path)))
@@ -233,7 +250,16 @@ def open_safe_directory(path: Path) -> Tuple[int, Path]:
233
250
  fail("UNSAFE_OUTPUT_SYMLINK", f"output component is a symlink: {absolute}")
234
251
  if not stat.S_ISDIR(info.st_mode):
235
252
  fail("UNSAFE_OUTPUT_PATH", f"output component is not a directory: {absolute}")
236
- next_fd = os.open(part, flags | nofollow, dir_fd=fd)
253
+ next_fd = None
254
+ try:
255
+ next_fd = os.open(part, flags | nofollow, dir_fd=fd)
256
+ opened = os.fstat(next_fd)
257
+ if not same_directory_identity(info, opened):
258
+ fail("UNSAFE_OUTPUT_SWAP", f"output component identity changed: {absolute}")
259
+ except BaseException:
260
+ if next_fd is not None:
261
+ os.close(next_fd)
262
+ raise
237
263
  os.close(fd)
238
264
  fd = next_fd
239
265
  verify_directory_identity(absolute, fd)
@@ -251,7 +277,7 @@ def verify_directory_identity(path: Path, fd: int) -> None:
251
277
  opened = os.fstat(fd)
252
278
  if stat.S_ISLNK(named.st_mode) or not stat.S_ISDIR(named.st_mode):
253
279
  fail("UNSAFE_OUTPUT_SYMLINK", f"output directory is no longer a real directory: {path}")
254
- if (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino):
280
+ if not same_directory_identity(named, opened):
255
281
  fail("UNSAFE_OUTPUT_SWAP", f"output directory identity changed: {path}")
256
282
 
257
283
 
@@ -11,8 +11,8 @@ const VERIFIER_RELATIVE = 'scripts/verify-canonical-corpus.mjs';
11
11
  const EXPECTED = Object.freeze({
12
12
  "schemaVersion": "litgrok.autoconference-corpus/v1",
13
13
  "fileCount": 25,
14
- "byteCount": 54924,
15
- "aggregateSha256": "13060670bae942a618c1c905243ca6cda158f153fab25ffa6e46d55a42778170",
14
+ "byteCount": 55711,
15
+ "aggregateSha256": "406a7ca96354e3b863577cdbcb783122031fd451f967d0a2bfadfcdd1949fd34",
16
16
  "files": {
17
17
  "assets/conference_template.md": "c1b0a1bbfb52080384e9bf6d725a91d2165a2105a4f055820660a607c913c7e7",
18
18
  "assets/report_template.md": "73a00032dc143f34f67a97a257f76e7a5acd9365d367e0b27d0c72e439a336ab",
@@ -32,7 +32,7 @@ const EXPECTED = Object.freeze({
32
32
  "references/modes/survey.md": "93866084c2abe5d7a1c6ef7251e5594e03044545ddda5e80c1ca42ef450769d0",
33
33
  "references/results-logging.md": "829714f30af623aa78a4ebc9e13a0ba7135b2c876af3443e1c1274af8857fe96",
34
34
  "references/visualization-guide.md": "4f7e705735b948038193c5a9c111e2db85672f5d97bb1c570aad217843868331",
35
- "scripts/init_conference.py": "cb83623c1338b7e3a5be7a7beac7a17b8ae362fc24fbc01d1caff634db290dfc",
35
+ "scripts/init_conference.py": "58aaaa91d3f31bdacfccac4c3a736efb62cb89563f7d37281be0e49f84651187",
36
36
  "templates/code-performance.md": "a90d1425477d9aec002778ba695299cfabb593ccd3dbab82ed0d76038e17a4d5",
37
37
  "templates/debate-mode.md": "9c74435dc7eb2e2d24d32b0a80381aadc62890f4674d916022ce6ef220aab966",
38
38
  "templates/prompt-optimization.md": "8d78c233529533bb742986769941347eebc460fdb2a2dac63952e65883398e10",
@@ -6,8 +6,8 @@
6
6
  "selection": "27 companion files retained; SKILL.md is a Grok-native adapter"
7
7
  },
8
8
  "file_count": 27,
9
- "byte_count": 38018,
10
- "aggregate_sha256": "520a3c79715f484e912439dd0b350b652e30e3e78c8eb278061bfc90174e124c",
9
+ "byte_count": 38805,
10
+ "aggregate_sha256": "0296ca6e52fb7cb68a37f3e17f148248146955ad9624e37e449e60b56eeeb1d3",
11
11
  "files": [
12
12
  {
13
13
  "path": "assets/report_template.md",
@@ -136,8 +136,8 @@
136
136
  },
137
137
  {
138
138
  "path": "scripts/init_research.py",
139
- "size": 13374,
140
- "sha256": "a5434711928eb4d0487d933df5e1b374f8553140004c6ab8e38fe577ae074d05"
139
+ "size": 14161,
140
+ "sha256": "b675ea22abf0794e32dbbf59b95248f20878bbf3d071fa10d37b5a004a7f880c"
141
141
  },
142
142
  {
143
143
  "path": "scripts/style_presets.py",
@@ -103,6 +103,23 @@ def preflight_capabilities() -> None:
103
103
  )
104
104
 
105
105
 
106
+ def same_directory_identity(left: os.stat_result, right: os.stat_result) -> bool:
107
+ """Compare path identity, including ctime to reject recreated directories."""
108
+ return (
109
+ left.st_dev,
110
+ left.st_ino,
111
+ left.st_mode,
112
+ left.st_uid,
113
+ left.st_ctime_ns,
114
+ ) == (
115
+ right.st_dev,
116
+ right.st_ino,
117
+ right.st_mode,
118
+ right.st_uid,
119
+ right.st_ctime_ns,
120
+ )
121
+
122
+
106
123
  def open_safe_directory(path: Path) -> Tuple[int, Path]:
107
124
  """Create/open path one component at a time without following symlinks."""
108
125
  absolute = Path(os.path.abspath(os.fspath(path)))
@@ -127,7 +144,16 @@ def open_safe_directory(path: Path) -> Tuple[int, Path]:
127
144
  fail("UNSAFE_OUTPUT_SYMLINK", f"output component is a symlink: {absolute}")
128
145
  if not stat.S_ISDIR(info.st_mode):
129
146
  fail("UNSAFE_OUTPUT_PATH", f"output component is not a directory: {absolute}")
130
- next_fd = os.open(part, flags | nofollow, dir_fd=fd)
147
+ next_fd = None
148
+ try:
149
+ next_fd = os.open(part, flags | nofollow, dir_fd=fd)
150
+ opened = os.fstat(next_fd)
151
+ if not same_directory_identity(info, opened):
152
+ fail("UNSAFE_OUTPUT_SWAP", f"output component identity changed: {absolute}")
153
+ except BaseException:
154
+ if next_fd is not None:
155
+ os.close(next_fd)
156
+ raise
131
157
  os.close(fd)
132
158
  fd = next_fd
133
159
  verify_directory_identity(absolute, fd)
@@ -146,7 +172,7 @@ def verify_directory_identity(path: Path, fd: int) -> None:
146
172
  opened = os.fstat(fd)
147
173
  if stat.S_ISLNK(named.st_mode) or not stat.S_ISDIR(named.st_mode):
148
174
  fail("UNSAFE_OUTPUT_SYMLINK", f"output directory is no longer a real directory: {path}")
149
- if (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino):
175
+ if not same_directory_identity(named, opened):
150
176
  fail("UNSAFE_OUTPUT_SWAP", f"output directory identity changed: {path}")
151
177
 
152
178
 
@@ -11,8 +11,8 @@ const VERIFIER_RELATIVE = 'scripts/verify-canonical-corpus.mjs';
11
11
  const EXPECTED = Object.freeze({
12
12
  "schemaVersion": "litgrok.autoresearch-corpus/v1",
13
13
  "fileCount": 27,
14
- "byteCount": 38018,
15
- "aggregateSha256": "520a3c79715f484e912439dd0b350b652e30e3e78c8eb278061bfc90174e124c",
14
+ "byteCount": 38805,
15
+ "aggregateSha256": "0296ca6e52fb7cb68a37f3e17f148248146955ad9624e37e449e60b56eeeb1d3",
16
16
  "files": {
17
17
  "assets/report_template.md": "b6b7607488736f24d3bacea8f3ec8563da40ff782cc1d85c064ab53b2488a4f9",
18
18
  "assets/research_template.md": "726d175cd2db92dfe58a738117d9c6cc88211e519d3be8fb7cb05b66ba34c050",
@@ -39,7 +39,7 @@ const EXPECTED = Object.freeze({
39
39
  "references/modes/ship/type-checklists.md": "6bea500594cad1e23bdeee2df34f6dd079d02b279773b20f914d313cabd630b3",
40
40
  "references/results-logging.md": "4671c19df6d389b42091f2f117fd90726cb32f3082b44dbc9a895dc3625b9832",
41
41
  "references/visualization-guide.md": "2ca249d74b30b41e7660c8b7ba91a59b0c33a1e74fc18b2ec2f867441a6e4b27",
42
- "scripts/init_research.py": "a5434711928eb4d0487d933df5e1b374f8553140004c6ab8e38fe577ae074d05",
42
+ "scripts/init_research.py": "b675ea22abf0794e32dbbf59b95248f20878bbf3d071fa10d37b5a004a7f880c",
43
43
  "scripts/style_presets.py": "b1c7d495016b0737b622684f4381f87990dfaae77934b3acae44e19742ebdfd5"
44
44
  }
45
45
  });
@@ -10,6 +10,7 @@ import {
10
10
  fstatSync,
11
11
  fsyncSync,
12
12
  lstatSync,
13
+ linkSync,
13
14
  mkdirSync,
14
15
  openSync,
15
16
  readFileSync,
@@ -301,10 +302,119 @@ function validateLockFileSecurity(path, identity) {
301
302
  return status;
302
303
  }
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
+
304
414
  function readSecureLock(path, identity, options = {}) {
305
415
  let expected;
306
416
  try {
307
- expected = validateLockFileSecurity(path, identity);
417
+ expected = options.expectedStatus ?? validateLockFileSecurity(path, identity);
308
418
  } catch (error) {
309
419
  if (options.missingLock === 'propagate' && error?.code === 'ENOENT') throw error;
310
420
  throw error instanceof SkillLoopError ? error : new SkillLoopError('STATE_LOCK_INVALID', path);
@@ -313,12 +423,15 @@ function readSecureLock(path, identity, options = {}) {
313
423
  try {
314
424
  descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
315
425
  const opened = fstatSync(descriptor);
316
- if (!opened.isFile() || opened.size > 4096 || opened.nlink !== 1
317
- || opened.dev !== expected.dev || opened.ino !== expected.ino) fail('STATE_LOCK_INVALID', path);
318
- const contents = readFileSync(descriptor, 'utf8').trim();
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');
319
432
  const current = validateLockFileSecurity(path, identity);
320
- if (current.dev !== opened.dev || current.ino !== opened.ino || current.size !== opened.size) fail('STATE_LOCK_RACE', path);
321
- return { contents, status: current };
433
+ if (!sameLockIdentity(current, opened)) fail('STATE_LOCK_RACE', path);
434
+ return { contents, digest, status: current };
322
435
  } catch (error) {
323
436
  if (error instanceof SkillLoopError) throw error;
324
437
  if (options.missingLock === 'propagate' && error?.code === 'ENOENT') throw error;
@@ -344,12 +457,12 @@ function reclaimStaleLock(path, identity, options = {}) {
344
457
  options.faultInjector?.('reclaim-read', { path });
345
458
  let secureRead;
346
459
  try {
347
- secureRead = readSecureLock(path, identity, { missingLock: 'propagate' });
460
+ secureRead = readSecureLock(path, identity, { missingLock: 'propagate', expectedStatus: initial });
348
461
  } catch (error) {
349
462
  if (error?.code === 'ENOENT') return 'vanished';
350
463
  throw error;
351
464
  }
352
- const { contents, status } = secureRead;
465
+ const { contents, digest, status } = secureRead;
353
466
  let record;
354
467
  if (/^[1-9][0-9]{0,14}$/.test(contents)) {
355
468
  if (identity.platform === 'win32') fail('STATE_LOCK_INVALID', 'legacy owner unavailable');
@@ -381,57 +494,46 @@ function reclaimStaleLock(path, identity, options = {}) {
381
494
  throw error;
382
495
  }
383
496
  const moved = readSecureLock(tombstone, identity);
384
- if (moved.status.dev !== status.dev || moved.status.ino !== status.ino || moved.contents !== contents) {
385
- if (!existsSync(path)) renameSync(tombstone, path);
497
+ if (!sameLockHandle(moved.status, status) || moved.contents !== contents || moved.digest !== digest) {
498
+ restoreMovedLock(path, tombstone);
386
499
  fail('STATE_LOCK_RACE', path);
387
500
  }
388
501
  unlinkSync(tombstone);
389
502
  return 'reclaimed';
390
503
  }
391
504
 
392
- function cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten) {
393
- try {
394
- closeSync(descriptor);
395
- } catch {
396
- fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'descriptor');
397
- }
398
- let current;
505
+ function cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten, bytesWritten, identity, options = {}) {
399
506
  try {
400
- current = lstatSync(path);
401
- } catch {
402
- fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'missing');
403
- }
404
- if (!current.isFile() || current.isSymbolicLink() || current.dev !== created.dev || current.ino !== created.ino
405
- || current.nlink !== 1 || current.size > 4096) fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'identity');
406
- let reader;
407
- if (tokenWritten) {
408
507
  try {
409
- reader = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
410
- const opened = fstatSync(reader);
411
- if (opened.dev !== created.dev || opened.ino !== created.ino || opened.size > 4096
412
- || readFileSync(reader, 'utf8') !== expectedBytes) fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'token');
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
+ });
413
514
  } catch (error) {
414
- if (error instanceof SkillLoopError) throw error;
415
- fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'read');
416
- } finally {
417
- if (reader !== undefined) closeSync(reader);
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');
418
523
  }
419
524
  }
420
- const final = lstatSync(path);
421
- if (final.dev !== created.dev || final.ino !== created.ino || final.nlink !== 1) {
422
- fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'retargeted');
423
- }
424
- unlinkSync(path);
425
525
  }
426
526
 
427
527
  export function withStateLock(path, operation, attempts = 200, options = {}) {
428
528
  safeMkdir(dirname(path));
429
529
  const identity = lockIdentity(path);
430
530
  let descriptor;
531
+ let ownerBytes;
431
532
  for (let index = 0; index < attempts; index += 1) {
432
533
  let created;
433
534
  let expectedBytes;
434
535
  let tokenWritten = false;
536
+ let bytesWritten = 0;
435
537
  try {
436
538
  descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
437
539
  created = fstatSync(descriptor);
@@ -442,12 +544,15 @@ export function withStateLock(path, operation, attempts = 200, options = {}) {
442
544
  createdAt: new Date().toISOString(),
443
545
  nonce: randomUUID(),
444
546
  })}\n`;
547
+ ownerBytes = expectedBytes;
445
548
  const bytes = Buffer.from(expectedBytes, 'utf8');
446
549
  let offset = 0;
447
550
  while (offset < bytes.length) {
551
+ options.faultInjector?.('before-token-write', { descriptor, path });
448
552
  const written = writeSync(descriptor, bytes, offset, bytes.length - offset);
449
553
  if (!Number.isSafeInteger(written) || written < 1) fail('STATE_LOCK_WRITE_FAILED');
450
554
  offset += written;
555
+ bytesWritten = offset;
451
556
  }
452
557
  tokenWritten = true;
453
558
  fsyncSync(descriptor);
@@ -456,7 +561,7 @@ export function withStateLock(path, operation, attempts = 200, options = {}) {
456
561
  break;
457
562
  } catch (error) {
458
563
  if (descriptor !== undefined) {
459
- cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten);
564
+ cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten, bytesWritten, identity, options);
460
565
  descriptor = undefined;
461
566
  }
462
567
  if (error?.code !== 'EEXIST') throw error;
@@ -474,13 +579,16 @@ export function withStateLock(path, operation, attempts = 200, options = {}) {
474
579
  try {
475
580
  return operation();
476
581
  } finally {
477
- closeSync(descriptor);
478
582
  try {
479
- const status = validateLockFileSecurity(path, identity);
480
- if (status.dev !== owned.dev || status.ino !== owned.ino) fail('STATE_LOCK_OWNERSHIP_LOST', path);
481
- unlinkSync(path);
583
+ removePinnedLock(path, descriptor, identity, owned, ownerBytes, 'STATE_LOCK_OWNERSHIP_LOST', {
584
+ secure: true,
585
+ faultInjector: options.faultInjector,
586
+ phase: 'before-release-rename',
587
+ });
482
588
  } catch (error) {
483
589
  if (error?.code !== 'ENOENT') throw error;
590
+ } finally {
591
+ closeSync(descriptor);
484
592
  }
485
593
  }
486
594
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6 (pending) — 2026-09-02
4
+
5
+ - Harden inode-reuse handling across skill-loop state locks and the
6
+ autoconference/autoresearch output scaffolders: lock reclaim, failed-acquisition
7
+ cleanup, and release stay bound to descriptor identity, ctime/birthtime, and
8
+ exact bytes through tombstone checks, while opened output directories are
9
+ revalidated against full stat identity before publication. Add same-inode race
10
+ regressions and regenerate the affected canonical manifests so replacements fail
11
+ closed while authorized rotations remain valid.
12
+
3
13
  ## 0.2.5 (pending) — 2026-09-01
4
14
 
5
15
  - Add the shared stacked LitFamily ASCII wordmark to the installer and lead both
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  </p>
23
23
  <p align="center">
24
24
  <img src="https://img.shields.io/badge/npm-litgrok--ai-cb3837" alt="npm: litgrok-ai" />
25
- <img src="https://img.shields.io/badge/version-0.2.5-2ea44f" alt="version 0.2.5" />
25
+ <img src="https://img.shields.io/badge/version-0.2.6-2ea44f" alt="version 0.2.6" />
26
26
  <img src="https://img.shields.io/badge/Grok%20Build-skills%20%2B%20rules-111827" alt="Grok Build skills and project rules" />
27
27
  <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" />
28
28
  </p>
@@ -105,7 +105,7 @@ npx --yes litgrok-ai@latest install
105
105
  Pin a version when you need a reproducible install:
106
106
 
107
107
  ```bash
108
- npx --yes litgrok-ai@0.2.5 install
108
+ npx --yes litgrok-ai@0.2.6 install
109
109
  ```
110
110
 
111
111
  Preview without writing files:
@@ -198,12 +198,12 @@ Review is proposal-only. `autoApply` is always false, transcript text is inert d
198
198
  From an installed project, use the package CLI directly when you want a replayable foreground command:
199
199
 
200
200
  ```bash
201
- npx --yes litgrok-ai@0.2.5 skill-loop review <session-id>
202
- npx --yes litgrok-ai@0.2.5 skill-loop list
203
- npx --yes litgrok-ai@0.2.5 skill-loop apply <proposal-id>
204
- npx --yes litgrok-ai@0.2.5 skill-loop reject <proposal-id>
205
- npx --yes litgrok-ai@0.2.5 skill-loop rollback <ledger-id>
206
- npx --yes litgrok-ai@0.2.5 skill-loop curator
201
+ npx --yes litgrok-ai@0.2.6 skill-loop review <session-id>
202
+ npx --yes litgrok-ai@0.2.6 skill-loop list
203
+ npx --yes litgrok-ai@0.2.6 skill-loop apply <proposal-id>
204
+ npx --yes litgrok-ai@0.2.6 skill-loop reject <proposal-id>
205
+ npx --yes litgrok-ai@0.2.6 skill-loop rollback <ledger-id>
206
+ npx --yes litgrok-ai@0.2.6 skill-loop curator
207
207
  ```
208
208
 
209
209
  `apply` prints the ledger id needed for rollback. The deterministic curator is also user-invoked: by default it runs at most once every seven days, requires at least two idle hours, marks eligible unpinned agent-owned skills stale after 30 idle days, and archives them outside discovery after 90 idle days. It makes a full backup before a transition and never deletes a skill package; `--force` bypasses only the seven-day run interval for an explicit test or maintenance invocation.
package/README_ko-KR.md CHANGED
@@ -22,7 +22,7 @@
22
22
  </p>
23
23
  <p align="center">
24
24
  <img src="https://img.shields.io/badge/npm-litgrok--ai-cb3837" alt="npm: litgrok-ai" />
25
- <img src="https://img.shields.io/badge/version-0.2.5-2ea44f" alt="version 0.2.5" />
25
+ <img src="https://img.shields.io/badge/version-0.2.6-2ea44f" alt="version 0.2.6" />
26
26
  <img src="https://img.shields.io/badge/Grok%20Build-skills%20%2B%20rules-111827" alt="Grok Build skills and project rules" />
27
27
  <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" />
28
28
  </p>
@@ -105,7 +105,7 @@ npx --yes litgrok-ai@latest install
105
105
  재현 가능한 설치는 버전을 고정합니다.
106
106
 
107
107
  ```bash
108
- npx --yes litgrok-ai@0.2.5 install
108
+ npx --yes litgrok-ai@0.2.6 install
109
109
  ```
110
110
 
111
111
  파일을 쓰지 않고 미리보기:
@@ -198,12 +198,12 @@ Review는 proposal만 만듭니다. `autoApply`는 항상 false이고 transcript
198
198
  설치된 project에서는 다음 package CLI를 foreground command로 실행할 수 있습니다.
199
199
 
200
200
  ```bash
201
- npx --yes litgrok-ai@0.2.5 skill-loop review <session-id>
202
- npx --yes litgrok-ai@0.2.5 skill-loop list
203
- npx --yes litgrok-ai@0.2.5 skill-loop apply <proposal-id>
204
- npx --yes litgrok-ai@0.2.5 skill-loop reject <proposal-id>
205
- npx --yes litgrok-ai@0.2.5 skill-loop rollback <ledger-id>
206
- npx --yes litgrok-ai@0.2.5 skill-loop curator
201
+ npx --yes litgrok-ai@0.2.6 skill-loop review <session-id>
202
+ npx --yes litgrok-ai@0.2.6 skill-loop list
203
+ npx --yes litgrok-ai@0.2.6 skill-loop apply <proposal-id>
204
+ npx --yes litgrok-ai@0.2.6 skill-loop reject <proposal-id>
205
+ npx --yes litgrok-ai@0.2.6 skill-loop rollback <ledger-id>
206
+ npx --yes litgrok-ai@0.2.6 skill-loop curator
207
207
  ```
208
208
 
209
209
  `apply`는 rollback에 필요한 ledger id를 출력합니다. Deterministic curator도 user-invoked입니다. 기본적으로 7일에 한 번만 실행되고, 마지막 activity 뒤 최소 2시간이 지나야 하며, pin되지 않은 agent-owned skill을 30일 idle 뒤 stale로 표시하고 90일 idle 뒤 discovery 밖으로 archive합니다. Transition 전에는 전체 backup을 만들고 skill package를 삭제하지 않습니다. `--force`는 명시적인 test 또는 maintenance 실행에서 7일 interval만 건너뜁니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litgrok-ai",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Grok Build skills, project rules, and hooks installer.",
5
5
  "type": "module",
6
6
  "bin": {
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litgrok",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Grok Build skills, project rules, and bounded lifecycle hooks for evidence-first lit work.",
5
5
  "author": {
6
6
  "name": "LitGrok contributors"