@litfamily/litgrok 1.0.15 → 1.0.17

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 (58) 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-docx/references/tonalities/proposal.md +0 -1
  10. package/.grok/skills/lit-docx/scripts/docx_design.py +1 -1
  11. package/.grok/skills/lit-docx/templates/tonalities/brief.yaml +0 -1
  12. package/.grok/skills/lit-docx/templates/tonalities/journal.yaml +0 -1
  13. package/.grok/skills/lit-docx/templates/tonalities/manual.yaml +0 -1
  14. package/.grok/skills/lit-docx/templates/tonalities/memo.yaml +0 -1
  15. package/.grok/skills/lit-docx/templates/tonalities/proposal.yaml +0 -1
  16. package/.grok/skills/lit-docx/templates/tonalities/report.yaml +0 -1
  17. package/.grok/skills/lit-pptx/FORBIDDEN_TERMS.json +55 -0
  18. package/.grok/skills/lit-pptx/references/tonalities/atlas.md +2 -5
  19. package/.grok/skills/lit-pptx/references/tonalities/chalk.md +1 -3
  20. package/.grok/skills/lit-pptx/references/tonalities/gazette.md +1 -3
  21. package/.grok/skills/lit-pptx/references/tonalities/ledger.md +4 -6
  22. package/.grok/skills/lit-pptx/references/tonalities/night.md +3 -6
  23. package/.grok/skills/lit-pptx/references/tonalities/paper.md +3 -5
  24. package/.grok/skills/lit-pptx/references/tonalities/signal.md +3 -5
  25. package/.grok/skills/lit-pptx/references/tonalities/studio.md +3 -5
  26. package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +3 -4
  27. package/.grok/skills/lit-pptx/scripts/validate_pptx.py +9 -4
  28. package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -20
  29. package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -19
  30. package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -20
  31. package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -22
  32. package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -20
  33. package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -20
  34. package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -21
  35. package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -20
  36. package/.grok/skills/lit-typographic-motion/SKILL.md +2 -2
  37. package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +2 -2
  38. package/.grok/skills/lit-typographic-motion/fonts/pins.json +1 -1
  39. package/.grok/skills/lit-typographic-motion/package.json +1 -1
  40. package/.grok/skills/lit-typographic-motion/references/complete-contract.md +1 -1
  41. package/.grok/skills/lit-typographic-motion/references/runtime.md +12 -8
  42. package/.grok/skills/lit-typographic-motion/references/scene-contract.md +1 -1
  43. package/.grok/skills/lit-typographic-motion/requirements-audio.txt +1 -1
  44. package/.grok/skills/lit-typographic-motion/scripts/lib/cache.mjs +21 -3
  45. package/.grok/skills/lit-typographic-motion/scripts/motion.mjs +1 -1
  46. package/.grok/skills/lit-typographic-motion/scripts/prewarm.mjs +3 -3
  47. package/.grok/skills/litgrok/SKILL.md +2 -2
  48. package/.grok-plugin/plugin.json +1 -1
  49. package/CHANGELOG.md +17 -0
  50. package/README.md +15 -13
  51. package/README_ko-KR.md +15 -13
  52. package/bin/litgrok.mjs +76 -2
  53. package/bin/status-line-config.mjs +79 -9
  54. package/docs/assets/readme/badge-version.svg +1 -1
  55. package/docs/reference.md +15 -5
  56. package/docs/reference_ko-KR.md +17 -7
  57. package/package.json +19 -2
  58. package/plugin.json +1 -1
@@ -8,11 +8,12 @@
8
8
  // what makes it fire once.
9
9
 
10
10
  import { createHash } from 'node:crypto';
11
- import { existsSync, lstatSync, readFileSync } from 'node:fs';
11
+ import { lstatSync, readFileSync } from 'node:fs';
12
12
  import { dirname, join, relative, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { resolveAutoHandoff } from './auto-handoff.mjs';
15
15
  import { readContextRecord } from './litgrok-hud-state.mjs';
16
+ import { readLedgerRecords } from './record-passive-event.mjs';
16
17
 
17
18
  export const DIRECTIVE_SIGNAL = 'auto-handoff-directive';
18
19
  export const RELOAD_SIGNAL = 'auto-handoff-reload';
@@ -40,21 +41,6 @@ function carriesMarker(text, marker) {
40
41
  return text.split(/\r?\n/).some((line) => lineCarriesMarker(line, marker));
41
42
  }
42
43
 
43
- function ledgerRecords(ledgerFile, sessionId) {
44
- if (!existsSync(ledgerFile)) return [];
45
- const records = [];
46
- for (const line of readFileSync(ledgerFile, 'utf8').split(/\r?\n/)) {
47
- if (!line) continue;
48
- try {
49
- const record = JSON.parse(line);
50
- if (record.sessionId === sessionId) records.push(record);
51
- } catch {
52
- // A damaged line is skipped; the recorder itself refuses to append to a ledger it cannot read.
53
- }
54
- }
55
- return records;
56
- }
57
-
58
44
  function lastIndex(records, predicate, after = -1) {
59
45
  for (let index = records.length - 1; index > after; index -= 1) {
60
46
  if (predicate(records[index])) return index;
@@ -121,7 +107,7 @@ export function evaluateAutoHandoff({ event, env = process.env, workspaceRoot, e
121
107
  const settings = resolveAutoHandoff({ env, dirs: [workspaceRoot, eventCwd] });
122
108
  if (!settings.active) return { block: false };
123
109
 
124
- const records = ledgerRecords(ledgerFile, sessionId);
110
+ const records = readLedgerRecords(ledgerFile, sessionId);
125
111
  const directiveIndex = lastIndex(records, (record) => record.signal === DIRECTIVE_SIGNAL);
126
112
  const compactIndex = directiveIndex < 0 ? -1 : lastIndex(records, (record) => record.event === 'PostCompact', directiveIndex);
127
113
 
@@ -4,9 +4,10 @@
4
4
  // environment); `on` without a number reuses the last value, and there is no built-in default.
5
5
 
6
6
  import { randomUUID } from 'node:crypto';
7
- import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
8
- import { isAbsolute, join } from 'node:path';
7
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
9
9
  import { readNewestContextRecord } from './litgrok-hud-state.mjs';
10
+ import { ignoreStateDirectory } from './record-passive-event.mjs';
10
11
 
11
12
  export const ENABLE_VARIABLE = 'LITGROK_AUTO_HANDOFF';
12
13
  export const PERCENT_VARIABLE = 'LITGROK_AUTO_HANDOFF_PERCENT';
@@ -23,6 +24,58 @@ function configPath(directory) {
23
24
  return join(directory, ...CONFIG_FILE.split('/'));
24
25
  }
25
26
 
27
+ function present(path) {
28
+ try {
29
+ lstatSync(path);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function samePath(left, right) {
37
+ const canonical = (path) => {
38
+ try {
39
+ return realpathSync(path);
40
+ } catch {
41
+ return resolve(path);
42
+ }
43
+ };
44
+ return canonical(left) === canonical(right);
45
+ }
46
+
47
+ // Project hooks run only from a Git project root, and Grok hands its workspace root to the Stop hook and
48
+ // the status line. The command picks the nearest Git root from any subfolder; outside Git it uses cwd.
49
+ export function projectRoot(cwd) {
50
+ let directory = resolve(cwd);
51
+ for (;;) {
52
+ if (present(join(directory, '.git'))) return { root: directory, git: true };
53
+ const parent = dirname(directory);
54
+ if (parent === directory) return { root: resolve(cwd), git: false };
55
+ directory = parent;
56
+ }
57
+ }
58
+
59
+ // The host decides the workspace root, so say when the chosen folder may not be the one it uses.
60
+ function rootNotes({ root, git }, home) {
61
+ const notes = [];
62
+ if (!git) {
63
+ notes.push(`Note: no Git root above ${root}, so this setting belongs to that folder alone. Project hooks need a Git root, and they read this file only when Grok Build starts in that folder.`);
64
+ } else if (home && samePath(root, home)) {
65
+ notes.push(`Note: the Git root here is your home folder (${root}), so this setting applies to Grok Build sessions started in your home folder. For a project inside it, run git init in the project and run this command there.`);
66
+ }
67
+ for (let directory = dirname(root); ; directory = dirname(directory)) {
68
+ // A dotfiles repository at home is not a project root unless LitGrok already keeps state there.
69
+ const homeRepository = home && samePath(directory, home) && !present(join(directory, '.grok', 'litgrok'));
70
+ if (!homeRepository && (present(join(directory, '.git')) || present(join(directory, '.grok', 'litgrok')))) {
71
+ notes.push(`Note: ${directory} above it is also a project root. The hooks read the file under the folder where you start Grok Build: ${configPath(root)} when you start in ${root}, or ${configPath(directory)} when you start in ${directory}. Run this command in the folder where you start Grok Build.`);
72
+ break;
73
+ }
74
+ if (dirname(directory) === directory) break;
75
+ }
76
+ return notes;
77
+ }
78
+
26
79
  // Returns { found: false } or { found: true, valid, enabled, percent, reason }.
27
80
  export function readAutoHandoffFile(directory) {
28
81
  const path = configPath(directory);
@@ -127,6 +180,7 @@ function ensureDirectory(path) {
127
180
  export function writeAutoHandoffFile(directory, settings) {
128
181
  ensureDirectory(join(directory, '.grok'));
129
182
  ensureDirectory(join(directory, '.grok', 'litgrok'));
183
+ ignoreStateDirectory(join(directory, '.grok', 'litgrok'));
130
184
  const target = configPath(directory);
131
185
  try {
132
186
  const existing = lstatSync(target);
@@ -189,13 +243,17 @@ export async function runAutoHandoff(args, { env = process.env, stdout = process
189
243
  return 1;
190
244
  }
191
245
 
192
- const resolved = resolveAutoHandoff({ env, dirs: [cwd] });
246
+ const located = projectRoot(cwd);
247
+ const { root } = located;
248
+ const notes = rootNotes(located, env.HOME || null);
249
+ const file = `Project file: ${configPath(root)}`;
250
+ const resolved = resolveAutoHandoff({ env, dirs: [root] });
193
251
  if (action === 'status') {
194
- stdout.write(`${describe(resolved, env).join('\n')}\n`);
252
+ stdout.write(`${[...describe(resolved, env), file, ...notes].join('\n')}\n`);
195
253
  return 0;
196
254
  }
197
255
 
198
- const stored = readAutoHandoffFile(cwd);
256
+ const stored = readAutoHandoffFile(root);
199
257
  const previous = stored.found && stored.valid ? stored.percent : null;
200
258
  let next;
201
259
  if (action === 'off') {
@@ -211,12 +269,12 @@ export async function runAutoHandoff(args, { env = process.env, stdout = process
211
269
  }
212
270
 
213
271
  try {
214
- writeAutoHandoffFile(cwd, next);
272
+ writeAutoHandoffFile(root, next);
215
273
  } catch (error) {
216
274
  return fail(`litgrok auto-handoff: ${error instanceof Error ? error.message : String(error)}`);
217
275
  }
218
276
 
219
- const after = resolveAutoHandoff({ env, dirs: [cwd] });
277
+ const after = resolveAutoHandoff({ env, dirs: [root] });
220
278
  const lines = [];
221
279
  if (action === 'off') {
222
280
  lines.push('Automatic handoff is OFF for this project.');
@@ -225,6 +283,7 @@ export async function runAutoHandoff(args, { env = process.env, stdout = process
225
283
  lines.push(`Automatic handoff is ON at ${next.percent} percent for this project.`);
226
284
  lines.push('When a turn ends with the context at or above that percent, the model is asked to write a handoff and then to tell you to run /compact.');
227
285
  }
286
+ lines.push(`Saved in ${configPath(root)}`, ...notes);
228
287
  if (after.enabled !== next.enabled) lines.push(`Warning: ${ENABLE_VARIABLE} is set in this shell, so sessions started from here stay ${after.enabled ? 'ON' : 'OFF'}.`);
229
288
  const { warning } = hostWarning(next.enabled ? next.percent : null, env);
230
289
  if (warning) lines.push(warning);
@@ -97,14 +97,15 @@ function loadRules() {
97
97
  }
98
98
 
99
99
  async function readEvent() {
100
- let input = '';
101
- let oversized = false;
100
+ // Decode once: a multi-byte character can straddle two pipe chunks.
101
+ const chunks = [];
102
+ let bytes = 0;
102
103
  for await (const chunk of process.stdin) {
103
- input += chunk;
104
- if (Buffer.byteLength(input) > MAX_EVENT_BYTES) { input = ''; oversized = true; }
104
+ bytes += chunk.length;
105
+ if (bytes <= MAX_EVENT_BYTES) chunks.push(chunk);
105
106
  }
106
- if (oversized) throw new Error('event exceeded the size limit; write left unchanged');
107
- try { return JSON.parse(input); } catch { throw new Error('event could not be parsed; write left unchanged'); }
107
+ if (bytes > MAX_EVENT_BYTES) throw new Error('event exceeded the size limit; write left unchanged');
108
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new Error('event could not be parsed; write left unchanged'); }
108
109
  }
109
110
 
110
111
  try {
@@ -1,20 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { isAbsolute } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { closeSync, constants, fstatSync, openSync, readFileSync, readSync, realpathSync } from 'node:fs';
5
+ import { dirname, isAbsolute, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
4
7
  import { resolveAutoHandoff } from './auto-handoff.mjs';
5
8
  import { readHudRecordForCwd, writeContextRecord } from './litgrok-hud-state.mjs';
6
9
 
7
10
  const MAX_INPUT_BYTES = 64 * 1024;
8
11
  const GRADIENT_STOPS = [[255, 99, 55], [255, 45, 149], [0, 229, 255]];
12
+ const BRAND_COLOR = [255, 99, 55];
13
+ // Grok cuts each line at 1024 characters, escapes included.
14
+ const MAX_LINE_CHARACTERS = 1024;
15
+ const RECORD_SCHEMA = 'litgrok.hook-event/v1';
16
+ // The ribbon reads only the end of the session ledger, enough for a few hundred records.
17
+ const LEDGER_TAIL_BYTES = 128 * 1024;
18
+ const COMPLETE_SHOWN_MS = 20 * 1000;
19
+ const HANDOFF_NEAR_POINTS = 10;
20
+ const CLOCK_SKEW_MS = 5 * 1000;
21
+ const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
22
+ // [background, foreground] of each pill.
23
+ const PILLS = {
24
+ brand: [[255, 45, 149], [18, 6, 12]],
25
+ running: [[10, 166, 194], [0, 19, 24]],
26
+ complete: [[29, 77, 54], [184, 245, 207]],
27
+ warning: [[90, 58, 8], [255, 210, 138]],
28
+ };
29
+ const TEXT = { dim: [139, 148, 158], amber: [255, 175, 60], green: [107, 219, 143] };
30
+ const GLYPHS = {
31
+ basic: { running: '●', complete: '✓', warning: '⚠', timer: '', tools: (n) => `${n} tool${n === 1 ? '' : 's'}`, agents: (n) => `${n} agent${n === 1 ? '' : 's'}` },
32
+ nerd: { running: '\uf111', complete: '\uf00c', warning: '\uf071', timer: '\uf017 ', tools: (n) => `\uf0ad ${n}`, agents: (n) => `\uf0c0 ${n}` },
33
+ };
34
+ 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
+ [127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]];
9
36
 
10
37
  async function readInput() {
11
- let input = '';
38
+ // Decode once: a multi-byte character can straddle two pipe chunks.
39
+ const chunks = [];
40
+ let bytes = 0;
12
41
  for await (const chunk of process.stdin) {
13
- input += chunk;
14
- if (Buffer.byteLength(input) > MAX_INPUT_BYTES) return null;
42
+ bytes += chunk.length;
43
+ if (bytes > MAX_INPUT_BYTES) return null;
44
+ chunks.push(chunk);
15
45
  }
16
46
  try {
17
- const value = JSON.parse(input);
47
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
18
48
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
19
49
  } catch {
20
50
  return null;
@@ -31,9 +61,42 @@ function plainText(value, fallback) {
31
61
  return cleaned || fallback;
32
62
  }
33
63
 
34
- function contextPercent(value) {
35
- if (typeof value !== 'number' || !Number.isFinite(value)) return '?';
36
- return String(Math.round(Math.max(0, Math.min(100, value))));
64
+ // The installer writes its manifest beside the hooks folder, so an installed copy can name its version.
65
+ function installedVersion() {
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(join(dirname(dirname(fileURLToPath(import.meta.url))), '.litgrok-install-manifest.json'), 'utf8'));
68
+ return typeof manifest?.version === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(manifest.version) ? manifest.version : '';
69
+ } catch {
70
+ return '';
71
+ }
72
+ }
73
+
74
+ // Grok sends effort.level only for models that take a reasoning effort.
75
+ function effortLevel(value) {
76
+ return typeof value === 'string' && /^[a-z][a-z-]{0,11}$/u.test(value) ? value : '';
77
+ }
78
+
79
+ function windowSize(value) {
80
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '';
81
+ if (value >= 1000000 && value % 100000 === 0) return `${value / 1000000}m`;
82
+ return `${Math.max(1, Math.round(value / 1000))}k`;
83
+ }
84
+
85
+ function contextBar(percent) {
86
+ const exact = (percent * 3) / 100;
87
+ const filled = Math.floor(exact);
88
+ const partialBlocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
89
+ const partial = filled < 3 && exact > filled ? Math.max(1, Math.min(7, Math.round((exact - filled) * 8))) : 0;
90
+ return `${'█'.repeat(filled)}${partialBlocks[partial]}${'░'.repeat(3 - filled - (partial > 0 ? 1 : 0))}`;
91
+ }
92
+
93
+ // A percent Grok did not send shows as ?, never as a guess.
94
+ function contextText(contextWindow) {
95
+ const size = windowSize(contextWindow?.context_window_size);
96
+ const value = contextWindow?.used_percentage;
97
+ if (typeof value !== 'number' || !Number.isFinite(value)) return size ? `ctx ?/${size}` : 'ctx ?';
98
+ const percent = Math.round(Math.max(0, Math.min(100, value)));
99
+ return `ctx [${contextBar(percent)}] ${percent}%${size ? `/${size}` : ''}`;
37
100
  }
38
101
 
39
102
  function cellWidth(character) {
@@ -65,37 +128,62 @@ function clipCells(text, maximum) {
65
128
  return result;
66
129
  }
67
130
 
131
+ function distance(left, right) {
132
+ return left.reduce((total, value, index) => total + (value - right[index]) ** 2, 0);
133
+ }
134
+
135
+ function xterm256(rgb) {
136
+ const levels = [0, 95, 135, 175, 215, 255];
137
+ const nearest = (value) => levels.reduce((best, level, index) => (Math.abs(level - value) < Math.abs(levels[best] - value) ? index : best), 0);
138
+ const cube = rgb.map(nearest);
139
+ const gray = Math.max(0, Math.min(23, Math.round((rgb.reduce((total, value) => total + value, 0) / 3 - 8) / 10)));
140
+ const grayValue = 8 + gray * 10;
141
+ return distance(rgb, [grayValue, grayValue, grayValue]) < distance(rgb, cube.map((index) => levels[index]))
142
+ ? 232 + gray
143
+ : 16 + cube[0] * 36 + cube[1] * 6 + cube[2];
144
+ }
145
+
146
+ // depth is 24 (truecolor), 256 or 16; the 16-colour codes are the nearest of the standard xterm palette.
147
+ function sgr(rgb, depth, background = false) {
148
+ if (depth === 256) return `\u001b[${background ? 48 : 38};5;${xterm256(rgb)}m`;
149
+ if (depth === 16) {
150
+ const index = ANSI_16.reduce((best, color, at) => (distance(rgb, color) < distance(rgb, ANSI_16[best]) ? at : best), 0);
151
+ return `\u001b[${(index < 8 ? 30 : 82) + index + (background ? 10 : 0)}m`;
152
+ }
153
+ return `\u001b[${background ? 48 : 38};2;${rgb.join(';')}m`;
154
+ }
155
+
68
156
  function gradientColor(position) {
69
157
  const stop = position <= 0.5 ? 0 : 1;
70
158
  const fraction = position <= 0.5 ? position * 2 : (position - 0.5) * 2;
71
159
  return GRADIENT_STOPS[stop].map((value, index) => Math.round(value + (GRADIENT_STOPS[stop + 1][index] - value) * fraction));
72
160
  }
73
161
 
74
- function paintLabel(text) {
162
+ // Bold is set once and each letter only changes the colour, so the longest label stays far below the cut.
163
+ function paintLabel(text, depth) {
75
164
  const characters = [...text];
76
- return characters.map((character, index) => {
165
+ return `\u001b[1m${characters.map((character, index) => {
77
166
  if (character === ' ') return character;
78
167
  const position = characters.length === 1 ? 0 : index / (characters.length - 1);
79
- return `\u001b[1m\u001b[38;2;${gradientColor(position).join(';')}m${character}\u001b[0m`;
80
- }).join('');
168
+ return `${sgr(gradientColor(position), depth)}${character}`;
169
+ }).join('')}\u001b[0m`;
81
170
  }
82
171
 
83
- function formatRow(discipline, model, context, color) {
172
+ function formatRow({ discipline, model, effort, context, version }, depth, maxCells) {
173
+ const brand = `[🔥LIT${version ? ` v${version}` : ''}]`;
174
+ const suffix = `${effort ? ` [${effort}]` : ''} │ ${context}`;
175
+ let ignition = '';
176
+ let ignitionText = '';
84
177
  if (discipline) {
85
- const prefix = '🔥 LIT IGNITED · ';
86
- const maxDisciplineCells = discipline === 'lit-scientific-visualization' ? 32 : 24;
87
- const disciplineText = clipCells(discipline, maxDisciplineCells);
88
- const label = `LIT IGNITED · ${disciplineText}`;
89
- const middle = ` ${disciplineText} 🔥 │ `;
90
- const suffix = ` │ ctx ${context}%`;
91
- const modelText = clipCells(model, Math.max(0, 79 - width(prefix + middle + suffix)));
92
- return `🔥 ${color ? paintLabel(label) : label} 🔥 │ ${modelText}${suffix}`;
178
+ // The skill name gives way after the model, and the model keeps at least its ellipsis.
179
+ const room = maxCells - width(`${brand} 🔥 LIT IGNITED · 🔥 | ${suffix}`) - 1;
180
+ const maxDisciplineCells = Math.max(1, Math.min(discipline === 'lit-scientific-visualization' ? 32 : 24, room));
181
+ const label = `LIT IGNITED · ${clipCells(discipline, maxDisciplineCells)}`;
182
+ ignitionText = ` 🔥 ${label} 🔥`;
183
+ ignition = ` 🔥 ${depth ? paintLabel(label, depth) : label} 🔥`;
93
184
  }
94
- const prefix = 'LIT · grok │ ';
95
- const suffix = ` │ ctx ${context}%`;
96
- const modelText = clipCells(model, Math.max(0, 79 - width(prefix + suffix)));
97
- const row = `${prefix}${modelText}${suffix}`;
98
- return color ? `\u001b[1m\u001b[38;2;255;99;55m${row}\u001b[0m` : row;
185
+ const modelText = clipCells(model, Math.max(1, maxCells - width(`${brand}${ignitionText} | ${suffix}`)));
186
+ return `${depth ? `\u001b[1m${sgr(BRAND_COLOR, depth)}${brand}\u001b[0m` : brand}${ignition} | ${modelText}${suffix}`;
99
187
  }
100
188
 
101
189
  // Automatic handoff needs Grok's context percent, which only the status line receives. Nothing is
@@ -116,6 +204,162 @@ function recordContextForHandoff(status) {
116
204
  });
117
205
  }
118
206
 
207
+ function statusDirs(status) {
208
+ return [status?.workspace?.repo_root, status?.workspace?.current_dir, status?.cwd];
209
+ }
210
+
211
+ // The passive hooks keep the session ledger under the workspace root Grok gives them, which the status
212
+ // input names as one of these folders. Only a regular file is read, and only its end.
213
+ function readLedgerTail(status) {
214
+ const sessionId = status?.session_id;
215
+ if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 512) return null;
216
+ const name = `${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}.jsonl`;
217
+ for (const directory of statusDirs(status)) {
218
+ if (typeof directory !== 'string' || !isAbsolute(directory)) continue;
219
+ let descriptor;
220
+ try {
221
+ descriptor = openSync(join(realpathSync(directory), '.grok', 'litgrok', 'session-ledger', name), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
222
+ } catch {
223
+ continue;
224
+ }
225
+ try {
226
+ const file = fstatSync(descriptor);
227
+ if (!file.isFile()) return null;
228
+ const length = Math.min(file.size, LEDGER_TAIL_BYTES);
229
+ const buffer = Buffer.alloc(length);
230
+ let text = buffer.subarray(0, readSync(descriptor, buffer, 0, length, file.size - length)).toString('utf8');
231
+ // A read that starts mid-file drops its first, partial line.
232
+ if (length < file.size) text = text.slice(text.indexOf('\n') + 1 || text.length);
233
+ return text.split('\n').flatMap((line) => {
234
+ try {
235
+ const record = JSON.parse(line);
236
+ return record?.schema === RECORD_SCHEMA && record.sessionId === sessionId && Number.isFinite(Date.parse(record.recordedAt)) ? [record] : [];
237
+ } catch {
238
+ return [];
239
+ }
240
+ });
241
+ } catch {
242
+ return null;
243
+ } finally {
244
+ closeSync(descriptor);
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ // What the ledger says about the current turn: it starts at the prompt Grok names, or the latest one.
251
+ // A turn whose start is not in the tail is unknown, so nothing is counted.
252
+ function turnActivity(records, promptId) {
253
+ if (!records) return null;
254
+ const prompts = records.flatMap((record, index) => (record.event === 'UserPromptSubmit' ? [index] : []));
255
+ const matching = typeof promptId === 'string' ? prompts.filter((index) => records[index].promptId === promptId) : [];
256
+ const start = (matching.length > 0 ? matching : prompts).at(-1);
257
+ if (start === undefined) return null;
258
+ const turn = records.slice(start + 1);
259
+ const count = (event) => turn.filter((record) => record.event === event).length;
260
+ const held = (record) => record.signal === 'plan-gate-block' || record.signal === 'auto-handoff-directive'
261
+ || (record.signal === 'auto-handoff-reload' && record.outcome === 'loaded');
262
+ const last = turn.at(-1);
263
+ const ended = last?.event === 'Stop' && !held(last);
264
+ return {
265
+ startedAt: Date.parse(records[start].recordedAt),
266
+ endedAt: ended ? Date.parse(last.recordedAt) : null,
267
+ tools: turn.filter((record) => TOOL_EVENTS.has(record.event)).length,
268
+ agents: Math.max(0, count('SubagentStart') - count('SubagentStop')),
269
+ planGate: !ended && turn.some((record) => record.signal === 'plan-gate-block'),
270
+ handoffAsked: !ended && turn.some((record) => record.signal === 'auto-handoff-directive'),
271
+ };
272
+ }
273
+
274
+ function duration(ms) {
275
+ const seconds = Math.floor(ms / 1000);
276
+ if (seconds < 60) return `${seconds}s`;
277
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
278
+ return `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}m`;
279
+ }
280
+
281
+ // Returns [long, short] warning text, or null. A plan-gate hold comes first, then a handoff request,
282
+ // then a context at or near the automatic-handoff percent.
283
+ function warningText(status, activity) {
284
+ if (activity?.planGate) return ['plan gate: save plans/<slug>.md', 'plan gate'];
285
+ if (activity?.handoffAsked) return ['handoff requested', 'handoff'];
286
+ const used = status?.context_window?.used_percentage;
287
+ if (typeof used !== 'number' || !Number.isFinite(used)) return null;
288
+ const settings = resolveAutoHandoff({ env: process.env, dirs: statusDirs(status) });
289
+ if (!settings.active) return null;
290
+ const percent = Math.round(Math.max(0, Math.min(100, used)));
291
+ if (percent >= settings.percent) return [`ctx ${percent}% ≥ handoff at ${settings.percent}%`, `ctx ${percent}%≥${settings.percent}%`];
292
+ if (percent >= settings.percent - HANDOFF_NEAR_POINTS) return [`ctx ${percent}% → handoff at ${settings.percent}%`, `ctx ${percent}%→${settings.percent}%`];
293
+ return null;
294
+ }
295
+
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);
300
+ const warning = warningText(status, activity);
301
+ let timer = null;
302
+ let state = null;
303
+ if (running) {
304
+ 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) {
307
+ state = 'complete';
308
+ timer = duration(Math.max(0, activity.endedAt - activity.startedAt));
309
+ }
310
+ if (state === null && warning === null) return null;
311
+ return { state, timer, warning, activity: running || state === 'complete' ? activity : null };
312
+ }
313
+
314
+ function pill(text, kind, depth, nerd) {
315
+ if (!depth) return { plain: `(${text})`, painted: `(${text})` };
316
+ const [background, foreground] = PILLS[kind];
317
+ if (nerd) {
318
+ const cap = (glyph) => `${sgr(background, depth)}${glyph}\u001b[0m`;
319
+ return { plain: `${text}`, painted: `${cap('')}${sgr(background, depth, true)}${sgr(foreground, depth)}\u001b[1m${text}\u001b[0m${cap('')}` };
320
+ }
321
+ return { plain: ` ${text} `, painted: `${sgr(background, depth, true)}${sgr(foreground, depth)}\u001b[1m ${text} \u001b[0m` };
322
+ }
323
+
324
+ function renderBand(discipline, band, depth, nerd, { warningIndex = 0, tools = true, agents = true, timer = true, warning = true, brand = 'full', skillCells = Infinity }) {
325
+ const glyphs = nerd ? GLYPHS.nerd : GLYPHS.basic;
326
+ const kind = band.warning ? 'warning' : band.state === 'complete' ? 'complete' : 'running';
327
+ const pills = [pill(brand === 'full' ? '🔥 LIT' : '🔥', 'brand', depth, nerd), pill(`${glyphs[kind]} ${clipCells(discipline, skillCells)}`, kind, depth, nerd)];
328
+ const items = [];
329
+ const paint = (text, rgb, bold = false) => (depth ? `${bold ? '\u001b[1m' : ''}${sgr(rgb, depth)}${text}\u001b[0m` : text);
330
+ if (band.warning && warning) items.push({ plain: band.warning[warningIndex], painted: paint(band.warning[warningIndex], TEXT.amber, true) });
331
+ if (band.state === 'complete') items.push({ plain: 'complete', painted: paint('complete', TEXT.green, true) });
332
+ if (band.timer && timer) items.push({ plain: `${glyphs.timer}${band.timer}`, painted: paint(`${glyphs.timer}${band.timer}`, TEXT.dim) });
333
+ if (band.activity && tools) items.push({ plain: glyphs.tools(band.activity.tools), painted: paint(glyphs.tools(band.activity.tools), TEXT.dim) });
334
+ if (band.activity && agents && band.state === 'running' && band.activity.agents > 0) {
335
+ items.push({ plain: glyphs.agents(band.activity.agents), painted: paint(glyphs.agents(band.activity.agents), TEXT.dim) });
336
+ }
337
+ const separator = { plain: ' · ', painted: paint(' · ', TEXT.dim) };
338
+ const join = (key) => [pills.map((part) => part[key]).join(' '), items.map((part) => part[key]).join(separator[key])].filter(Boolean).join(' ');
339
+ return { plain: join('plain'), painted: join('painted') };
340
+ }
341
+
342
+ // Narrow rows give up tool calls first, then subagents, the timer, the long warning, the warning itself
343
+ // and the brand's name, and last of all clip the skill name.
344
+ function formatBand(discipline, band, depth, nerd, maxCells) {
345
+ const tiers = [
346
+ {},
347
+ { tools: false },
348
+ { tools: false, agents: false },
349
+ { tools: false, agents: false, timer: false },
350
+ { tools: false, agents: false, timer: false, warningIndex: 1 },
351
+ { tools: false, agents: false, timer: false, warningIndex: 1, brand: 'compact' },
352
+ { tools: false, agents: false, timer: false, warning: false, brand: 'compact' },
353
+ ];
354
+ for (const tier of tiers) {
355
+ const row = renderBand(discipline, band, depth, nerd, tier);
356
+ if (width(row.plain) <= maxCells && row.painted.length <= MAX_LINE_CHARACTERS) return row.painted;
357
+ }
358
+ const last = tiers.at(-1);
359
+ const room = maxCells - width(renderBand('', band, depth, nerd, { ...last, skillCells: 0 }).plain);
360
+ return renderBand(discipline, band, depth, nerd, { ...last, skillCells: Math.max(1, room) }).painted;
361
+ }
362
+
119
363
  const input = await readInput();
120
364
  try {
121
365
  recordContextForHandoff(input);
@@ -131,7 +375,27 @@ try {
131
375
  }
132
376
  const discipline = record?.discipline ?? null;
133
377
  const model = plainText(input?.model?.display_name, 'grok');
134
- const context = contextPercent(input?.context_window?.used_percentage);
135
- const color = process.env.LITGROK_HUD_COLOR !== '0' && !Object.hasOwn(process.env, 'NO_COLOR');
136
- const row = formatRow(discipline, model, context, color);
137
- process.stdout.write(`${row}\n`);
378
+ const colorSetting = process.env.LITGROK_HUD_COLOR;
379
+ const depth = colorSetting === '0' || Object.hasOwn(process.env, 'NO_COLOR') ? 0 : colorSetting === '256' ? 256 : colorSetting === '16' ? 16 : 24;
380
+ // Grok sets COLUMNS to the width of the row; without it the row keeps to 79 cells.
381
+ const columns = Number.parseInt(process.env.COLUMNS ?? '', 10);
382
+ const maxCells = Number.isFinite(columns) && columns >= 20 ? Math.min(columns, 1000) - 1 : 79;
383
+ const row = formatRow({
384
+ discipline,
385
+ model,
386
+ effort: effortLevel(input?.effort?.level),
387
+ context: contextText(input?.context_window),
388
+ version: installedVersion(),
389
+ }, depth, maxCells);
390
+ // 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.
392
+ let band = null;
393
+ if (discipline && process.env.LITGROK_HUD_LINES !== '1') {
394
+ try {
395
+ const state = bandState(input, Date.now());
396
+ if (state) band = formatBand(discipline, state, depth, process.env.LITGROK_HUD_GLYPHS === 'nerd', maxCells);
397
+ } catch {
398
+ band = null;
399
+ }
400
+ }
401
+ process.stdout.write(band ? `${row}\n${band}\n` : `${row}\n`);
@@ -11,21 +11,13 @@ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node
11
11
  import { join } from 'node:path';
12
12
 
13
13
  import { countImplementationRows } from '../skills/lit-plan/scripts/scaffold-plan.mjs';
14
+ import { readLedgerRecords } from './record-passive-event.mjs';
14
15
 
15
16
  export const PLAN_GATE_SIGNAL = 'plan-gate-block';
16
17
  export const PLAN_GATE_MAX_BLOCKS = 2;
17
18
  const SCAFFOLD = '.grok/skills/lit-plan/scripts/scaffold-plan.mjs';
18
19
  const MAX_PLAN_BYTES = 256 * 1024;
19
20
 
20
- function ledgerRecords(ledgerFile, sessionId) {
21
- if (!existsSync(ledgerFile)) return [];
22
- return readFileSync(ledgerFile, 'utf8')
23
- .split(/\r?\n/)
24
- .filter(Boolean)
25
- .map((line) => JSON.parse(line))
26
- .filter((record) => record.sessionId === sessionId);
27
- }
28
-
29
21
  function turnStart(records, promptId) {
30
22
  const prompts = records.filter((record) => record.event === 'UserPromptSubmit');
31
23
  const matching = typeof promptId === 'string' ? prompts.filter((record) => record.promptId === promptId) : [];
@@ -59,7 +51,7 @@ export function evaluatePlanGate({ event, workspaceRoot, sessionId, ledgerFile }
59
51
  if (event.reason !== undefined && event.reason !== 'end_turn') return { block: false };
60
52
  if (event.subagentType !== undefined) return { block: false };
61
53
 
62
- const records = ledgerRecords(ledgerFile, sessionId);
54
+ const records = readLedgerRecords(ledgerFile, sessionId);
63
55
  const { since, litPlanTurn } = turnStart(records, event.promptId);
64
56
  const planMode = event.permissionMode === 'plan' || litPlanTurn;
65
57
  const written = plansWrittenSince(workspaceRoot, since);