@litfamily/litgrok 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.grok/hooks/auto-handoff-gate.mjs +3 -17
  2. package/.grok/hooks/auto-handoff.mjs +66 -7
  3. package/.grok/hooks/deliverable-hedge-guard.mjs +7 -6
  4. package/.grok/hooks/lit-status-line.mjs +294 -30
  5. package/.grok/hooks/plan-gate.mjs +2 -10
  6. package/.grok/hooks/record-passive-event.mjs +174 -28
  7. package/.grok/hooks/session-start.mjs +8 -5
  8. package/.grok/rules/00-litgrok.md +1 -1
  9. package/.grok/skills/lit-pptx/package-lock.json +167 -119
  10. package/.grok/skills/lit-pptx/package.json +1 -1
  11. package/.grok/skills/lit-pptx/scripts/deps.cjs +1 -1
  12. package/.grok/skills/lit-typographic-motion/SKILL.md +2 -2
  13. package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +2 -2
  14. package/.grok/skills/lit-typographic-motion/fonts/pins.json +1 -1
  15. package/.grok/skills/lit-typographic-motion/package.json +1 -1
  16. package/.grok/skills/lit-typographic-motion/references/complete-contract.md +1 -1
  17. package/.grok/skills/lit-typographic-motion/references/runtime.md +12 -8
  18. package/.grok/skills/lit-typographic-motion/references/scene-contract.md +1 -1
  19. package/.grok/skills/lit-typographic-motion/requirements-audio.txt +1 -1
  20. package/.grok/skills/lit-typographic-motion/scripts/lib/cache.mjs +21 -3
  21. package/.grok/skills/lit-typographic-motion/scripts/motion.mjs +1 -1
  22. package/.grok/skills/lit-typographic-motion/scripts/prewarm.mjs +3 -3
  23. package/.grok/skills/litgrok/SKILL.md +2 -2
  24. package/.grok/skills/readme-studio/templates/hyperframes/package-lock.json +169 -121
  25. package/.grok/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  26. package/.grok-plugin/plugin.json +1 -1
  27. package/CHANGELOG.md +18 -0
  28. package/README.md +15 -13
  29. package/README_ko-KR.md +15 -13
  30. package/bin/litgrok.mjs +76 -2
  31. package/bin/status-line-config.mjs +79 -9
  32. package/docs/assets/readme/badge-version.svg +1 -1
  33. package/docs/reference.md +15 -5
  34. package/docs/reference_ko-KR.md +17 -7
  35. package/package.json +19 -2
  36. package/plugin.json +1 -1
@@ -5,18 +5,28 @@ import {
5
5
  existsSync,
6
6
  fstatSync,
7
7
  fsyncSync,
8
+ linkSync,
8
9
  lstatSync,
9
10
  mkdirSync,
10
11
  openSync,
12
+ readdirSync,
11
13
  readFileSync,
14
+ readSync,
12
15
  realpathSync,
16
+ unlinkSync,
17
+ writeFileSync,
13
18
  writeSync,
14
19
  } from 'node:fs';
15
- import { isAbsolute, join } from 'node:path';
20
+ import { basename, dirname, isAbsolute, join } from 'node:path';
16
21
  import { writeHudRecords } from './litgrok-hud-state.mjs';
17
22
 
18
23
  const MAX_EVENT_BYTES = 1024 * 1024;
24
+ // A ledger past this size moves aside as a numbered segment and a fresh one starts.
19
25
  const MAX_LEDGER_BYTES = 8 * 1024 * 1024;
26
+ // Identity is checked on the first record only, so appending never rereads the whole ledger.
27
+ const FIRST_RECORD_PROBE_BYTES = 64 * 1024;
28
+ const KEPT_SEGMENTS = 2;
29
+ const ROTATION_LOCK_STALE_MS = 30 * 1000;
20
30
  const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
21
31
  const RECORD_SCHEMA = 'litgrok.hook-event/v1';
22
32
  const HUD_DISCIPLINE_PATTERN = /(?:^|[\s/])(lit-scientific-visualization|lit-handoff|autoconference|autoresearch|lit-plan|litwork|lit)(?=$|[^\w-])/iu;
@@ -100,13 +110,16 @@ function normalizedEventName(value) {
100
110
  }
101
111
 
102
112
  async function readEvent() {
103
- let input = '';
113
+ // Decode once: a multi-byte character can straddle two pipe chunks.
114
+ const chunks = [];
115
+ let bytes = 0;
104
116
  for await (const chunk of process.stdin) {
105
- input += chunk;
106
- if (Buffer.byteLength(input) > MAX_EVENT_BYTES) throw new RecorderError('EVENT_TOO_LARGE');
117
+ bytes += chunk.length;
118
+ if (bytes > MAX_EVENT_BYTES) throw new RecorderError('EVENT_TOO_LARGE');
119
+ chunks.push(chunk);
107
120
  }
108
121
  try {
109
- const event = JSON.parse(input);
122
+ const event = JSON.parse(Buffer.concat(chunks).toString('utf8'));
110
123
  if (!event || typeof event !== 'object' || Array.isArray(event)) throw new Error();
111
124
  return event;
112
125
  } catch {
@@ -169,11 +182,22 @@ function ensureDirectory(path) {
169
182
  if (!status.isDirectory() || status.isSymbolicLink()) throw new RecorderError('LEDGER_DIRECTORY_UNSAFE');
170
183
  }
171
184
 
185
+ // .grok/litgrok/ holds only runtime state (session ids, cwd, tool names, the auto-handoff choice), never
186
+ // the payload a project commits, so it ignores itself in Git. An existing file is the user's and is kept.
187
+ export function ignoreStateDirectory(productDirectory) {
188
+ try {
189
+ writeFileSync(join(productDirectory, '.gitignore'), '*\n', { flag: 'wx', mode: 0o644 });
190
+ } catch (error) {
191
+ if (error?.code !== 'EEXIST') throw error;
192
+ }
193
+ }
194
+
172
195
  function ledgerPath(realWorkspaceRoot, sessionId) {
173
196
  const grokDirectory = join(realWorkspaceRoot, '.grok');
174
197
  const productDirectory = join(grokDirectory, 'litgrok');
175
198
  const ledgerDirectory = join(productDirectory, 'session-ledger');
176
199
  for (const path of [grokDirectory, productDirectory, ledgerDirectory]) ensureDirectory(path);
200
+ ignoreStateDirectory(productDirectory);
177
201
  const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
178
202
  return join(ledgerDirectory, `${sessionKey}.jsonl`);
179
203
  }
@@ -195,21 +219,132 @@ export function claimSessionIgnition(event) {
195
219
  }
196
220
  }
197
221
 
198
- function validateExistingLedger(path, sessionId) {
199
- if (!existsSync(path)) return;
200
- const status = lstatSync(path);
201
- if (!status.isFile() || status.isSymbolicLink() || status.size > MAX_LEDGER_BYTES) {
202
- throw new RecorderError('LEDGER_FILE_UNSAFE');
222
+ function readAt(descriptor, length, position) {
223
+ const buffer = Buffer.alloc(length);
224
+ const read = readSync(descriptor, buffer, 0, length, position);
225
+ return buffer.subarray(0, read);
226
+ }
227
+
228
+ function segmentPattern(path) {
229
+ return new RegExp(`^${basename(path, '.jsonl')}\\.(\\d+)\\.jsonl$`, 'u');
230
+ }
231
+
232
+ function segmentNumbers(path) {
233
+ const pattern = segmentPattern(path);
234
+ return readdirSync(dirname(path)).flatMap((name) => {
235
+ const match = pattern.exec(name);
236
+ return match ? [Number(match[1])] : [];
237
+ });
238
+ }
239
+
240
+ // Only one hook rotates at a time. The lock is an exclusive empty file; one left by a crashed hook is
241
+ // taken over once it is older than ROTATION_LOCK_STALE_MS.
242
+ function takeRotationLock(path) {
243
+ const lock = `${path}.rotate.lock`;
244
+ const noFollow = constants.O_NOFOLLOW ?? 0;
245
+ for (let attempt = 0; attempt < 2; attempt += 1) {
246
+ try {
247
+ closeSync(openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600));
248
+ return lock;
249
+ } catch (error) {
250
+ if (error?.code !== 'EEXIST') throw error;
251
+ const status = lstatSync(lock, { throwIfNoEntry: false });
252
+ if (!status || Date.now() - status.mtimeMs <= ROTATION_LOCK_STALE_MS) return null;
253
+ try {
254
+ unlinkSync(lock);
255
+ } catch (removeError) {
256
+ if (removeError?.code !== 'ENOENT') throw removeError;
257
+ }
258
+ }
203
259
  }
204
- const text = readFileSync(path, 'utf8');
205
- for (const line of text.split(/\r?\n/).filter(Boolean)) {
260
+ return null;
261
+ }
262
+
263
+ // Moves the measured ledger aside as the next numbered segment and keeps only the newest
264
+ // KEPT_SEGMENTS segments. Returns false when another hook holds the rotation lock; true when the
265
+ // ledger was rotated here or had already been rotated, so the caller reopens the live name.
266
+ function rotateLedger(path, measured) {
267
+ const lock = takeRotationLock(path);
268
+ if (lock === null) return false;
269
+ try {
270
+ const live = lstatSync(path, { throwIfNoEntry: false });
271
+ if (!live || live.ino !== measured.ino || live.dev !== measured.dev) return true;
272
+ let number = Math.max(0, ...segmentNumbers(path)) + 1;
273
+ for (;;) {
274
+ try {
275
+ linkSync(path, join(dirname(path), `${basename(path, '.jsonl')}.${number}.jsonl`));
276
+ break;
277
+ } catch (error) {
278
+ if (error?.code !== 'EEXIST') throw error;
279
+ number += 1;
280
+ }
281
+ }
282
+ unlinkSync(path);
283
+ for (const old of segmentNumbers(path).sort((left, right) => right - left).slice(KEPT_SEGMENTS)) {
284
+ try {
285
+ unlinkSync(join(dirname(path), `${basename(path, '.jsonl')}.${old}.jsonl`));
286
+ } catch (error) {
287
+ if (error?.code !== 'ENOENT') throw error;
288
+ }
289
+ }
290
+ return true;
291
+ } finally {
206
292
  try {
207
- const record = JSON.parse(line);
208
- if (record.schema !== RECORD_SCHEMA || record.sessionId !== sessionId) throw new Error();
209
- } catch {
210
- throw new RecorderError('LEDGER_IDENTITY_MISMATCH');
293
+ unlinkSync(lock);
294
+ } catch (error) {
295
+ if (error?.code !== 'ENOENT') throw error;
296
+ }
297
+ }
298
+ }
299
+
300
+ // Checks identity on the first record before anything else, so an oversized foreign file is refused,
301
+ // never rotated. Returns { prefix } with a newline when the last record was torn (it stays one skipped
302
+ // line instead of swallowing the new record), plus rotate: 'full' past the size cap or 'torn' when the
303
+ // only record never got its newline.
304
+ function checkOpenLedger(descriptor, sessionId) {
305
+ const status = fstatSync(descriptor);
306
+ if (!status.isFile()) throw new RecorderError('LEDGER_FILE_UNSAFE');
307
+ if (status.size === 0) return { prefix: '' };
308
+ const oversized = status.size > MAX_LEDGER_BYTES;
309
+ const refusal = oversized ? 'LEDGER_FILE_UNSAFE' : 'LEDGER_IDENTITY_MISMATCH';
310
+ const head = readAt(descriptor, Math.min(status.size, FIRST_RECORD_PROBE_BYTES), 0);
311
+ const end = head.indexOf(0x0a);
312
+ if (end < 0) {
313
+ if (status.size <= FIRST_RECORD_PROBE_BYTES) return { rotate: 'torn', prefix: '\n' };
314
+ throw new RecorderError(refusal);
315
+ }
316
+ try {
317
+ const record = JSON.parse(head.subarray(0, end).toString('utf8'));
318
+ if (record.schema !== RECORD_SCHEMA || record.sessionId !== sessionId) throw new Error();
319
+ } catch {
320
+ throw new RecorderError(refusal);
321
+ }
322
+ const prefix = readAt(descriptor, 1, status.size - 1)[0] === 0x0a ? '' : '\n';
323
+ return oversized ? { rotate: 'full', prefix } : { prefix };
324
+ }
325
+
326
+ // Records of one session, oldest first: the newest rotated segment and then the live ledger. Lines
327
+ // that do not parse (a torn write) or belong to another session are skipped.
328
+ export function readLedgerRecords(ledgerFile, sessionId) {
329
+ const files = [];
330
+ const numbers = existsSync(dirname(ledgerFile)) ? segmentNumbers(ledgerFile) : [];
331
+ if (numbers.length > 0) files.push(join(dirname(ledgerFile), `${basename(ledgerFile, '.jsonl')}.${Math.max(...numbers)}.jsonl`));
332
+ files.push(ledgerFile);
333
+ const records = [];
334
+ for (const file of files) {
335
+ const status = lstatSync(file, { throwIfNoEntry: false });
336
+ if (!status?.isFile()) continue;
337
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
338
+ if (!line) continue;
339
+ try {
340
+ const record = JSON.parse(line);
341
+ if (record?.sessionId === sessionId) records.push(record);
342
+ } catch {
343
+ // A torn line is skipped.
344
+ }
211
345
  }
212
346
  }
347
+ return records;
213
348
  }
214
349
 
215
350
  function toolRecord(event, expectedEvent) {
@@ -222,21 +357,32 @@ function toolRecord(event, expectedEvent) {
222
357
  };
223
358
  }
224
359
 
225
- function appendRecord(path, record) {
226
- validateExistingLedger(path, record.sessionId);
360
+ function openLedger(path) {
227
361
  const noFollow = constants.O_NOFOLLOW ?? 0;
228
- const descriptor = openSync(
229
- path,
230
- constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | noFollow,
231
- 0o600,
232
- );
233
362
  try {
234
- if (!fstatSync(descriptor).isFile()) throw new RecorderError('LEDGER_FILE_UNSAFE');
235
- writeSync(descriptor, `${JSON.stringify(record)}\n`);
236
- fsyncSync(descriptor);
237
- } finally {
238
- closeSync(descriptor);
363
+ return openSync(path, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | noFollow, 0o600);
364
+ } catch (error) {
365
+ if (error?.code === 'ELOOP' || error?.code === 'EISDIR') throw new RecorderError('LEDGER_FILE_UNSAFE');
366
+ throw error;
367
+ }
368
+ }
369
+
370
+ function appendRecord(path, record) {
371
+ for (let attempt = 0; attempt < 3; attempt += 1) {
372
+ const descriptor = openLedger(path);
373
+ try {
374
+ const check = checkOpenLedger(descriptor, record.sessionId);
375
+ if (check.rotate && rotateLedger(path, fstatSync(descriptor))) continue;
376
+ // Another hook is moving a torn ledger aside; reopen. A full one can take this record meanwhile.
377
+ if (check.rotate === 'torn') continue;
378
+ writeSync(descriptor, `${check.prefix}${JSON.stringify(record)}\n`);
379
+ fsyncSync(descriptor);
380
+ return;
381
+ } finally {
382
+ closeSync(descriptor);
383
+ }
239
384
  }
385
+ throw new RecorderError('LEDGER_FILE_UNSAFE');
240
386
  }
241
387
 
242
388
  export function appendSessionRecord(ledgerFile, baseRecord, fields) {
@@ -3,15 +3,18 @@
3
3
  import { renderMark } from './lit-mark.mjs';
4
4
  import { claimSessionIgnition } from './record-passive-event.mjs';
5
5
 
6
- let input = '';
6
+ // Decode once: a multi-byte character can straddle two pipe chunks.
7
+ const chunks = [];
8
+ let bytes = 0;
7
9
  for await (const chunk of process.stdin) {
8
- input += chunk;
9
- if (Buffer.byteLength(input) > 1024 * 1024) break;
10
+ bytes += chunk.length;
11
+ chunks.push(chunk);
12
+ if (bytes > 1024 * 1024) break;
10
13
  }
11
14
  let event;
12
15
  try {
13
- if (Buffer.byteLength(input) > 1024 * 1024) throw new Error('EVENT_TOO_LARGE');
14
- event = JSON.parse(input);
16
+ if (bytes > 1024 * 1024) throw new Error('EVENT_TOO_LARGE');
17
+ event = JSON.parse(Buffer.concat(chunks).toString('utf8'));
15
18
  if (claimSessionIgnition(event)) process.stdout.write(`${renderMark({ args: process.argv.slice(2) }).join('\n')}\n`);
16
19
  } catch (error) {
17
20
  process.stderr.write(`LitGrok session-start: ${error.code || 'EVENT_INVALID'}\n`);
@@ -117,7 +117,7 @@ Film context: this is a film request. Load `lit-typographic-motion` from `.grok/
117
117
  Film commands, run from the skill root:
118
118
 
119
119
  - `node scripts/motion.mjs` with `make`, `stage`, `sound`, `look`, `gate`, `verify` (each takes `--out <dir>`);
120
- - pre-warm outside the session: `litgrok-ai motion-runtime install`.
120
+ - pre-warm outside the session: `npm exec --yes --package @litfamily/litgrok@<installed version> -- litgrok motion-runtime install`.
121
121
 
122
122
  ## Office output selection
123
123