@litfamily/litgrok 1.0.18 → 1.0.19
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/lit-status-line.mjs +194 -15
- package/.grok/hooks/plan-progress.mjs +36 -0
- package/.grok/hooks/post-tool-use-failure.mjs +2 -1
- package/.grok/hooks/post-tool-use.json +0 -1
- package/.grok/hooks/post-tool-use.mjs +2 -2
- package/.grok/hooks/pre-tool-use.json +15 -0
- package/.grok/hooks/pre-tool-use.mjs +7 -0
- package/.grok/hooks/record-passive-event.mjs +32 -1
- package/.grok/hooks/stop.mjs +19 -1
- package/.grok/hooks/workflow-stage.mjs +189 -0
- package/.grok/skills/litgrok/SKILL.md +4 -2
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +9 -9
- package/README_ko-KR.md +9 -9
- package/bin/litgrok.mjs +26 -8
- package/bin/status-line-config.mjs +71 -40
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/privacy.md +1 -1
- package/docs/reference.md +6 -2
- package/docs/reference_ko-KR.md +6 -2
- package/hooks/hooks.json +9 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -6,6 +6,7 @@ import { dirname, isAbsolute, join } from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { resolveAutoHandoff } from './auto-handoff.mjs';
|
|
8
8
|
import { readHudRecordForCwd, writeContextRecord } from './litgrok-hud-state.mjs';
|
|
9
|
+
import { STAGES, readWorkflowState } from './workflow-stage.mjs';
|
|
9
10
|
|
|
10
11
|
const MAX_INPUT_BYTES = 64 * 1024;
|
|
11
12
|
const GRADIENT_STOPS = [[255, 99, 55], [255, 45, 149], [0, 229, 255]];
|
|
@@ -18,6 +19,8 @@ const LEDGER_TAIL_BYTES = 128 * 1024;
|
|
|
18
19
|
const COMPLETE_SHOWN_MS = 20 * 1000;
|
|
19
20
|
const HANDOFF_NEAR_POINTS = 10;
|
|
20
21
|
const CLOCK_SKEW_MS = 5 * 1000;
|
|
22
|
+
// A turn the ledger has not heard from for this long is no longer shown as running.
|
|
23
|
+
const LEDGER_TURN_QUIET_MS = 10 * 60 * 1000;
|
|
21
24
|
const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
|
|
22
25
|
// [background, foreground] of each pill.
|
|
23
26
|
const PILLS = {
|
|
@@ -33,6 +36,16 @@ const GLYPHS = {
|
|
|
33
36
|
};
|
|
34
37
|
const ANSI_16 = [[0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
|
|
35
38
|
[127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]];
|
|
39
|
+
// The workflow row's rail runs orange to pink to a softer cyan than the label.
|
|
40
|
+
const RAIL_STOPS = [[255, 99, 55], [255, 45, 149], [95, 215, 255]];
|
|
41
|
+
// The moving head is the label's cyan: a white cell would vanish on a light terminal.
|
|
42
|
+
const RAIL = { ink: [11, 15, 20], faint: [72, 79, 88], head: [0, 229, 255], red: [255, 95, 95] };
|
|
43
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
44
|
+
// Grok's own tool names, shortened to the words people know them by. Any other name shows as sent.
|
|
45
|
+
const TOOL_LABELS = {
|
|
46
|
+
run_terminal_command: 'Bash', read_file: 'Read', search_replace: 'Edit', write: 'Write', grep: 'Grep', list_dir: 'List',
|
|
47
|
+
todo_write: 'Todo', web_search: 'Search', web_fetch: 'Fetch', image_edit: 'Image', monitor: 'Monitor', ask_user_question: 'Ask',
|
|
48
|
+
};
|
|
36
49
|
|
|
37
50
|
async function readInput() {
|
|
38
51
|
// Decode once: a multi-byte character can straddle two pipe chunks.
|
|
@@ -153,10 +166,10 @@ function sgr(rgb, depth, background = false) {
|
|
|
153
166
|
return `\u001b[${background ? 48 : 38};2;${rgb.join(';')}m`;
|
|
154
167
|
}
|
|
155
168
|
|
|
156
|
-
function gradientColor(position) {
|
|
169
|
+
function gradientColor(position, stops = GRADIENT_STOPS) {
|
|
157
170
|
const stop = position <= 0.5 ? 0 : 1;
|
|
158
171
|
const fraction = position <= 0.5 ? position * 2 : (position - 0.5) * 2;
|
|
159
|
-
return
|
|
172
|
+
return stops[stop].map((value, index) => Math.round(value + (stops[stop + 1][index] - value) * fraction));
|
|
160
173
|
}
|
|
161
174
|
|
|
162
175
|
// Bold is set once and each letter only changes the colour, so the longest label stays far below the cut.
|
|
@@ -247,6 +260,25 @@ function readLedgerTail(status) {
|
|
|
247
260
|
return null;
|
|
248
261
|
}
|
|
249
262
|
|
|
263
|
+
// The hooks keep the session's workflow snapshot beside its ledger, in the same folders.
|
|
264
|
+
function readWorkflow(status) {
|
|
265
|
+
const sessionId = status?.session_id;
|
|
266
|
+
if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 512) return null;
|
|
267
|
+
const name = `${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}.workflow.json`;
|
|
268
|
+
for (const directory of statusDirs(status)) {
|
|
269
|
+
if (typeof directory !== 'string' || !isAbsolute(directory)) continue;
|
|
270
|
+
let root;
|
|
271
|
+
try {
|
|
272
|
+
root = realpathSync(directory);
|
|
273
|
+
} catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const state = readWorkflowState(join(root, '.grok', 'litgrok', 'session-ledger', name), sessionId);
|
|
277
|
+
if (state) return state;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
250
282
|
// What the ledger says about the current turn: it starts at the prompt Grok names, or the latest one.
|
|
251
283
|
// A turn whose start is not in the tail is unknown, so nothing is counted.
|
|
252
284
|
function turnActivity(records, promptId) {
|
|
@@ -260,10 +292,20 @@ function turnActivity(records, promptId) {
|
|
|
260
292
|
const held = (record) => record.signal === 'plan-gate-block' || record.signal === 'auto-handoff-directive'
|
|
261
293
|
|| (record.signal === 'auto-handoff-reload' && record.outcome === 'loaded');
|
|
262
294
|
const last = turn.at(-1);
|
|
263
|
-
const
|
|
295
|
+
const failed = last?.event === 'StopFailure';
|
|
296
|
+
const ended = (last?.event === 'Stop' && !held(last)) || failed;
|
|
297
|
+
// A call is in flight while its PreToolUse is the turn's newest tool record. Parallel calls can hide
|
|
298
|
+
// one that is still running; that shows less, never a finished call as running.
|
|
299
|
+
const lastTool = turn.filter((record) => record.event === 'PreToolUse' || TOOL_EVENTS.has(record.event)).at(-1);
|
|
300
|
+
const liveStart = Date.parse(lastTool?.recordedAt);
|
|
264
301
|
return {
|
|
302
|
+
live: !ended && lastTool?.event === 'PreToolUse' && typeof lastTool.tool?.name === 'string' && Number.isFinite(liveStart)
|
|
303
|
+
? { name: lastTool.tool.name, startedAt: liveStart }
|
|
304
|
+
: null,
|
|
265
305
|
startedAt: Date.parse(records[start].recordedAt),
|
|
306
|
+
lastAt: Date.parse((last ?? records[start]).recordedAt),
|
|
266
307
|
endedAt: ended ? Date.parse(last.recordedAt) : null,
|
|
308
|
+
failed,
|
|
267
309
|
tools: turn.filter((record) => TOOL_EVENTS.has(record.event)).length,
|
|
268
310
|
agents: Math.max(0, count('SubagentStart') - count('SubagentStop')),
|
|
269
311
|
planGate: !ended && turn.some((record) => record.signal === 'plan-gate-block'),
|
|
@@ -293,17 +335,30 @@ function warningText(status, activity) {
|
|
|
293
335
|
return null;
|
|
294
336
|
}
|
|
295
337
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
338
|
+
// Whether a turn runs and since when. Grok's turn field decides when Grok sends one. Grok Build 1.0.13
|
|
339
|
+
// sends none and, during a turn, re-runs the command with the payload of the last state change; then a
|
|
340
|
+
// turn runs while the ledger's newest prompt has nothing that ended it and the ledger heard from it
|
|
341
|
+
// recently. A cancel that records no stop shows as running until the ledger has been quiet that long.
|
|
342
|
+
function turnState(status, activity, now) {
|
|
343
|
+
if (status !== null && typeof status === 'object' && Object.hasOwn(status, 'turn')) {
|
|
344
|
+
const started = status.turn?.started_at_ms;
|
|
345
|
+
const running = typeof started === 'number' && Number.isFinite(started) && started > 0;
|
|
346
|
+
return { running, startedAt: running && started <= now + CLOCK_SKEW_MS ? started : null };
|
|
347
|
+
}
|
|
348
|
+
const running = activity !== null && activity.endedAt === null && Number.isFinite(activity.startedAt)
|
|
349
|
+
&& activity.startedAt <= now + CLOCK_SKEW_MS && now - activity.lastAt <= LEDGER_TURN_QUIET_MS;
|
|
350
|
+
return { running, startedAt: running ? activity.startedAt : null };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function bandState(status, now, activity, turn) {
|
|
354
|
+
const running = turn.running;
|
|
300
355
|
const warning = warningText(status, activity);
|
|
301
356
|
let timer = null;
|
|
302
357
|
let state = null;
|
|
303
358
|
if (running) {
|
|
304
359
|
state = 'running';
|
|
305
|
-
if (
|
|
306
|
-
} else if (activity?.endedAt && now - activity.endedAt <= COMPLETE_SHOWN_MS && activity.endedAt <= now + CLOCK_SKEW_MS) {
|
|
360
|
+
if (turn.startedAt !== null) timer = duration(Math.max(0, now - turn.startedAt));
|
|
361
|
+
} else if (activity?.endedAt && !activity.failed && now - activity.endedAt <= COMPLETE_SHOWN_MS && activity.endedAt <= now + CLOCK_SKEW_MS) {
|
|
307
362
|
state = 'complete';
|
|
308
363
|
timer = duration(Math.max(0, activity.endedAt - activity.startedAt));
|
|
309
364
|
}
|
|
@@ -360,6 +415,109 @@ function formatBand(discipline, band, depth, nerd, maxCells) {
|
|
|
360
415
|
return renderBand(discipline, band, depth, nerd, { ...last, skillCells: Math.max(1, room) }).painted;
|
|
361
416
|
}
|
|
362
417
|
|
|
418
|
+
function clock(ms) {
|
|
419
|
+
const seconds = Math.floor(Math.max(0, ms) / 1000);
|
|
420
|
+
const minutes = Math.floor(seconds / 60);
|
|
421
|
+
const rest = String(seconds % 60).padStart(2, '0');
|
|
422
|
+
return minutes < 60 ? `${minutes}:${rest}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${rest}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The current stage's pill: its name, the plan's checked tasks while working, and a tick once the
|
|
426
|
+
// stage's turn has ended (for work, only once every task is checked).
|
|
427
|
+
function stageLabel(workflow) {
|
|
428
|
+
if (workflow.stage === 'done') return 'DONE ✓';
|
|
429
|
+
const plan = workflow.plan;
|
|
430
|
+
const counter = workflow.stage === 'work' && plan?.total > 0 ? ` ${plan.done}/${plan.total}` : '';
|
|
431
|
+
const finished = workflow.ended && (workflow.stage !== 'work' || (plan?.total > 0 && plan.done === plan.total));
|
|
432
|
+
return `${workflow.stage.toUpperCase()}${counter}${finished ? ' ✓' : ''}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The breadcrumb as { text, role, position, rail } pieces. Stage k of n sits at k/(n-1) along the
|
|
436
|
+
// orange-pink-cyan gradient and each rail cell between two stages takes its own step in between.
|
|
437
|
+
// Rails up to the current stage are lit. A stage before it shows a tick when the session went through
|
|
438
|
+
// it; interview shows only when it happened.
|
|
439
|
+
function breadcrumbPieces(workflow, { rail, compact }) {
|
|
440
|
+
const shown = STAGES.filter((stage) => stage !== 'interview' || workflow.visited.includes('interview') || workflow.stage === 'interview');
|
|
441
|
+
const current = shown.indexOf(workflow.stage);
|
|
442
|
+
const last = Math.max(1, shown.length - 1);
|
|
443
|
+
const pieces = [];
|
|
444
|
+
shown.forEach((stage, index) => {
|
|
445
|
+
if (index > 0) {
|
|
446
|
+
if (!compact) pieces.push({ text: ' ', role: 'space' });
|
|
447
|
+
for (let cell = 0; cell < rail; cell += 1) {
|
|
448
|
+
pieces.push({ text: index <= current ? '━' : '─', role: index <= current ? 'lit' : 'dim', position: (index - 1 + (cell + 1) / (rail + 1)) / last, rail: [index, cell] });
|
|
449
|
+
}
|
|
450
|
+
if (!compact) pieces.push({ text: ' ', role: 'space' });
|
|
451
|
+
}
|
|
452
|
+
const position = index / last;
|
|
453
|
+
if (index === current) pieces.push({ text: stageLabel(workflow), role: 'pill', position });
|
|
454
|
+
else if (index > current) pieces.push({ text: compact ? '·' : `${stage} ·`, role: 'future' });
|
|
455
|
+
else if (workflow.visited.includes(stage)) pieces.push({ text: compact ? '✓' : `${stage} ✓`, role: 'past', position });
|
|
456
|
+
else pieces.push({ text: compact ? '-' : stage, role: 'skipped' });
|
|
457
|
+
});
|
|
458
|
+
return { pieces, current };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// While a turn runs, one bright cell rides the rail into the current stage and moves on at each
|
|
462
|
+
// redraw (Grok redraws about every 300 ms during a turn).
|
|
463
|
+
function renderBreadcrumb(workflow, tier, depth, running, now) {
|
|
464
|
+
const { pieces, current } = breadcrumbPieces(workflow, tier);
|
|
465
|
+
const plain = pieces.map(({ text, role }) => (role === 'pill' ? `[${text}]` : text)).join('');
|
|
466
|
+
if (!depth) return { plain, painted: plain };
|
|
467
|
+
const head = running ? [current > 0 ? current : 1, Math.floor(now / 300) % tier.rail] : null;
|
|
468
|
+
const painted = pieces.map(({ text, role, position, rail }) => {
|
|
469
|
+
if (role === 'space') return text;
|
|
470
|
+
if (role === 'future' || role === 'skipped') return `${sgr(TEXT.dim, depth)}${text}`;
|
|
471
|
+
const rgb = gradientColor(position, RAIL_STOPS);
|
|
472
|
+
if (role === 'pill') return `${sgr(rgb, depth, true)}${sgr(RAIL.ink, depth)}\u001b[1m ${text} \u001b[0m`;
|
|
473
|
+
if (role === 'past') return `${sgr(rgb, depth)}${text}`;
|
|
474
|
+
if (head && rail[0] === head[0] && rail[1] === head[1]) return `${sgr(RAIL.head, depth)}${text}`;
|
|
475
|
+
return `${sgr(role === 'lit' ? rgb : RAIL.faint, depth)}${text}`;
|
|
476
|
+
}).join('');
|
|
477
|
+
return { plain, painted: `${painted}\u001b[0m` };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function toolLabel(name) {
|
|
481
|
+
return Object.hasOwn(TOOL_LABELS, name) ? TOOL_LABELS[name] : clipCells(plainText(name, 'tool'), 20);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function renderWorkflow(workflow, live, running, depth, now, tier) {
|
|
485
|
+
const paint = (text, rgb) => (depth ? `${sgr(rgb, depth)}${text}\u001b[0m` : text);
|
|
486
|
+
const parts = [];
|
|
487
|
+
if (workflow?.stage) parts.push(renderBreadcrumb(workflow, tier, depth, running, now));
|
|
488
|
+
if (live && tier.live) {
|
|
489
|
+
const spinner = SPINNER[Math.floor(now / 100) % SPINNER.length];
|
|
490
|
+
const text = tier.live === 'full' ? `${spinner} ${toolLabel(live.name)} · ${clock(now - live.startedAt)}` : `${spinner} ${clock(now - live.startedAt)}`;
|
|
491
|
+
parts.push({ plain: text, painted: paint(text, TEXT.amber) });
|
|
492
|
+
}
|
|
493
|
+
if (workflow?.test && tier.tests) {
|
|
494
|
+
const pass = workflow.test.verdict === 'pass';
|
|
495
|
+
const text = `tests ${pass ? '✓' : '✗'}${workflow.test.count === undefined ? '' : ` ${workflow.test.count}`}`;
|
|
496
|
+
parts.push({ plain: text, painted: paint(text, pass ? TEXT.green : RAIL.red) });
|
|
497
|
+
}
|
|
498
|
+
return { plain: parts.map((part) => part.plain).join(' '), painted: parts.map((part) => part.painted).join(' ') };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Narrow rows shorten the rails, then drop the test badge, the tool's name, the tool and the stage
|
|
502
|
+
// names, and last of all keep only the current stage.
|
|
503
|
+
function formatWorkflow(workflow, live, running, depth, now, maxCells) {
|
|
504
|
+
const tiers = [
|
|
505
|
+
{ rail: 4, live: 'full', tests: true },
|
|
506
|
+
{ rail: 2, live: 'full', tests: true },
|
|
507
|
+
{ rail: 2, live: 'full', tests: false },
|
|
508
|
+
{ rail: 2, live: 'short', tests: false },
|
|
509
|
+
{ rail: 1, live: false, tests: false },
|
|
510
|
+
{ rail: 1, compact: true, live: false, tests: false },
|
|
511
|
+
];
|
|
512
|
+
for (const tier of tiers) {
|
|
513
|
+
const row = renderWorkflow(workflow, live, running, depth, now, tier);
|
|
514
|
+
if (width(row.plain) <= maxCells && row.painted.length <= MAX_LINE_CHARACTERS) return row.painted || null;
|
|
515
|
+
}
|
|
516
|
+
if (!workflow?.stage) return null;
|
|
517
|
+
const label = clipCells(stageLabel(workflow), Math.max(1, maxCells - 2));
|
|
518
|
+
return depth ? `${sgr(RAIL_STOPS[1], depth, true)}${sgr(RAIL.ink, depth)}\u001b[1m ${label} \u001b[0m` : `[${label}]`;
|
|
519
|
+
}
|
|
520
|
+
|
|
363
521
|
const input = await readInput();
|
|
364
522
|
try {
|
|
365
523
|
recordContextForHandoff(input);
|
|
@@ -388,14 +546,35 @@ const row = formatRow({
|
|
|
388
546
|
version: installedVersion(),
|
|
389
547
|
}, depth, maxCells);
|
|
390
548
|
// The ribbon is a second line, shown only while a skill is active and a turn runs, has just ended or
|
|
391
|
-
// needs attention.
|
|
549
|
+
// needs attention. The workflow row follows it once the session has a workflow stage, or while a skill
|
|
550
|
+
// is active and there is a tool in flight or a test verdict to show. LITGROK_HUD_LINES=1 keeps the
|
|
551
|
+
// compact row alone.
|
|
392
552
|
let band = null;
|
|
393
|
-
|
|
553
|
+
let workflowLine = null;
|
|
554
|
+
if (process.env.LITGROK_HUD_LINES !== '1') {
|
|
555
|
+
const now = Date.now();
|
|
556
|
+
let activity = null;
|
|
557
|
+
try {
|
|
558
|
+
activity = turnActivity(readLedgerTail(input), input?.prompt_id);
|
|
559
|
+
} catch {
|
|
560
|
+
activity = null;
|
|
561
|
+
}
|
|
562
|
+
const turn = turnState(input, activity, now);
|
|
563
|
+
const { running } = turn;
|
|
564
|
+
if (discipline) {
|
|
565
|
+
try {
|
|
566
|
+
const state = bandState(input, now, activity, turn);
|
|
567
|
+
if (state) band = formatBand(discipline, state, depth, process.env.LITGROK_HUD_GLYPHS === 'nerd', maxCells);
|
|
568
|
+
} catch {
|
|
569
|
+
band = null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
394
572
|
try {
|
|
395
|
-
const
|
|
396
|
-
|
|
573
|
+
const workflow = readWorkflow(input);
|
|
574
|
+
const live = running ? activity?.live ?? null : null;
|
|
575
|
+
if (workflow?.stage || (discipline && (live || workflow?.test))) workflowLine = formatWorkflow(workflow, live, running, depth, now, maxCells);
|
|
397
576
|
} catch {
|
|
398
|
-
|
|
577
|
+
workflowLine = null;
|
|
399
578
|
}
|
|
400
579
|
}
|
|
401
|
-
process.stdout.write(
|
|
580
|
+
process.stdout.write(`${[row, band, workflowLine].filter(Boolean).join('\n')}\n`);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Checkbox progress of the newest plan. The prompt, tool and stop hooks note it in the workflow
|
|
2
|
+
// snapshot for the status line, which never reads plan files itself.
|
|
3
|
+
|
|
4
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { IMPLEMENTATION_ROW, planLinesOutsideFences } from '../skills/lit-plan/scripts/scaffold-plan.mjs';
|
|
8
|
+
|
|
9
|
+
const MAX_PLAN_BYTES = 256 * 1024;
|
|
10
|
+
const MAX_PLAN_ENTRIES = 500;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Checkbox progress of the newest plan in `<workspace>/plans/` that has column-zero task rows:
|
|
14
|
+
* { done, total }, or null. Symlinks, oversized files and fenced rows are skipped.
|
|
15
|
+
*/
|
|
16
|
+
export function planProgress(workspaceRoot) {
|
|
17
|
+
const directory = join(workspaceRoot, 'plans');
|
|
18
|
+
try {
|
|
19
|
+
if (!lstatSync(directory).isDirectory()) return null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const candidates = [];
|
|
24
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).slice(0, MAX_PLAN_ENTRIES)) {
|
|
25
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
26
|
+
const status = lstatSync(join(directory, entry.name));
|
|
27
|
+
if (status.isFile() && status.size <= MAX_PLAN_BYTES) candidates.push({ path: join(directory, entry.name), mtimeMs: status.mtimeMs });
|
|
28
|
+
}
|
|
29
|
+
candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
30
|
+
for (const { path } of candidates) {
|
|
31
|
+
const rows = planLinesOutsideFences(readFileSync(path, 'utf8')).filter(({ line }) => IMPLEMENTATION_ROW.test(line));
|
|
32
|
+
if (rows.length === 0) continue;
|
|
33
|
+
return { done: rows.filter(({ line }) => /^- \[[xX]\]/u.test(line)).length, total: rows.length };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// No failure-detail field is documented, so this records only the failing toolName and opaque toolInput digest
|
|
3
|
+
// No failure-detail field is documented, so this records only the failing toolName and opaque toolInput digest,
|
|
4
|
+
// plus a fail verdict when the call was a test run.
|
|
4
5
|
import { recordPassiveEvent } from './record-passive-event.mjs';
|
|
5
6
|
|
|
6
7
|
await recordPassiveEvent('PostToolUseFailure');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Every finished call is recorded for the status line: toolName and a digest of the opaque toolInput,
|
|
4
|
+
// never its contents. A test run also keeps its verdict and one count, read from toolResult.
|
|
5
5
|
import { recordPassiveEvent } from './record-passive-event.mjs';
|
|
6
6
|
|
|
7
7
|
await recordPassiveEvent('PostToolUse');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Records which tool started and when, for the status line's live step. The tool input is never
|
|
4
|
+
// stored, not even as a digest. No stdout and exit 0 let the call proceed; a failure fails open.
|
|
5
|
+
import { recordPassiveEvent } from './record-passive-event.mjs';
|
|
6
|
+
|
|
7
|
+
await recordPassiveEvent('PreToolUse');
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from 'node:fs';
|
|
20
20
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
21
21
|
import { writeHudRecords } from './litgrok-hud-state.mjs';
|
|
22
|
+
import { stageFromPrompt, testVerdict, updateWorkflowState } from './workflow-stage.mjs';
|
|
22
23
|
|
|
23
24
|
const MAX_EVENT_BYTES = 1024 * 1024;
|
|
24
25
|
// A ledger past this size moves aside as a numbered segment and a fresh one starts.
|
|
@@ -29,6 +30,8 @@ const KEPT_SEGMENTS = 2;
|
|
|
29
30
|
const ROTATION_LOCK_STALE_MS = 30 * 1000;
|
|
30
31
|
const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
|
|
31
32
|
const RECORD_SCHEMA = 'litgrok.hook-event/v1';
|
|
33
|
+
// A finished call by one of these tools cannot have changed a plan file, so it skips the plan reread.
|
|
34
|
+
const READ_ONLY_TOOLS = /^(?:read_file|grep|list_dir|glob|todo_write|web_search|web_fetch|read|ls)$/iu;
|
|
32
35
|
const HUD_DISCIPLINE_PATTERN = /(?:^|[\s/])(lit-scientific-visualization|lit-handoff|autoconference|autoresearch|lit-plan|litwork|lit)(?=$|[^\w-])/iu;
|
|
33
36
|
|
|
34
37
|
function maskCode(text) {
|
|
@@ -348,6 +351,8 @@ export function readLedgerRecords(ledgerFile, sessionId) {
|
|
|
348
351
|
}
|
|
349
352
|
|
|
350
353
|
function toolRecord(event, expectedEvent) {
|
|
354
|
+
// A call that is starting keeps only its tool name, for the status line's live step.
|
|
355
|
+
if (expectedEvent === 'PreToolUse') return { name: requireString(event.toolName, 'TOOL_NAME_MISSING') };
|
|
351
356
|
if (!TOOL_EVENTS.has(expectedEvent)) return undefined;
|
|
352
357
|
const name = requireString(event.toolName, 'TOOL_NAME_MISSING');
|
|
353
358
|
if (!Object.hasOwn(event, 'toolInput')) throw new RecorderError('TOOL_INPUT_MISSING');
|
|
@@ -385,6 +390,24 @@ function appendRecord(path, record) {
|
|
|
385
390
|
throw new RecorderError('LEDGER_FILE_UNSAFE');
|
|
386
391
|
}
|
|
387
392
|
|
|
393
|
+
// The workflow snapshot is display state for the status line: a failure leaves the ledger record in
|
|
394
|
+
// place, says so on stderr and lets the hook succeed. The plan reader loads only here, because the
|
|
395
|
+
// status line imports this module and must not depend on the lit-plan skill's files.
|
|
396
|
+
async function noteWorkflow(ledgerFile, environment, expectedEvent, record) {
|
|
397
|
+
if (!['UserPromptSubmit', 'PostToolUse', 'PostToolUseFailure'].includes(expectedEvent)) return;
|
|
398
|
+
try {
|
|
399
|
+
const readsPlan = expectedEvent !== 'PostToolUseFailure' && !READ_ONLY_TOOLS.test(record.tool?.name ?? '');
|
|
400
|
+
const plan = readsPlan ? (await import('./plan-progress.mjs')).planProgress(environment.realWorkspaceRoot) : undefined;
|
|
401
|
+
updateWorkflowState(ledgerFile, environment.sessionId, {
|
|
402
|
+
stage: record.stage,
|
|
403
|
+
plan,
|
|
404
|
+
test: record.test,
|
|
405
|
+
});
|
|
406
|
+
} catch (error) {
|
|
407
|
+
process.stderr.write(`LitGrok workflow state: ${error?.message ?? 'WORKFLOW_STATE_FAILED'}\n`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
388
411
|
export function appendSessionRecord(ledgerFile, baseRecord, fields) {
|
|
389
412
|
appendRecord(ledgerFile, { ...baseRecord, ...fields, recordedAt: new Date().toISOString() });
|
|
390
413
|
}
|
|
@@ -410,9 +433,17 @@ export async function recordPassiveEvent(expectedEvent) {
|
|
|
410
433
|
&& /(?:^|[\s/])lit-plan(?:$|[^\w-])/iu.test(event.prompt) ? 'lit-plan' : null;
|
|
411
434
|
const hudDiscipline = expectedEvent === 'UserPromptSubmit' ? hudDisciplineFromPrompt(event.prompt) : null;
|
|
412
435
|
if (discipline !== null) record.discipline = discipline;
|
|
436
|
+
const stage = expectedEvent === 'UserPromptSubmit' && typeof event.prompt === 'string' ? stageFromPrompt(visiblePromptText(event.prompt)) : null;
|
|
437
|
+
if (stage !== null) record.stage = stage;
|
|
438
|
+
// Only the verdict and one count of a test run are kept, never the command or its output.
|
|
439
|
+
const test = TOOL_EVENTS.has(expectedEvent) ? testVerdict(event, expectedEvent === 'PostToolUseFailure') : null;
|
|
440
|
+
if (test !== null) record.test = test;
|
|
413
441
|
if (expectedEvent === 'Stop') record.signal = 'turn-ended';
|
|
414
442
|
const ledgerFile = environment.sessionId === null ? null : ledgerPath(environment.realWorkspaceRoot, environment.sessionId);
|
|
415
|
-
if (ledgerFile !== null)
|
|
443
|
+
if (ledgerFile !== null) {
|
|
444
|
+
appendRecord(ledgerFile, record);
|
|
445
|
+
await noteWorkflow(ledgerFile, environment, expectedEvent, record);
|
|
446
|
+
}
|
|
416
447
|
if (expectedEvent === 'UserPromptSubmit') {
|
|
417
448
|
writeHudRecords({
|
|
418
449
|
env: process.env,
|
package/.grok/hooks/stop.mjs
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
import { appendSessionRecord, recordPassiveEvent } from './record-passive-event.mjs';
|
|
5
5
|
import { PLAN_GATE_SIGNAL, evaluatePlanGate } from './plan-gate.mjs';
|
|
6
6
|
import { evaluateAutoHandoff } from './auto-handoff-gate.mjs';
|
|
7
|
+
import { planProgress } from './plan-progress.mjs';
|
|
8
|
+
import { updateWorkflowState } from './workflow-stage.mjs';
|
|
7
9
|
|
|
8
10
|
const observed = await recordPassiveEvent('Stop');
|
|
9
11
|
if (observed) {
|
|
12
|
+
let held = false;
|
|
10
13
|
// Plan gate: a planning turn must persist plans/<slug>.md; evaluated after the ledger record so a block is counted.
|
|
11
14
|
let gate;
|
|
12
15
|
try {
|
|
@@ -21,6 +24,7 @@ if (observed) {
|
|
|
21
24
|
gate = { block: false };
|
|
22
25
|
}
|
|
23
26
|
if (gate.block) {
|
|
27
|
+
held = true;
|
|
24
28
|
appendSessionRecord(observed.ledgerFile, observed.record, { signal: PLAN_GATE_SIGNAL, reason: gate.reason });
|
|
25
29
|
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: gate.reason })}\n`);
|
|
26
30
|
} else {
|
|
@@ -35,9 +39,23 @@ if (observed) {
|
|
|
35
39
|
ledgerFile: observed.ledgerFile,
|
|
36
40
|
});
|
|
37
41
|
if (handoff.record) appendSessionRecord(observed.ledgerFile, observed.record, handoff.record);
|
|
38
|
-
if (handoff.block)
|
|
42
|
+
if (handoff.block) {
|
|
43
|
+
held = true;
|
|
44
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: handoff.reason })}\n`);
|
|
45
|
+
}
|
|
39
46
|
} catch (error) {
|
|
40
47
|
process.stderr.write(`LitGrok auto-handoff: AUTO_HANDOFF_FAILED ${error instanceof Error ? error.message : String(error)}\n`);
|
|
41
48
|
}
|
|
42
49
|
}
|
|
50
|
+
// The workflow row: a stop that ends the turn closes the current stage, a held one keeps it open.
|
|
51
|
+
if (observed.ledgerFile) {
|
|
52
|
+
try {
|
|
53
|
+
updateWorkflowState(observed.ledgerFile, observed.environment.sessionId, {
|
|
54
|
+
ended: !held,
|
|
55
|
+
plan: planProgress(observed.environment.realWorkspaceRoot),
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
process.stderr.write(`LitGrok workflow state: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
43
61
|
}
|