@litfamily/litgrok 1.0.27 → 1.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.grok/hooks/lit-status-line.mjs +34 -4
- package/.grok/hooks/plan-gate.mjs +13 -3
- package/.grok/hooks/record-passive-event.mjs +19 -4
- package/.grok/hooks/session-end.json +15 -0
- package/.grok/hooks/session-end.mjs +6 -0
- package/.grok/hooks/session-liveness.mjs +102 -0
- package/.grok/hooks/session-rows.mjs +57 -0
- package/.grok/rules/00-litgrok.md +1 -0
- package/.grok/skills/lit-adhd/SKILL.md +14 -10
- package/.grok/skills/lit-adhd/references/card-examples.md +21 -8
- package/.grok/skills/lit-adhd/references/status-taxonomy.md +18 -4
- package/.grok/skills/lit-adhd/scripts/lib/brief.mjs +61 -10
- package/.grok/skills/lit-adhd/scripts/lib/collect.mjs +93 -25
- package/.grok/skills/lit-adhd/scripts/lib/next-step.mjs +8 -4
- package/.grok/skills/lit-plan/SKILL.md +2 -0
- package/.grok/skills/lit-plan/references/plan-schema.md +4 -0
- package/.grok/skills/lit-plan/scripts/scaffold-plan.mjs +9 -2
- package/.grok/skills/lit-plan/scripts/scope-roots.mjs +83 -0
- package/.grok/skills/start-work/SKILL.md +7 -0
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +9 -9
- package/README_ko-KR.md +9 -9
- package/TRADEMARK.md +12 -0
- package/bin/litgrok.mjs +2 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +7 -3
- package/docs/reference_ko-KR.md +7 -3
- package/hooks/hooks.json +11 -0
- package/package.json +2 -1
- package/plugin.json +1 -1
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
450
|
-
&& (/(?:^|[\s/])lit-plan(?:$|[^\w-])/iu.test(
|
|
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,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
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
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
|
|
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
|
-
- `
|
|
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. **
|
|
14
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
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
|
|