@litfamily/litgrok 1.0.27 → 1.0.28

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.
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { resolveAutoHandoff } from './auto-handoff.mjs';
8
8
  import { rewindCellRgb } from './lit-mark.mjs';
9
9
  import { readHudRecordForCwd, writeContextRecord } from './litgrok-hud-state.mjs';
10
+ import { readOtherSessions } from './session-rows.mjs';
10
11
  import { STAGES, readWorkflowState } from './workflow-stage.mjs';
11
12
 
12
13
  const MAX_INPUT_BYTES = 64 * 1024;
@@ -759,6 +760,28 @@ function detailLine({ workflow, live, warning, glyphs, paint, now, maxCells, rai
759
760
  return layoutLine({ left, right, separator: { plain: ` ${glyphs.dot} `, painted: paint.fg(` ${glyphs.dot} `, 'dim') }, rail: elbow, maxCells, ellipsis: glyphs.ellipsis });
760
761
  }
761
762
 
763
+ // One row per other session of this workspace: `● start-work · w2B:p2 running`. The name clips first;
764
+ // the state word stays.
765
+ function otherSessionLine(row, { glyphs, paint, maxCells, rail }) {
766
+ const mark = row.state === 'running' ? glyphs.run : row.state === 'done' ? glyphs.tick : glyphs.dot;
767
+ const role = row.state === 'running' ? 'accent' : row.state === 'done' ? 'green' : 'dim';
768
+ const name = row.pane ? `${plainText(row.label, 'session')} ${glyphs.dot} ${row.pane}` : plainText(row.label, 'session');
769
+ const text = mark ? `${mark} ${name}` : name;
770
+ const lead = mark ? `${mark} ` : '';
771
+ return layoutLine({
772
+ left: [
773
+ segment(text, (value) => paint.fg(value, role, row.state !== 'idle'), {
774
+ clip: 1, minCells: width(lead) + 1, shrink: (cells) => `${lead}${clipCells(name, Math.max(1, cells - width(lead)), glyphs.ellipsis)}`,
775
+ }),
776
+ segment(row.state, (value) => paint.fg(value, role)),
777
+ ],
778
+ separator: { plain: ' ', painted: ' ' },
779
+ rail,
780
+ maxCells,
781
+ ellipsis: glyphs.ellipsis,
782
+ });
783
+ }
784
+
762
785
  function figuresLine({ status, glyphs, paint, handoff, maxCells, rail }) {
763
786
  return layoutLine({
764
787
  left: figureLeads(figureSegments(status, { glyphs, paint, handoff }), glyphs, paint, false),
@@ -822,18 +845,25 @@ function render(input) {
822
845
  burst: phase.phase === 'complete' && workflow?.stage === 'done',
823
846
  };
824
847
  if (process.env.LITGROK_HUD_LINES === '1') return [compactRow({ ...shared, looks, phase })];
848
+ let others = [];
849
+ try {
850
+ others = readOtherSessions(statusDirs(input), input?.session_id, now);
851
+ } catch {
852
+ others = [];
853
+ }
854
+ const rail = railFor(paint, glyphs);
855
+ const otherLines = others.map((row) => otherSessionLine(row, { glyphs, paint, maxCells, rail }));
825
856
  // The card shows while a discipline is active or the session has a workflow stage; otherwise the
826
- // HUD is the single row.
827
- if (!discipline && !workflow?.stage) return [compactRow({ ...shared, looks, phase })];
857
+ // HUD is the single row, with the other sessions' rows under it.
858
+ if (!discipline && !workflow?.stage) return [compactRow({ ...shared, looks, phase }), ...otherLines];
828
859
  const warning = warningText(input, activity, handoff);
829
860
  const live = turn.running ? activity?.live ?? null : null;
830
- const rail = railFor(paint, glyphs);
831
861
  const lines = [titleLine({ ...shared, phase, warning, activity: phase.phase === 'idle' ? null : activity, rail, looks })];
832
862
  if (workflow?.stage) lines.push(mapLine(workflow, { paint, glyphs, nerd, running: turn.running, now }, rail, maxCells));
833
863
  const detail = detailLine({ workflow, live, warning, glyphs, paint, now, maxCells, rail });
834
864
  if (detail) lines.push(detail);
835
865
  lines.push(figuresLine({ ...shared, rail }));
836
- return lines;
866
+ return [...lines, ...otherLines];
837
867
  }
838
868
 
839
869
 
@@ -3,14 +3,16 @@
3
3
  // Host contract (documented Stop decision control): stdout `{"decision":"block","reason"}` keeps
4
4
  // the agent working; empty stdout with exit 0 allows the stop; failures fail open. The prompt text
5
5
  // is never persisted, so the gate arms on `permissionMode === "plan"`, on the ledger's lit-plan
6
- // discipline marker for the turn, and on a plan file written this turn that carries zero rows. At
6
+ // discipline marker for the turn, on a plan file written this turn that carries zero rows, and on
7
+ // one whose todos name a folder outside the project with no `Scope roots:` line covering it. At
7
8
  // most two blocks per session; after that it passes with a stderr warning so a condition the model
8
9
  // cannot resolve never loops.
9
10
 
10
11
  import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
11
12
  import { join } from 'node:path';
12
13
 
13
- import { countImplementationRows } from '../skills/lit-plan/scripts/scaffold-plan.mjs';
14
+ import { countImplementationRows, planLinesOutsideFences } from '../skills/lit-plan/scripts/scaffold-plan.mjs';
15
+ import { describeScopePath, uncoveredScopePaths } from '../skills/lit-plan/scripts/scope-roots.mjs';
14
16
  import { readLedgerRecords } from './record-passive-event.mjs';
15
17
 
16
18
  export const PLAN_GATE_SIGNAL = 'plan-gate-block';
@@ -38,7 +40,13 @@ function plansWrittenSince(workspaceRoot, since) {
38
40
  const status = statSync(path);
39
41
  if (status.size > MAX_PLAN_BYTES) continue;
40
42
  if (since !== null && status.mtimeMs < since) continue;
41
- plans.push({ name: `plans/${entry.name}`, mtimeMs: status.mtimeMs, rows: countImplementationRows(readFileSync(path, 'utf8')) });
43
+ const text = readFileSync(path, 'utf8');
44
+ plans.push({
45
+ name: `plans/${entry.name}`,
46
+ mtimeMs: status.mtimeMs,
47
+ rows: countImplementationRows(text),
48
+ outside: uncoveredScopePaths(planLinesOutsideFences(text), workspaceRoot),
49
+ });
42
50
  }
43
51
  return plans.sort((left, right) => right.mtimeMs - left.mtimeMs);
44
52
  }
@@ -62,6 +70,8 @@ export function evaluatePlanGate({ event, workspaceRoot, sessionId, ledgerFile }
62
70
  reason = `lit-plan must persist plans/<slug>.md before finishing this planning turn: run \`node ${SCAFFOLD} <slug>\` from the project root, append column-zero \`- [ ] N. <title>\` task rows under "## Todos", then run \`node ${SCAFFOLD} --check plans/<slug>.md\`.`;
63
71
  } else if (newest && newest.rows === 0 && (planMode || since !== null)) {
64
72
  reason = `${newest.name} was written this turn but has no column-zero \`- [ ] 1. <title>\` task row; start-work cannot execute it. Append the task rows under "## Todos", then run \`node ${SCAFFOLD} --check ${newest.name}\`.`;
73
+ } else if (newest && newest.outside.length > 0 && (planMode || since !== null)) {
74
+ reason = `${newest.name} names folders outside the project that start-work would have to stop and ask for. ${newest.outside.map(describeScopePath).join(' ')} Then run \`node ${SCAFFOLD} --check ${newest.name}\`.`;
65
75
  }
66
76
  if (reason === null) return { block: false };
67
77
 
@@ -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 { writeLiveness } from './session-liveness.mjs';
22
23
  import { entryFromPrompt, readWorkflowState, testVerdict, updateWorkflowState, workflowStatePath } from './workflow-stage.mjs';
23
24
 
24
25
  const MAX_EVENT_BYTES = 1024 * 1024;
@@ -30,6 +31,7 @@ const KEPT_SEGMENTS = 2;
30
31
  const ROTATION_LOCK_STALE_MS = 30 * 1000;
31
32
  const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
32
33
  const RECORD_SCHEMA = 'litgrok.hook-event/v1';
34
+ const LIVENESS_BY_EVENT = Object.freeze({ PostToolUse: 'alive', PostToolUseFailure: 'alive', Stop: 'idle', SessionEnd: 'ended' });
33
35
  // A finished call by one of these tools cannot have changed a plan file, so it skips the plan reread.
34
36
  const READ_ONLY_TOOLS = /^(?:read_file|grep|list_dir|glob|todo_write|web_search|web_fetch|read|ls)$/iu;
35
37
  const HUD_DISCIPLINE_PATTERN = /(?:^|[\s/])(lit-scientific-visualization|lit-handoff|lit-brainstorm|lit-adhd|lit-rewind|autoconference|autoresearch|lit-plan|litwork|review-work|lit)(?=$|[^\w-])/iu;
@@ -74,9 +76,13 @@ function maskInlineCode(text) {
74
76
  return output + text.slice(cursor);
75
77
  }
76
78
 
79
+ // A message relayed from another session is quoted data, never this user's route; an unclosed
80
+ // block runs to the end of the prompt.
81
+ const CROSS_SESSION_BLOCK = /<cross-session-message\b[^>]*>[\s\S]*?(?:<\/cross-session-message>|$)/gu;
82
+
77
83
  function visiblePromptText(prompt) {
78
84
  let fence = null;
79
- const lines = prompt.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
85
+ const lines = prompt.replace(CROSS_SESSION_BLOCK, ' ').match(/[^\n]*\n|[^\n]+$/gu) ?? [];
80
86
  const withoutFences = lines.map((line) => {
81
87
  if (fence !== null) {
82
88
  const close = line.match(/^[\t ]{0,3}(`+|~+)[\t ]*(?:\r?\n)?$/u)?.[1];
@@ -438,7 +444,8 @@ export async function recordPassiveEvent(expectedEvent) {
438
444
  const tool = toolRecord(event, expectedEvent);
439
445
  if (tool) record.tool = tool;
440
446
  if (typeof event.promptId === 'string') record.promptId = event.promptId;
441
- const entry = expectedEvent === 'UserPromptSubmit' && typeof event.prompt === 'string' ? entryFromPrompt(visiblePromptText(event.prompt)) : null;
447
+ const visiblePrompt = expectedEvent === 'UserPromptSubmit' && typeof event.prompt === 'string' ? visiblePromptText(event.prompt) : null;
448
+ const entry = visiblePrompt === null ? null : entryFromPrompt(visiblePrompt);
442
449
  const ledgerFile = environment.sessionId === null ? null : ledgerPath(environment.realWorkspaceRoot, environment.sessionId);
443
450
  // A bare `lit` continues the discipline the last explicit switch selected, and with it that discipline's gate.
444
451
  const continued = entry?.entry === 'continue' && ledgerFile !== null
@@ -446,8 +453,8 @@ export async function recordPassiveEvent(expectedEvent) {
446
453
  : null;
447
454
  const selected = entry === null || entry.entry === 'continue' ? undefined
448
455
  : entry.discipline === 'lit-brainstorm' ? 'lit-brainstorm' : entry.stage === 'plan' ? 'lit-plan' : null;
449
- const discipline = expectedEvent === 'UserPromptSubmit' && typeof event.prompt === 'string'
450
- && (/(?:^|[\s/])lit-plan(?:$|[^\w-])/iu.test(event.prompt) || entry?.discipline === 'lit-plan' || continued === 'lit-plan') ? 'lit-plan' : null;
456
+ const discipline = visiblePrompt !== null
457
+ && (/(?:^|[\s/])lit-plan(?:$|[^\w-])/iu.test(visiblePrompt) || entry?.discipline === 'lit-plan' || continued === 'lit-plan') ? 'lit-plan' : null;
451
458
  let hudDiscipline = null;
452
459
  if (expectedEvent === 'UserPromptSubmit') {
453
460
  hudDiscipline = continued ?? (entry?.discipline !== undefined ? entry.discipline : hudDisciplineFromPrompt(event.prompt));
@@ -473,6 +480,14 @@ export async function recordPassiveEvent(expectedEvent) {
473
480
  if (ledgerFile !== null) {
474
481
  appendRecord(ledgerFile, record);
475
482
  await noteWorkflow(ledgerFile, environment, expectedEvent, record, entry?.entry, selected);
483
+ // Liveness is best effort: a refused or failed write never changes this hook's output.
484
+ if (Object.hasOwn(LIVENESS_BY_EVENT, expectedEvent)) {
485
+ try {
486
+ writeLiveness({ ledgerFile, sessionId: environment.sessionId, cwd: environment.eventCwd, state: LIVENESS_BY_EVENT[expectedEvent] });
487
+ } catch {
488
+ // The ledger record above is the durable trace.
489
+ }
490
+ }
476
491
  }
477
492
  if (expectedEvent === 'UserPromptSubmit') {
478
493
  writeHudRecords({
@@ -0,0 +1,15 @@
1
+ {
2
+ "hooks": {
3
+ "SessionEnd": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "session-end.mjs",
9
+ "timeout": 5
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ // No end-reason field is documented, so this records only the session end and marks its liveness `ended`.
4
+ import { recordPassiveEvent } from './record-passive-event.mjs';
5
+
6
+ await recordPassiveEvent('SessionEnd');
@@ -0,0 +1,102 @@
1
+ // Liveness: one small record per session under `.grok/litgrok/liveness/<key>.json`, so the status line
2
+ // and lit-adhd can tell a session that is working (`alive`, from PostToolUse and its failure hook)
3
+ // from one waiting at a turn end (`idle`, from Stop) and one that is gone (`ended`, from SessionEnd).
4
+ // A session that dies without SessionEnd leaves its last record behind; a reader treats a record older
5
+ // than fifteen minutes as ended. The herdr pane id is kept only when it is a plain short token.
6
+
7
+ import { randomUUID } from 'node:crypto';
8
+ import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
9
+ import { basename, dirname, join } from 'node:path';
10
+
11
+ export const LIVENESS_REPEAT_MS = 30 * 1000;
12
+ export const LIVENESS_STALE_MS = 15 * 60 * 1000;
13
+ const LIVENESS_STATES = new Set(['alive', 'idle', 'ended']);
14
+ const MAX_RECORD_BYTES = 4096;
15
+ const PANE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/u;
16
+
17
+ function livenessError(code) {
18
+ const error = new Error(code);
19
+ error.code = code;
20
+ return error;
21
+ }
22
+
23
+ // `.grok/litgrok/session-ledger/<key>.jsonl` -> `.grok/litgrok/liveness/<key>.json`.
24
+ export function livenessPath(ledgerFile) {
25
+ return join(dirname(dirname(ledgerFile)), 'liveness', basename(ledgerFile).replace(/\.jsonl$/u, '.json'));
26
+ }
27
+
28
+ function ensureLivenessDirectory(path) {
29
+ try {
30
+ mkdirSync(path, { mode: 0o700 });
31
+ } catch (error) {
32
+ if (error?.code !== 'EEXIST') throw error;
33
+ }
34
+ const status = lstatSync(path);
35
+ if (!status.isDirectory() || status.isSymbolicLink()) throw livenessError('LIVENESS_DIRECTORY_UNSAFE');
36
+ }
37
+
38
+ export function paneIdFrom(env) {
39
+ const value = env?.HERDR_PANE_ID;
40
+ return typeof value === 'string' && PANE_ID.test(value) ? value : null;
41
+ }
42
+
43
+ function validRecord(record) {
44
+ return record !== null && typeof record === 'object' && !Array.isArray(record)
45
+ && typeof record.sessionId === 'string' && record.sessionId.length > 0 && record.sessionId.length <= 512
46
+ && typeof record.cwd === 'string'
47
+ && LIVENESS_STATES.has(record.state)
48
+ && (record.pane === null || (typeof record.pane === 'string' && PANE_ID.test(record.pane)))
49
+ && typeof record.at === 'string' && Number.isFinite(Date.parse(record.at));
50
+ }
51
+
52
+ function readRaw(path) {
53
+ try {
54
+ const status = lstatSync(path);
55
+ if (!status.isFile() || status.isSymbolicLink() || status.size > MAX_RECORD_BYTES) return null;
56
+ const record = JSON.parse(readFileSync(path, 'utf8'));
57
+ return validRecord(record) ? record : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** The record at `path`, with `state: 'ended'` once it is older than fifteen minutes, or null. */
64
+ export function readLiveness(path, now = Date.now()) {
65
+ const record = readRaw(path);
66
+ if (record === null) return null;
67
+ return now - Date.parse(record.at) > LIVENESS_STALE_MS ? { ...record, state: 'ended' } : record;
68
+ }
69
+
70
+ /**
71
+ * Writes this session's state. Returns `'unchanged'` when the same state and pane were written less
72
+ * than thirty seconds ago, `'written'` otherwise. Refuses a target that is not a regular file.
73
+ */
74
+ export function writeLiveness({ ledgerFile, sessionId, cwd, state, env = process.env, now = Date.now() }) {
75
+ if (!LIVENESS_STATES.has(state)) throw livenessError('LIVENESS_STATE_INVALID');
76
+ const target = livenessPath(ledgerFile);
77
+ ensureLivenessDirectory(dirname(target));
78
+ try {
79
+ const existing = lstatSync(target);
80
+ if (!existing.isFile() || existing.isSymbolicLink()) throw livenessError('LIVENESS_RECORD_UNSAFE');
81
+ } catch (error) {
82
+ if (error?.code !== 'ENOENT') throw error;
83
+ }
84
+ const record = { sessionId, cwd, state, pane: paneIdFrom(env), at: new Date(now).toISOString() };
85
+ if (!validRecord(record)) throw livenessError('LIVENESS_RECORD_INVALID');
86
+ const previous = readRaw(target);
87
+ if (previous && previous.state === state && previous.pane === record.pane && now - Date.parse(previous.at) < LIVENESS_REPEAT_MS) {
88
+ return 'unchanged';
89
+ }
90
+ const temporary = join(dirname(target), `.liveness-${randomUUID()}.tmp`);
91
+ try {
92
+ writeFileSync(temporary, `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
93
+ renameSync(temporary, target);
94
+ } finally {
95
+ try {
96
+ unlinkSync(temporary);
97
+ } catch (error) {
98
+ if (error?.code !== 'ENOENT') throw error;
99
+ }
100
+ }
101
+ return 'written';
102
+ }
@@ -0,0 +1,57 @@
1
+ // The other sessions of this workspace, for the status line's extra rows. Each comes from that
2
+ // session's liveness record and, when it has one, its workflow snapshot; this session's own files are
3
+ // skipped. A session that is working reads `running`, one waiting at a turn end `idle`, and a finished
4
+ // start-work (its work stage closed with every plan task checked, or the done stage) `done` for one
5
+ // minute, then it leaves. An ended or stale session is not shown. Nothing here writes.
6
+
7
+ import { createHash } from 'node:crypto';
8
+ import { readdirSync, realpathSync } from 'node:fs';
9
+ import { isAbsolute, join } from 'node:path';
10
+ import { readLiveness } from './session-liveness.mjs';
11
+ import { readWorkflowState } from './workflow-stage.mjs';
12
+
13
+ export const DONE_SHOWN_MS = 60 * 1000;
14
+ const MAX_ROWS = 4;
15
+ const STATE_ORDER = { running: 0, idle: 1, done: 2 };
16
+ const RECORD_NAME = /^[0-9a-f]{32}\.json$/u;
17
+
18
+ const keyOf = (sessionId) => createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
19
+
20
+ function finished(workflow) {
21
+ return workflow.stage === 'done'
22
+ || (workflow.ended && workflow.stage === 'work' && workflow.plan?.total > 0 && workflow.plan.done === workflow.plan.total);
23
+ }
24
+
25
+ /** Up to four `{ label, pane, state }` rows, running first, then idle, then done; newest first within each. */
26
+ export function readOtherSessions(dirs, selfSessionId, now = Date.now()) {
27
+ const selfName = typeof selfSessionId === 'string' && selfSessionId !== '' ? `${keyOf(selfSessionId)}.json` : null;
28
+ for (const directory of dirs) {
29
+ if (typeof directory !== 'string' || !isAbsolute(directory)) continue;
30
+ let root;
31
+ let names;
32
+ try {
33
+ root = realpathSync(directory);
34
+ names = readdirSync(join(root, '.grok', 'litgrok', 'liveness'));
35
+ } catch {
36
+ continue;
37
+ }
38
+ const rows = [];
39
+ for (const name of names) {
40
+ if (!RECORD_NAME.test(name) || name === selfName) continue;
41
+ const live = readLiveness(join(root, '.grok', 'litgrok', 'liveness', name), now);
42
+ if (!live || `${keyOf(live.sessionId)}.json` !== name) continue;
43
+ const workflow = readWorkflowState(join(root, '.grok', 'litgrok', 'session-ledger', name.replace(/\.json$/u, '.workflow.json')), live.sessionId);
44
+ let state;
45
+ if (workflow && finished(workflow)) {
46
+ if (now - Date.parse(workflow.updatedAt) > DONE_SHOWN_MS) continue;
47
+ state = 'done';
48
+ } else if (live.state === 'alive') state = 'running';
49
+ else if (live.state === 'idle') state = 'idle';
50
+ else continue;
51
+ rows.push({ label: workflow?.active ?? workflow?.stage ?? 'session', pane: live.pane, state, at: Date.parse(live.at) });
52
+ }
53
+ rows.sort((left, right) => STATE_ORDER[left.state] - STATE_ORDER[right.state] || right.at - left.at);
54
+ return rows.slice(0, MAX_ROWS).map(({ at: _at, ...row }) => row);
55
+ }
56
+ return [];
57
+ }
@@ -102,6 +102,7 @@ Judge by this session's own state (earlier banner, plan file, workflow row), nev
102
102
  - A named discipline (`lit plan`, `lit-plan`, `start-work`, `review-work`, `lit review plan|work`) switches: ignite it once, in full.
103
103
  - `lit brainstorm|brainstorming|ideation|ideate|idea|ideas …` opens `lit-brainstorm` (options only, no edits), never deep-interview, lit-plan or the Office and film routes.
104
104
  - An approved plan or an active start-work plus a bare `lit` resumes that work; write no second plan.
105
+ - A bare `go` within 30 minutes (plan mtime) of saving a plan here loads start-work on it.
105
106
  - `lit new …` and `lit restart …` start fresh with the full activation.
106
107
  - A bare `lit` with nothing active asks one question to confirm; `lit fix X` never does.
107
108
 
@@ -48,10 +48,11 @@ node <SKILL_ROOT>/scripts/rewind.mjs --brief --lang <ko|en>
48
48
  2. The brief is the result:
49
49
 
50
50
  - a one-line headline: how many sessions, how many wait on you, how many are active, and the next step in plain words;
51
- - one card per live session grouped by worktree and branch (status badge, goal, a real gauge or the known facts, done, next);
52
- - ended sessions, one table row each, newest first;
53
- - a roadmap timeline (done ✓ with dates, now ▶, later ○);
54
- - **one recommended next step** in a box, with the word to type (`go`), then one line: "Type `go` to continue." When nothing can run on `go`, the box still recommends a real step from the records (a session waiting on the user, an open plan item, the latest active session, the latest ended one) and says that nothing runs on its own.
51
+ - a three-line opener: **Done so far** (finished plans, commits, sessions that ended through SessionEnd), **Doing now** (waiting, active and idle counts) and **Next** (the same one step as the box);
52
+ - one card per live, idle or waiting session grouped by worktree and branch (status badge, goal, a real gauge or the known facts, done, next);
53
+ - done and ended sessions, one table row each, newest first, a done one marked ✓;
54
+ - a roadmap timeline (commits ✓ with dates, finished plans ✓ done, now ▶, later ○);
55
+ - **one recommended next step** in a box (an untagged fence), with the word to type (`go`), then one line: "Type `go` to continue." When nothing can run on `go`, the box still recommends a real step from the records (a session waiting on the user, an open plan item, the first `## Next` item of this worktree's HANDOFF.md that no checked plan row or commit already did, the latest active session, the latest ended one) and says that nothing runs on its own. A step that is already checked or committed is never offered again.
55
56
 
56
57
  **Progress gauges, required.** A gauge appears only when both numbers are real, in the form `[▉▉▉░░] 3/5` (filled `▉` done, light `░` remaining, then done/total). An unknown total has no gauge: the card states what is known in words instead. Never a question-mark gauge, never a guess, never an estimate, never a gauge of your own.
57
58
 
@@ -92,23 +93,26 @@ The collector (`scripts/lib/collect.mjs`, served as `scripts/rewind.mjs`) reads,
92
93
  | Source | What it gives | Missing reads |
93
94
  | --- | --- | --- |
94
95
  | Grok Build session records, `~/.grok/sessions/<encoded cwd>/<session-id>/` | per session: title, first and last ask, tools used, last assistant text, status | `transcripts: unknown` |
95
- | `plans/*.md` checkbox rows | open plans, current item, done/total, remaining names | `plans: unknown` |
96
- | `.handoff/HANDOFF.md`, else root `HANDOFF.md` | headings from the first 40 lines only | `handoffs: unknown` |
96
+ | `plans/*.md` checkbox rows changed inside the window | open and finished plans, current item, done/total, remaining names; plans outside the window or past the read budget are counted as skipped | `plans: unknown` |
97
+ | `.handoff/HANDOFF.md`, else root `HANDOFF.md` | headings from the first 40 lines, and for this worktree the items under `## Next` | `handoffs: unknown` |
98
+ | `.grok/litgrok/liveness/<key>.json`, written by LitGrok's hooks | whether a session is working, waiting at a turn end, or ended through SessionEnd | the transcript's own status stands |
97
99
  | `git log --since` | commits in the window, matched to sessions by time and branch | `git: unknown` |
98
100
  | `herdr agent list` (only when `HERDR_ENV=1`) | agent panes | `herdr: unknown` |
99
101
 
100
- Session files are read head (64 KiB) and tail (256 KiB) only, under one 8 MiB budget for the whole run, through descriptors opened without following links. The store format is private and may move, so every field is read defensively and anything missing reads as unknown. Only the session's own project folder is ever listed: the store is never scanned blindly. Every string is redacted before it leaves the collector: credential shapes, `NAME=value` with a secret-like name, URI userinfo, any current environment value of eight or more characters, and the home directory (shown as `~`).
102
+ Plans and handoffs are read first, so a run whose transcripts spend the budget still shows what is left. Session files are read head (64 KiB) and tail (256 KiB) only, under one 8 MiB budget for the whole run, through descriptors opened without following links. The store format is private and may move, so every field is read defensively and anything missing reads as unknown. Only the session's own project folder is ever listed: the store is never scanned blindly. Every string is redacted before it leaves the collector: credential shapes, `NAME=value` with a secret-like name, URI userinfo, any current environment value of eight or more characters, and the home directory (shown as `~`).
101
103
 
102
104
  Grok Build keeps no litgoal ledger, workflow stack or start-work state file, so those sources stay `unknown` and the offered next step degrades to plans. A paused workflow or a start-work boundary shows in the waiting reason only when the transcript itself says so.
103
105
 
104
106
  ## Status taxonomy
105
107
 
106
108
  - `waiting-on-user`: the session's last word was the assistant asking something (a closing `?`, `lit approve`, `확인해 주세요`, `할까요`…).
107
- - `active`: written to within the last 15 minutes and not waiting.
108
- - `ended`: quiet for longer than that.
109
+ - `active`: written to within the last 15 minutes and not waiting, or a liveness record under 15 minutes old says it is working.
110
+ - `idle`: a liveness record under 15 minutes old says it stopped at a turn end and asked nothing.
111
+ - `done`: its liveness record says it ended through SessionEnd, at any age.
112
+ - `ended`: quiet for longer than 15 minutes, with no SessionEnd record.
109
113
  - `unknown`: the records held no timestamp.
110
114
 
111
- Sessions waiting on the user come first, then active ones, newest first.
115
+ Sessions waiting on the user come first, then active, idle, done and ended ones, newest first.
112
116
 
113
117
  ## The next step
114
118
 
@@ -10,18 +10,23 @@ words change with the language.
10
10
 
11
11
  1. **Headline** (`## 🧭 …`): how many sessions, how many wait on you, how many are active, and the
12
12
  next step in plain words.
13
- 2. **One card per live session** (waiting or active), under a `### 🌿 branch · worktree` group:
14
- - badge: 🟠 waiting on you, 🟢 active, ⚪ ended, ⚫ unknown, with the reason it waits;
13
+ 2. **Opener**, three lines: **Done so far** (finished plans, commits, sessions done), **Doing now**
14
+ (waiting, active and idle counts), **Next** (the one step the box recommends).
15
+ 3. **One card per live session** (waiting, active or idle), under a `### 🌿 branch · worktree` group:
16
+ - badge: 🟠 waiting on you, 🟢 active, 🔵 idle, ⚫ unknown, with the reason it waits;
15
17
  - the goal in bold, from the session's title or its first ask;
16
18
  - a gauge only when both numbers are recorded (a session backed by a plan shows its plan's
17
19
  gauge), otherwise the known facts after ⏳ (how long it ran and its last ask), never a
18
20
  question-mark gauge;
19
21
  - ✅ what was done (edited files, commits, or the tools used before any edit) and ▶ what is next
20
22
  for that session, or what it is doing now.
21
- 3. **Ended sessions**, one table row each.
22
- 4. **Roadmap** timeline: ✓ finished commits with their dates, ▶ the current item of each open plan
23
- with its gauge, ○ the items after it.
24
- 5. **One recommended next step** in a box, and the word to type.
23
+ 4. **Done and ended sessions**, one table row each; a session that ended through SessionEnd is done
24
+ and its id is marked ✓.
25
+ 5. **Roadmap** timeline: ✓ finished commits with their dates, ✓ finished plans, ▶ the current item of
26
+ each open plan with its gauge, ○ the items after it.
27
+ 6. **One recommended next step** in a box (an untagged fence), and the word to type. A step that a
28
+ checked plan row or a commit already did is never offered; with no open plan, the first remaining
29
+ item under this worktree's HANDOFF.md `## Next` is the fallback.
25
30
 
26
31
  ## English
27
32
 
@@ -29,6 +34,10 @@ words change with the language.
29
34
  ## 🧭 3 sessions · 1 waits on you · 1 active · next: Answer sess-wai in proj: “Which window should I start with?”
30
35
  *last 30d · 1 worktree*
31
36
 
37
+ **Done so far:** 1 commit
38
+ **Doing now:** 1 waits on you · 1 active
39
+ **Next:** Answer sess-wai in proj: “Which window should I start with?”
40
+
32
41
  ### 🌿 `unknown` · proj
33
42
 
34
43
  > 🟠 **WAITING ON YOU** (it asked you something) · `sess-wai` · just now
@@ -55,7 +64,7 @@ words change with the language.
55
64
  - ▶ **now** · `plans/brief.md` `[▉░░] 1/3` → 1. Collect records
56
65
  - ○ later · 2. Draw brief
57
66
 
58
- ```text
67
+ ```
59
68
  ╭─ RECOMMENDED NEXT STEP ──────────────────────────────────────╮
60
69
  │ Answer sess-wai in proj: “Which window should I start with?” │
61
70
  ╰──────────────────────────────────────────────────────────────╯
@@ -72,6 +81,10 @@ Type **`go`** to continue.
72
81
  ## 🧭 세션 3개 · 나를 기다리는 것 1 · 진행 중 1 · 다음: proj의 sess-wai에 답하기: “Which window should I start with?”
73
82
  *최근 30d · 워크트리 1개*
74
83
 
84
+ **지금까지 한 일:** 커밋 1개
85
+ **지금 하는 일:** 나를 기다리는 것 1 · 진행 중 1
86
+ **다음:** proj의 sess-wai에 답하기: “Which window should I start with?”
87
+
75
88
  ### 🌿 `unknown` · proj
76
89
 
77
90
  > 🟠 **나를 기다림** (질문에 답을 기다림) · `sess-wai` · 방금
@@ -98,7 +111,7 @@ Type **`go`** to continue.
98
111
  - ▶ **지금** · `plans/brief.md` `[▉░░] 1/3` → 1. Collect records
99
112
  - ○ 이후 · 2. Draw brief
100
113
 
101
- ```text
114
+ ```
102
115
  ╭─ 추천하는 다음 단계 ──────────────────────────────────────────╮
103
116
  │ proj의 sess-wai에 답하기: “Which window should I start with?” │
104
117
  ╰───────────────────────────────────────────────────────────────╯
@@ -1,19 +1,33 @@
1
1
  # lit-adhd status taxonomy
2
2
 
3
3
  The collector assigns every session exactly one status. The brief renders it in the user's language
4
- with the words below and never invents a fifth.
4
+ with the words below and never invents a seventh.
5
5
 
6
6
  | Status | Set when | English | 한국어 |
7
7
  | --- | --- | --- | --- |
8
8
  | `waiting-on-user` | The session's last word was the assistant asking something (a closing `?`, `lit approve`, `확인해 주세요`, `할까요`…) | waiting on you | 나를 기다림 |
9
- | `active` | Written to within the last 15 minutes and not waiting | active | 진행 중 |
10
- | `ended` | Quiet for longer than 15 minutes | ended | 끝남 |
9
+ | `active` | Written to within the last 15 minutes and not waiting, or a liveness record under 15 minutes old says `alive` | active | 진행 중 |
10
+ | `idle` | A liveness record under 15 minutes old says `idle` (a turn ended) and the session asked nothing | idle | 쉬는 중 |
11
+ | `done` | Its liveness record says `ended` (written by the SessionEnd hook), at any age | done | 마침 |
12
+ | `ended` | Quiet for longer than 15 minutes, with no SessionEnd record | ended | 끝남 |
11
13
  | `unknown` | The records held no timestamp | unknown | 모름 |
12
14
 
15
+ Liveness records live at `.grok/litgrok/liveness/<key>.json`, where `<key>` is the first 32 hex digits of
16
+ the SHA-256 of the session id. A question the session asked still wins over any liveness record.
17
+
13
18
  `waitingReason` says why a session waits (`asked the user`); name it in the card's status cell when
14
19
  present: `waiting on you (asked the user)` / `나를 기다림 (질문에 답을 기다림)`.
15
20
 
16
- Order in the brief: waiting sessions first, then active, ended and unknown; newest first inside each group.
21
+ Order in the brief: waiting sessions first, then active, idle, done, ended and unknown; newest first
22
+ inside each group. Waiting, active and idle sessions get cards; done and ended ones share the history
23
+ table, a done row marked ✓.
24
+
25
+ ## Plans
26
+
27
+ A plan counts when its file changed inside the window; this worktree's plans are held to the window
28
+ too. A plan with every row checked stays in the roadmap as finished (`complete: true`, no current item)
29
+ and is counted in `roadmap.plansDone`. A plan outside the window, or one the read budget could not cover,
30
+ is not read and is counted in `roadmap.plansSkipped`; the brief's last line says how many.
17
31
 
18
32
  ## Sources
19
33