@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.
- package/.grok/hooks/auto-handoff-gate.mjs +3 -17
- package/.grok/hooks/auto-handoff.mjs +66 -7
- package/.grok/hooks/deliverable-hedge-guard.mjs +7 -6
- package/.grok/hooks/lit-status-line.mjs +294 -30
- package/.grok/hooks/plan-gate.mjs +2 -10
- package/.grok/hooks/record-passive-event.mjs +174 -28
- package/.grok/hooks/session-start.mjs +8 -5
- package/.grok/rules/00-litgrok.md +1 -1
- package/.grok/skills/lit-pptx/package-lock.json +167 -119
- package/.grok/skills/lit-pptx/package.json +1 -1
- package/.grok/skills/lit-pptx/scripts/deps.cjs +1 -1
- package/.grok/skills/lit-typographic-motion/SKILL.md +2 -2
- package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +2 -2
- package/.grok/skills/lit-typographic-motion/fonts/pins.json +1 -1
- package/.grok/skills/lit-typographic-motion/package.json +1 -1
- package/.grok/skills/lit-typographic-motion/references/complete-contract.md +1 -1
- package/.grok/skills/lit-typographic-motion/references/runtime.md +12 -8
- package/.grok/skills/lit-typographic-motion/references/scene-contract.md +1 -1
- package/.grok/skills/lit-typographic-motion/requirements-audio.txt +1 -1
- package/.grok/skills/lit-typographic-motion/scripts/lib/cache.mjs +21 -3
- package/.grok/skills/lit-typographic-motion/scripts/motion.mjs +1 -1
- package/.grok/skills/lit-typographic-motion/scripts/prewarm.mjs +3 -3
- package/.grok/skills/litgrok/SKILL.md +2 -2
- package/.grok/skills/readme-studio/templates/hyperframes/package-lock.json +169 -121
- package/.grok/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +15 -13
- package/README_ko-KR.md +15 -13
- package/bin/litgrok.mjs +76 -2
- package/bin/status-line-config.mjs +79 -9
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +15 -5
- package/docs/reference_ko-KR.md +17 -7
- package/package.json +19 -2
- 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
|
-
|
|
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
|
-
|
|
106
|
-
if (
|
|
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(
|
|
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
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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 (
|
|
14
|
-
event = JSON.parse(
|
|
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
|
|
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
|
|