@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.
@@ -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 GRADIENT_STOPS[stop].map((value, index) => Math.round(value + (GRADIENT_STOPS[stop + 1][index] - value) * fraction));
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 ended = last?.event === 'Stop' && !held(last);
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
- function bandState(status, now) {
297
- const started = status?.turn?.started_at_ms;
298
- const running = typeof started === 'number' && Number.isFinite(started) && started > 0;
299
- const activity = turnActivity(readLedgerTail(status), running ? status.prompt_id : undefined);
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 (started <= now + CLOCK_SKEW_MS) timer = duration(Math.max(0, now - started));
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. LITGROK_HUD_LINES=1 keeps the compact row alone.
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
- if (discipline && process.env.LITGROK_HUD_LINES !== '1') {
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 state = bandState(input, Date.now());
396
- if (state) band = formatBand(discipline, state, depth, process.env.LITGROK_HUD_GLYPHS === 'nerd', maxCells);
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
- band = null;
577
+ workflowLine = null;
399
578
  }
400
579
  }
401
- process.stdout.write(band ? `${row}\n${band}\n` : `${row}\n`);
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');
@@ -2,7 +2,6 @@
2
2
  "hooks": {
3
3
  "PostToolUse": [
4
4
  {
5
- "matcher": "^(Edit|edit|Write|write)$",
6
5
  "hooks": [
7
6
  {
8
7
  "type": "command",
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // A documented edited-file path or tool-result field would make this ledger richer; neither is documented.
4
- // Record only toolName and a digest of the opaque documented toolInput, never its contents.
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,15 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "pre-tool-use.mjs",
9
+ "timeout": 5
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -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) appendRecord(ledgerFile, record);
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,
@@ -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) process.stdout.write(`${JSON.stringify({ decision: 'block', reason: handoff.reason })}\n`);
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
  }