@litfamily/litmuse 0.1.2 → 0.1.3

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.
@@ -4,6 +4,8 @@ import { homedir } from 'node:os';
4
4
  import { run, emitContext, PLUGIN_ROOT, pluginVersion, cachedLatest, newerThan, readSession, sessionPath, workspaceOf } from './lib/common.mjs';
5
5
  import { SKILL_ROUTES } from './lib/routes.mjs';
6
6
  import { readFreshSnapshot } from './lib/handoff-snapshot.mjs';
7
+ import { resolveProjectStateRoot } from './lib/workflow-stack.mjs';
8
+ import { recordLoadedVersion } from './lib/stage.mjs';
7
9
 
8
10
  const STDOUT_BUDGET = 15 * 1024;
9
11
  const STYLE_FILES = { eli5: 'style-eli5.md', 'asd-ste100': 'style-asd-ste100.md' };
@@ -53,7 +55,7 @@ function suggestAdhdOnce(payload) {
53
55
  const cwd = workspaceOf(payload);
54
56
  const rec = readSession(payload);
55
57
  if (rec && rec.route) return null;
56
- const dir = join(cwd, '.litmuse', 'workflow-stack');
58
+ const dir = join(resolveProjectStateRoot(cwd), '.litmuse', 'workflow-stack');
57
59
  const day = 24 * 60 * 60 * 1000;
58
60
  const fresh = readdirSync(dir).some((name) => name.endsWith('.json') && Date.now() - statSync(join(dir, name)).mtimeMs <= day);
59
61
  if (!fresh) return null;
@@ -81,6 +83,7 @@ function fenced(text, room) {
81
83
 
82
84
  run((payload) => {
83
85
  const version = pluginVersion();
86
+ recordLoadedVersion(payload, version);
84
87
  const base = readFileSync(join(PLUGIN_ROOT, 'context', 'session.md'), 'utf8').replaceAll('{{version}}', version);
85
88
  const routes = SKILL_ROUTES.map((r) => `- ${r.id}: ${r.when}`).join('\n');
86
89
  const parts = [base.trim(), `LitMuse routes (skill id: use when)\n${routes}`];
@@ -3,16 +3,31 @@ import { join } from 'node:path';
3
3
  import { run, block, activeRoute, writeSession, workspaceOf } from './lib/common.mjs';
4
4
  import { continuationHold } from './lib/continuation.mjs';
5
5
  import { MOTION_ROUTE, evaluateMotionStop } from './lib/motion.mjs';
6
+ import { putNextStep } from './lib/next-step.mjs';
7
+ import { markLiveness } from './lib/stage.mjs';
6
8
 
9
+ // The newest plan written since the route started, as `plans/<name>.md`, or null.
7
10
  function planWrittenSince(cwd, since) {
8
11
  const dir = join(cwd, 'plans');
9
- if (!existsSync(dir)) return false;
10
- return readdirSync(dir).some((f) => f.endsWith('.md') && statSync(join(dir, f)).mtimeMs >= since);
12
+ if (!existsSync(dir)) return null;
13
+ let best = null;
14
+ for (const f of readdirSync(dir)) {
15
+ if (!f.endsWith('.md')) continue;
16
+ let mtime;
17
+ try {
18
+ mtime = statSync(join(dir, f)).mtimeMs;
19
+ } catch {
20
+ continue; // removed since the listing
21
+ }
22
+ if (mtime >= since && (!best || mtime > best.mtime)) best = { name: `plans/${f}`, mtime };
23
+ }
24
+ return best?.name ?? null;
11
25
  }
12
26
 
13
27
  // The lit-plan gate holds a turn at most once, so a model that cannot write the plan still ends. The motion gate holds
14
28
  // at most twice, then asks the reply to say the render is not done.
15
29
  run((payload) => {
30
+ markLiveness(payload, 'idle');
16
31
  const state = activeRoute(payload);
17
32
  if (!state) return;
18
33
  if (state.route === MOTION_ROUTE) {
@@ -24,8 +39,13 @@ run((payload) => {
24
39
  return;
25
40
  }
26
41
  const turn = payload.turn_id ?? state.turnId ?? 'turn';
42
+ const saved = planWrittenSince(workspaceOf(payload), state.activatedAt ?? 0);
43
+ // A saved plan arms a one-word `go` for this session; the skill's closing line says so.
44
+ if (saved) {
45
+ putNextStep(payload, saved);
46
+ return;
47
+ }
27
48
  if (state.stopHeldFor === turn) return;
28
- if (planWrittenSince(workspaceOf(payload), state.activatedAt ?? 0)) return;
29
49
  writeSession(payload, { ...state, stopHeldFor: turn });
30
50
  block('LitMuse lit-plan gate: this planning turn has no plan file yet. Write the plan to plans/<slug>.md (task rows as "- [ ] N. <title>"), then end the turn and point the user to /start-work.');
31
51
  });
@@ -164,7 +164,7 @@ run((payload) => {
164
164
  stackStart(payload, 'start-work', { plan }, false);
165
165
  recordRoute(payload, { route: 'start-work', keyword: 'go', kind: 'ignite', plan, activatedAt: Date.now() });
166
166
  emitContext('UserPromptSubmit', [
167
- ...activation({ route: 'start-work', keyword: 'go', bare: false, resume: false, restart: false, trailing: false }, payload),
167
+ activation({ route: 'start-work', keyword: 'go', bare: false, resume: false, restart: false, trailing: false }, payload),
168
168
  `Plan: ${plan}. The typed go is the approval: the work starts without a separate approval turn; work through its open rows in order.`,
169
169
  ].join('\n'));
170
170
  return;
@@ -6,7 +6,7 @@
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, existsSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
- import { basename, dirname, join, resolve } from 'node:path';
9
+ import { basename, join, resolve } from 'node:path';
10
10
 
11
11
  import { parsePlanTaskRows } from '../plan-task-rows.mjs';
12
12
  import { PULSE_UNKNOWN, aggregateProgress, clipToWidth, displayWidth, progressOf } from './gauge.mjs';
@@ -14,6 +14,8 @@ import { chooseNextStep, writeNextStep } from './next-step.mjs';
14
14
  import { makeRedactor } from './redact.mjs';
15
15
  import { findTranscripts, makeBudget, readTranscriptLines, sessionRecord } from './transcripts.mjs';
16
16
  import { rainbowLine } from '../../../lib/stage-card.mjs';
17
+ import { projectKey, resolveProjectStateRoot } from '../../hooks/lib/workflow-stack.mjs';
18
+ import { readLiveness } from '../../hooks/lib/stage.mjs';
17
19
 
18
20
  const SMALL_FILE_BYTES = 1024 * 1024;
19
21
  const HERDR_TIMEOUT_MS = 2000;
@@ -67,18 +69,6 @@ const entries = (directory) => {
67
69
 
68
70
  const defaultExec = (file, args, options) => execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...options });
69
71
 
70
- const outermostRoot = (startDir) => {
71
- const start = resolve(startDir);
72
- let root = null;
73
- let current = start;
74
- for (;;) {
75
- if (existsSync(join(current, '.git'))) root = current;
76
- const parent = dirname(current);
77
- if (parent === current) return root ?? start;
78
- current = parent;
79
- }
80
- };
81
-
82
72
  const worktreesOf = (cwd, exec) => {
83
73
  try {
84
74
  const out = exec('git', ['worktree', 'list', '--porcelain'], { cwd, timeout: GIT_TIMEOUT_MS });
@@ -99,24 +89,37 @@ const mtimeOf = (path) => {
99
89
  }
100
90
  };
101
91
 
102
- const plansIn = (root, budget, redact, local) => {
92
+ // Every plan with real rows, finished ones included (a finished plan is done work, not a missing
93
+ // one). A plan the read budget can no longer cover whole is counted in `skipped`, never half-read.
94
+ const plansIn = (root, budget, redact, local, skipped) => {
103
95
  const list = entries(join(root, 'plans'));
104
96
  if (!list) return null;
105
97
  return list.filter((entry) => entry.isFile() && entry.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
106
- const text = readSmallFile(join(root, 'plans', entry.name), budget);
98
+ const path = join(root, 'plans', entry.name);
99
+ let size = 0;
100
+ try {
101
+ size = lstatSync(path).size;
102
+ } catch {
103
+ return [];
104
+ }
105
+ if (budget.limit - budget.used < Math.min(size, SMALL_FILE_BYTES)) {
106
+ budget.exhausted = true;
107
+ skipped.count += 1;
108
+ return [];
109
+ }
110
+ const text = readSmallFile(path, budget);
107
111
  if (text === null) return [];
108
112
  const rows = parsePlanTaskRows(text);
109
113
  if (!rows.length || isTemplate(rows)) return [];
110
114
  const open = rows.filter((row) => !row.checked);
111
- if (!open.length) return [];
112
115
  const done = rows.length - open.length;
113
116
  return [{
114
117
  worktree: redact(root),
115
118
  plan: `plans/${entry.name}`,
116
119
  local,
117
- changedMs: mtimeOf(join(root, 'plans', entry.name)),
120
+ changedMs: mtimeOf(path),
118
121
  rows: rows.map((row) => ({ checked: row.checked, title: clipToWidth(redact(row.text), NAME_MAX) })),
119
- current: clipToWidth(redact(open[0].text), NAME_MAX),
122
+ current: open.length ? clipToWidth(redact(open[0].text), NAME_MAX) : null,
120
123
  progress: progressOf(done, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX))),
121
124
  }];
122
125
  });
@@ -141,7 +144,21 @@ const goalsOf = (root, budget, redact) => {
141
144
  });
142
145
  };
143
146
 
144
- const handoffsOf = (root, budget, redact) => {
147
+ // The first item under a `## Next` or `## Next Steps` heading, list marker removed, or null.
148
+ const nextItemOf = (text) => {
149
+ const lines = text.split('\n');
150
+ const at = lines.findIndex((line) => /^#{1,3} Next(?: Steps)?\s*$/iu.test(line.trim()));
151
+ if (at < 0) return null;
152
+ for (const line of lines.slice(at + 1)) {
153
+ if (/^#{1,6} /u.test(line)) return null;
154
+ const item = line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/u, '').trim();
155
+ if (item) return item;
156
+ }
157
+ return null;
158
+ };
159
+
160
+ // Handoff headings from the first 40 lines; for this worktree's own files only, the Next item too.
161
+ const handoffsOf = (root, budget, redact, local) => {
145
162
  const files = (entries(root) ?? [])
146
163
  .filter((entry) => entry.isFile() && /^HANDOFF.*\.md$/u.test(entry.name))
147
164
  .map((entry) => join(root, entry.name));
@@ -150,7 +167,8 @@ const handoffsOf = (root, budget, redact) => {
150
167
  return files.map((path) => {
151
168
  const text = readSmallFile(path, budget) ?? '';
152
169
  const headings = text.split('\n').slice(0, 40).filter((line) => /^#{1,3} /u.test(line)).map((line) => clipToWidth(redact(line), 100));
153
- return { worktree: redact(root), file: basename(path), headings };
170
+ const next = local ? nextItemOf(text) : null;
171
+ return { worktree: redact(root), file: basename(path), headings, local, next: next ? clipToWidth(redact(next), 100) : null };
154
172
  });
155
173
  };
156
174
 
@@ -189,29 +207,49 @@ const stacksOf = (root, budget, redact) => {
189
207
  });
190
208
  };
191
209
 
192
- // This host has no start-work lifecycle state: the session's own stage record says whether a
193
- // start-work run is the live route and which plan it carries. Boundaries are not tracked here,
194
- // so a paused boundary never comes from this source.
195
- const startWorkOf = (root, budget, redact, env, sessionId) => {
196
- const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
197
- const file = sessionId ? join(stateHome, 'litmuse', 'stage', `${String(sessionId).replace(/[^A-Za-z0-9_-]/gu, '_').slice(0, 128)}.json`) : null;
198
- const record = file ? readJson(file, budget) : null;
199
- if (!record || record.route !== 'start-work') return null;
200
- const plan = typeof record.plan === 'string' && /^plans\/[A-Za-z0-9._-]+\.md$/u.test(record.plan) ? record.plan : null;
210
+ // This host has no start-work lifecycle state: the stage records say it. This session's record
211
+ // says whether a start-work run is the live route and which plan it carries; any session's record
212
+ // whose plan is fully ticked is a completed run (`complete`, with the plan's change time). Only a
213
+ // start-work route stores a plan on the record, so a plan there means a run. Boundaries are not
214
+ // tracked here, so a paused boundary never comes from this source.
215
+ const PLAN_NAME = /^plans\/[A-Za-z0-9._-]+\.md$/u;
216
+ const stageDirOf = (env) => join(env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), 'litmuse', 'stage');
217
+ const STAGE_RECORDS_MAX = 200;
218
+
219
+ const runOf = (root, record, sessionId, budget, redact) => {
220
+ const plan = typeof record.plan === 'string' && PLAN_NAME.test(record.plan) ? record.plan : null;
201
221
  const planText = plan ? readSmallFile(join(root, plan), budget) : null;
202
222
  const rows = planText === null ? [] : parsePlanTaskRows(planText);
203
223
  const open = rows.filter((row) => !row.checked);
204
- return [{
224
+ const id = typeof record.session === 'string' ? record.session : sessionId ?? null;
225
+ return {
205
226
  worktree: redact(root),
206
- workId: `stage-${record.session ?? sessionId ?? 'unknown'}`,
227
+ workId: `stage-${id ?? 'unknown'}`,
207
228
  plan: plan ?? 'unknown',
208
- status: 'active',
209
- sessionId: typeof record.session === 'string' ? record.session : sessionId ?? null,
210
- id8: typeof (record.session ?? sessionId) === 'string' ? String(record.session ?? sessionId).slice(0, 8) : 'unknown',
229
+ status: rows.length && !open.length ? 'complete' : 'active',
230
+ sessionId: id,
231
+ id8: typeof id === 'string' ? id.slice(0, 8) : 'unknown',
232
+ completedMs: rows.length && !open.length ? mtimeOf(join(root, plan)) : null,
211
233
  progress: rows.length
212
234
  ? progressOf(rows.length - open.length, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX)))
213
235
  : progressOf(null, null),
214
- }];
236
+ };
237
+ };
238
+
239
+ const startWorkOf = (root, budget, redact, env, sessionId) => {
240
+ const dir = stageDirOf(env);
241
+ const own = sessionId ? readJson(join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/gu, '_').slice(0, 128)}.json`), budget) : null;
242
+ const runs = own && own.route === 'start-work' ? [runOf(root, own, sessionId, budget, redact)] : [];
243
+ const list = entries(dir);
244
+ const here = projectKey(root);
245
+ for (const entry of (list ?? []).filter((item) => item.isFile() && item.name.endsWith('.json') && item.name !== 'latest.json').slice(0, STAGE_RECORDS_MAX)) {
246
+ const record = readJson(join(dir, entry.name), budget);
247
+ if (!record || typeof record.session !== 'string' || typeof record.cwd !== 'string' || !PLAN_NAME.test(String(record.plan))) continue;
248
+ if (projectKey(record.cwd) !== here) continue;
249
+ const run = runOf(root, record, null, budget, redact);
250
+ if (run.status === 'complete' && !runs.some((item) => item.workId === run.workId)) runs.push(run);
251
+ }
252
+ return runs.length || own ? runs : null;
215
253
  };
216
254
 
217
255
  const herdrOf = (env, exec, redact) => {
@@ -231,7 +269,9 @@ const sessionStoreDir = (env) => {
231
269
  };
232
270
 
233
271
  const known = (list) => (list === null ? 'unknown' : 'ok');
234
- const statusRank = { 'waiting-on-user': 0, active: 1, ended: 2, unknown: 3 };
272
+ const statusRank = { 'waiting-on-user': 0, active: 1, idle: 2, done: 3, ended: 4, unknown: 5 };
273
+ // A host may log a line or two after the Stop hook or the last plan tick; this much lag still counts.
274
+ const SETTLE_SLACK_MS = 30_000;
235
275
 
236
276
  /**
237
277
  * Collects the brief. Options: `cwd`, `here` (cwd only), `since` (`7d`), `max` (12), `sessionId`
@@ -254,10 +294,30 @@ export const collectRewind = ({
254
294
  const sinceMs = parseSince(since) ?? parseSince(DEFAULT_SINCE);
255
295
  const limit = Number.isInteger(max) && max > 0 ? Math.min(max, 50) : DEFAULT_MAX;
256
296
  const start = resolve(cwd);
257
- const stateRoot = outermostRoot(start);
297
+ const stateRoot = resolveProjectStateRoot(start);
258
298
  const listed = here ? null : worktreesOf(start, exec);
259
299
  const roots = [...new Set([start, ...(here ? [] : listed ?? [stateRoot])])];
260
300
 
301
+ // The small records come first, then transcripts: a busy session store must never crowd the
302
+ // plans out of the shared read budget.
303
+ const isLocal = (root) => resolve(root) === stateRoot || resolve(root) === start;
304
+ const collectEach = (read) => {
305
+ const results = roots.map(read);
306
+ return results.every((value) => value === null) ? null : results.flatMap((value) => value ?? []);
307
+ };
308
+ const skippedPlans = { count: 0 };
309
+ const plans = collectEach((root) => plansIn(root, budget, redact, isLocal(root), skippedPlans));
310
+ const litgoal = collectEach((root) => {
311
+ const goals = goalsOf(root, budget, redact);
312
+ return goals ? goals.map((goal) => ({ ...goal, local: isLocal(root) })) : null;
313
+ });
314
+ const handoffs = roots.flatMap((root) => handoffsOf(root, budget, redact, isLocal(root)));
315
+ const workflows = collectEach((root) => stacksOf(root, budget, redact));
316
+ const startWorkRuns = collectEach((root) => startWorkOf(root, budget, redact, env, sessionId));
317
+ const startWork = startWorkRuns && startWorkRuns.filter((run, index) => startWorkRuns.findIndex((item) => item.workId === run.workId) === index);
318
+ const commits = collectEach((root) => commitsOf(root, sinceMs, now, exec, redact));
319
+ const herdr = herdrOf(env, exec, redact);
320
+
261
321
  const transcripts = findTranscripts(sessionStoreDir(env), roots);
262
322
  const sessions = [];
263
323
  for (const file of transcripts.files) {
@@ -269,22 +329,26 @@ export const collectRewind = ({
269
329
  sessions.push(session);
270
330
  }
271
331
 
272
- const collectEach = (read) => {
273
- const results = roots.map(read);
274
- return results.every((value) => value === null) ? null : results.flatMap((value) => value ?? []);
275
- };
276
- const plans = collectEach((root) => plansIn(root, budget, redact, resolve(root) === stateRoot || resolve(root) === start));
277
- const litgoal = collectEach((root) => {
278
- const goals = goalsOf(root, budget, redact);
279
- return goals ? goals.map((goal) => ({ ...goal, local: resolve(root) === stateRoot || resolve(root) === start })) : null;
280
- });
281
- const handoffs = roots.flatMap((root) => handoffsOf(root, budget, redact));
282
- const commits = collectEach((root) => commitsOf(root, sinceMs, now, exec, redact));
283
- const workflows = collectEach((root) => stacksOf(root, budget, redact));
284
- const startWork = collectEach((root) => startWorkOf(root, budget, redact, env, sessionId));
285
- const herdr = herdrOf(env, exec, redact);
332
+ // The hooks' liveness records: Stop marks a session idle, SessionEnd marks it ended.
333
+ const liveness = new Map(readLiveness(env).map((item) => [item.session, item]));
286
334
 
287
335
  // A session waits on the user when it asked something, or holds a paused frame or a boundary.
336
+ // Another session that is otherwise active reads `done` when its start-work run completed after its
337
+ // last log line, and `idle` when its last hook event is a Stop no older than that line.
338
+ for (const session of sessions) {
339
+ const live = liveness.get(session.sessionId);
340
+ const finished = (startWork ?? []).find((item) => item.status === 'complete' && item.sessionId === session.sessionId);
341
+ const self = Boolean(sessionId) && session.sessionId === sessionId;
342
+ if (!self && session.status === 'active' && session.lastMs !== null) {
343
+ if (finished && finished.completedMs !== null && finished.completedMs >= session.lastMs - SETTLE_SLACK_MS) {
344
+ session.status = 'done';
345
+ session.finishedPlan = finished.plan;
346
+ } else if (live?.state === 'idle' && Date.parse(live.at) >= session.lastMs - SETTLE_SLACK_MS) {
347
+ session.status = 'idle';
348
+ }
349
+ }
350
+ if (!self && live?.state === 'ended' && session.status !== 'waiting-on-user') session.status = 'ended';
351
+ }
288
352
  for (const session of sessions) {
289
353
  const frames = (workflows ?? []).filter((frame) => frame.sessionId === session.sessionId);
290
354
  const top = frames.at(-1);
@@ -308,11 +372,12 @@ export const collectRewind = ({
308
372
  }
309
373
  sessions.sort((a, b) => statusRank[a.status] - statusRank[b.status] || (b.lastMs ?? 0) - (a.lastMs ?? 0));
310
374
 
311
- // This worktree's open plans and the ones another worktree touched inside the window are listed,
312
- // this one's first, then the most recently changed, up to a short cap; a stale plan elsewhere is
313
- // left out entirely. Unfilled scaffolds never reach the list (plansIn drops them). The roadmap gauge
314
- // counts this worktree only, since a sum over unrelated branches measures nothing.
315
- const recent = (plan) => plan.local || (plan.changedMs !== null && now - plan.changedMs <= sinceMs);
375
+ // Plans changed inside the window are listed, this worktree's included, finished ones too (they
376
+ // count as done): this worktree's first, then the most recently changed, up to a short cap; a plan
377
+ // last changed before the window is left out, here or elsewhere. Unfilled scaffolds never reach the
378
+ // list (plansIn drops them). The roadmap gauge counts this worktree only, since a sum over unrelated
379
+ // branches measures nothing.
380
+ const recent = (plan) => plan.changedMs !== null && now - plan.changedMs <= sinceMs;
316
381
  const orderedPlans = (plans ?? []).filter(recent).sort((a, b) => Number(b.local) - Number(a.local) || (b.changedMs ?? 0) - (a.changedMs ?? 0));
317
382
  const shownPlans = orderedPlans.slice(0, PLAN_LIMIT);
318
383
  const localGoals = (litgoal ?? []).filter((goal) => goal.local);
@@ -341,6 +406,7 @@ export const collectRewind = ({
341
406
  roadmap: {
342
407
  plans: shownPlans,
343
408
  plansOmitted: orderedPlans.length - shownPlans.length,
409
+ plansSkipped: skippedPlans.count,
344
410
  // Another worktree's finished objective is history, not roadmap.
345
411
  litgoal: (litgoal ?? []).filter((goal) => goal.local || goal.status !== 'complete'),
346
412
  progress: roadmapItems.length ? aggregateProgress(roadmapItems) : progressOf(null, null),
@@ -353,6 +419,7 @@ export const collectRewind = ({
353
419
  };
354
420
  const next = chooseNextStep(data, sessionId);
355
421
  data.next = next;
422
+ data.opener = openerOf(data, sessionId, redact(start));
356
423
  data.nextRecorded = false;
357
424
  if (record && sessionId) {
358
425
  try {
@@ -382,10 +449,12 @@ const remainingLine = (names, width) => {
382
449
  // ANSI pieces never reach the width math: every row is clipped raw, then painted.
383
450
  const ESC = '\u001b[';
384
451
  const paint = (code, text) => `${ESC}${code}m${text}${ESC}0m`;
385
- const STATUS_BADGE = { 'waiting-on-user': '● waiting on you', active: '● ACTIVE', ended: '○ Ended', unknown: '○ unknown' };
452
+ const STATUS_BADGE = { 'waiting-on-user': '● waiting on you', active: '● ACTIVE', idle: '◌ idle', done: '✓ done', ended: '○ Ended', unknown: '○ unknown' };
386
453
  const BADGE_PAINT = {
387
454
  'waiting-on-user': (text) => paint('33;1', text),
388
455
  active: (text) => paint('32;1', text),
456
+ idle: (text) => paint('2', text),
457
+ done: (text) => paint('32', text),
389
458
  ended: (text) => paint('2', text),
390
459
  unknown: (text) => paint('2', text),
391
460
  };
@@ -412,6 +481,7 @@ const sessionNext = (session, data) => {
412
481
  const frame = data.workflows.find((item) => item.id8 === session.id8 && item.status === 'paused' && data.next.say);
413
482
  if (frame) return `say "${data.next.say}" to resume the paused ${frame.skill}`;
414
483
  if (session.status === 'waiting-on-user') return session.waitingReason === 'start-work boundary' ? 'answer the boundary: `lit approve` or `lit deny`' : 'answer its question';
484
+ if (session.status === 'done' && session.finishedPlan) return `start-work finished ${session.finishedPlan}`;
415
485
  return null;
416
486
  };
417
487
 
@@ -489,8 +559,10 @@ const sessionCard = (session, data, emitLine, now) => {
489
559
  const commits = (session.done?.commits ?? []).map((commit) => `${commit.hash} ${commit.subject}`);
490
560
  if (tools || commits.length) emitLine([['✅ Done: ', (text) => paint('32', text)], [[tools, ...commits].filter(Boolean).join(' · '), null]]);
491
561
  // A card with no recorded edit yet names what it is doing now: its last tool call, name only.
492
- // (This host's log keeps no file list, so commits are the only edits it can show.)
493
- if (!commits.length && !(session.done?.files ?? []).length && session.lastAction?.tool) emitLine([['▶ Now: ', (text) => paint('33', text)], [session.lastAction.tool, null]]);
562
+ // (This host's log keeps no file list, so commits are the only edits it can show.) An idle or done
563
+ // session is doing nothing now, so its last call is history and never shown as current.
564
+ const settled = session.status === 'idle' || session.status === 'done';
565
+ if (!settled && !commits.length && !(session.done?.files ?? []).length && session.lastAction?.tool) emitLine([['▶ Now: ', (text) => paint('33', text)], [session.lastAction.tool, null]]);
494
566
  const next = sessionNext(session, data);
495
567
  if (next) emitLine([['➤ Next: ', (text) => paint('36;1', text)], [next, null]]);
496
568
  };
@@ -566,11 +638,60 @@ const suggestion = (data) => {
566
638
  if (plan) return { text: `Next in ${plan.local ? plan.plan : `${basename(String(plan.worktree))}/${plan.plan}`}: ${clipToWidth(String(plan.current), 60)}`, say: 'go' };
567
639
  const active = sessions.find((session) => session.status === 'active' && !session.self);
568
640
  if (active) return { text: `Check on ${active.id8} in ${place(active)} (${goal(active)})`, say: null };
569
- const ended = sessions.find((session) => session.status === 'ended' && !session.self);
570
- if (ended) return { text: `Pick up ${ended.id8} in ${place(ended)}: ${goal(ended)}`, say: null };
641
+ // Then the newest session that is not running: review what a done one finished, or pick up an
642
+ // idle or ended one. A finished step is never offered again as the thing to do.
643
+ const settled = sessions.find((session) => ['done', 'idle', 'ended'].includes(session.status) && !session.self);
644
+ if (settled?.status === 'done') return { text: `Review what ${settled.id8} finished in ${place(settled)}: ${settled.finishedPlan ?? goal(settled)}`, say: null };
645
+ if (settled) return { text: `Pick up ${settled.id8} in ${place(settled)}: ${goal(settled)}`, say: null };
571
646
  return { text: 'No session or plan in this window: say what to start', say: null };
572
647
  };
573
648
 
649
+ // With no step for `go`, this worktree's HANDOFF Next item comes before the generic suggestion; it
650
+ // is shown only, never something `go` runs.
651
+ const handoffNextOf = (data) => (data.handoffs ?? []).find((handoff) => handoff.local && handoff.next)?.next ?? null;
652
+ const stepOf = (data) => {
653
+ if (data.next?.kind && data.next.kind !== 'none') return null;
654
+ const handoff = handoffNextOf(data);
655
+ return handoff ? { text: handoff, say: null } : suggestion(data);
656
+ };
657
+ const goStepText = (next) => {
658
+ const target = next.target?.plan ?? next.target?.skill ?? '';
659
+ return `${next.kind.replace(/-/g, ' ')}${target ? `: ${target}` : ''}${next.say ? ` (say "${next.say}")` : ''}`;
660
+ };
661
+
662
+ /**
663
+ * The three lines that open the brief, from records only. Done: this worktree's newest commits in the
664
+ * window (two at most), then its completed start-work runs and fully ticked plans (three items in
665
+ * all), else its finished goal. Doing: this session's top frame, else its running start-work, else
666
+ * the newest active session's goal. Next: the recommended step and the word to say.
667
+ */
668
+ const openerOf = (data, sessionId, here) => {
669
+ const subjects = data.commits.filter((commit) => commit.worktree === here).slice(0, 2).map((commit) => commit.subject);
670
+ const runs = data.startWork.filter((work) => work.status === 'complete' && work.worktree === here);
671
+ const runPlans = new Set(runs.map((work) => work.plan));
672
+ const finished = [
673
+ ...runs.map((work) => `${work.plan} (start-work complete)`),
674
+ ...data.roadmap.plans.filter((plan) => plan.local && plan.current === null && !runPlans.has(plan.plan)).map((plan) => `${plan.plan} ${plan.progress.gauge}`),
675
+ ];
676
+ const items = [...subjects, ...new Set(finished)].slice(0, 3);
677
+ const goal = data.roadmap.litgoal.find((item) => item.local && item.status === 'complete');
678
+ const frame = data.workflows.filter((item) => Boolean(sessionId) && item.sessionId === sessionId).at(-1);
679
+ const work = data.startWork.find((item) => item.status === 'active' && Boolean(sessionId) && item.sessionId === sessionId);
680
+ const active = data.sessions.find((session) => session.status === 'active');
681
+ const known = (part) => part && part !== 'unknown';
682
+ const doing = frame
683
+ ? [frame.skill, frame.stage, frame.progress?.gauge].filter(known).join(' · ')
684
+ : work
685
+ ? ['start-work', work.plan, work.progress?.gauge].filter(known).join(' · ')
686
+ : active ? clipToWidth(String(known(active.title) ? active.title : active.asked?.[0] ?? 'unknown'), 80) : 'nothing running';
687
+ const step = stepOf(data);
688
+ return {
689
+ done: items.length ? items.join('; ') : goal ? goal.objective : 'nothing finished in this window',
690
+ doing,
691
+ next: step ? `${step.text}${step.say ? ` (say "${step.say}")` : ''}` : goStepText(data.next),
692
+ };
693
+ };
694
+
574
695
  // Wraps a step to the box at word breaks, three lines at most, the last one clipped.
575
696
  const wrap = (text, width, rows = 3) => {
576
697
  const out = [''];
@@ -590,9 +711,15 @@ export const renderRewind = (data, width = 100, { color = false } = {}) => {
590
711
  const emitLine = (segments) => emit(out, color, width, segments);
591
712
  const put = (raw, fn = null) => emitLine([[raw, fn]]);
592
713
  const frame = Math.max(width, 20);
593
- const step = data.next?.kind && data.next.kind !== 'none' ? null : suggestion(data);
714
+ const step = stepOf(data);
594
715
  const nextKind = data.next.kind.replace(/-/g, ' ');
595
716
  const nextSay = data.next.say ? ` (say "${data.next.say}")` : '';
717
+ // Three plain lines open the brief: done so far, doing now, next.
718
+ if (data.opener) {
719
+ put(`Done so far: ${data.opener.done}`);
720
+ put(`Doing now: ${data.opener.doing}`);
721
+ put(`Next: ${data.opener.next}`);
722
+ }
596
723
  // The one static rainbow line of this surface marks the adhd turn (no animation on this host).
597
724
  const headline = `lit-adhd: ${data.summary.sessions} session(s) · ${data.summary.waitingOnUser} waiting on you · next: ${step ? step.text : `${nextKind}${nextSay}`}`;
598
725
  put(headline, color ? (text) => rainbowLine(text) : null);
@@ -646,6 +773,8 @@ export const renderRewind = (data, width = 100, { color = false } = {}) => {
646
773
  put('Nothing runs on its own here: open that session, or say what to do.');
647
774
  const unread = Object.entries(data.sources).filter(([, status]) => status !== 'ok').map(([name]) => SOURCE_LABELS[name] ?? name);
648
775
  if (unread.length) put(`Not read: ${unread.join(', ')}.`);
776
+ const skipped = data.roadmap.plansSkipped ?? 0;
777
+ if (skipped > 0) put(`${skipped} ${skipped === 1 ? 'plan was' : 'plans were'} not read (read budget used up).`);
649
778
  return `${out.join('\n')}\n`;
650
779
  };
651
780
 
@@ -17,6 +17,8 @@ const ACTIVE_MS = 15 * 60 * 1000;
17
17
  export const makeBudget = (limit = TOTAL_BUDGET_BYTES) => ({ limit, used: 0, exhausted: false });
18
18
 
19
19
  const readSlice = (fd, position, length, budget) => {
20
+ // An empty slice (a head-only peek) reads nothing; it must not mark the budget used up.
21
+ if (length <= 0) return '';
20
22
  const allowed = Math.min(length, budget.limit - budget.used);
21
23
  if (allowed <= 0) {
22
24
  budget.exhausted = true;
@@ -47,14 +47,15 @@ verdicts: [PASS, FAIL, BLOCKED]
47
47
 
48
48
  ## #contract.outputs
49
49
 
50
- The brief has six parts, in this order:
50
+ The brief opens with three lines, then has six parts, in this order:
51
51
 
52
+ 0. **Three opening lines**, copied from `opener` in the user's language: `Done so far:` (`opener.done`: this worktree's newest commits in the window, two at most, then its completed start-work runs as `<plan> (start-work complete)` and its fully ticked plans with their gauge, three items in all, else its finished goal, else "nothing finished in this window"); `Doing now:` (`opener.doing`: this session's top frame with stage and gauge, else its running start-work plan, else the newest active session's goal, else "nothing running"); `Next:` (`opener.next`: the one next step, with the word to say in brackets). In Korean: `지금까지:`, `지금:`, `다음:`.
52
53
  1. **Headline.** Sessions found, how many wait on the user, the one next step: `7 sessions · 1 waiting on you · next: resume frame (say "lit continue")` / `세션 7개 · 나를 기다리는 것 1 · 다음: resume frame ("lit continue")`.
53
- 2. **One card per live session.** Sessions that wait on the user or are active come first, grouped under their worktree. A status badge (`● waiting on you`, `● ACTIVE`, in the user's language) with the id, the route when known, and the age; the goal in plain words; the known facts (`⏳ 3m of work · last ask: "…"`, in the user's language); a real gauge with what is left; what was done (`✅ Done:`, tools named never counted, and commits); what it is doing now when no edit is on record (`▶ Now:`, the last tool call by name); what is next (`➤ Next:`).
54
+ 2. **One card per live session.** Sessions that wait on the user come first, then active, idle and done ones, grouped under their worktree. A status badge (`● waiting on you`, `● ACTIVE`, `◌ idle`, `✓ done`, in the user's language) with the id, the route when known, and the age; the goal in plain words; the known facts (`⏳ 3m of work · last ask: "…"`, in the user's language); a real gauge with what is left; what was done (`✅ Done:`, tools named never counted, and commits); what it is doing now when no edit is on record (`▶ Now:`, the last tool call by name, for a session that is still running); what is next (`➤ Next:`). A done session's next line names the plan its run finished (`start-work finished plans/a.md`); an idle or done session's last tool call is history, never offered as its next step.
54
55
  3. **Ended sessions, one bordered table.** Ended sessions collapse into a ruled table (`id | tree | goal | ago | done`), at most 8 rows with a count of the rest. The age reads the largest unit (`6h`, not `6h 40m`); the done cell reads commits or the file count, never both.
55
- 4. **Roadmap timeline.** Done `✓` (finished plans and landed commits with dates), now `▶` (the current item of each open plan), later `○` (remaining names), then goal objectives with their gauges. Plans from other worktrees carry their worktree name, and only the ones touched inside the window are listed; unfilled plan scaffolds never appear. When `plansOmitted` is above zero, say how many more open plans exist.
56
+ 4. **Roadmap timeline.** Done `✓` (finished plans, which count toward the roadmap gauge, and landed commits with dates), now `▶` (the current item of each open plan), later `○` (remaining names), then goal objectives with their gauges. Every plan is held to the window: only plans changed inside it are listed, this worktree's included, and plans from other worktrees carry their worktree name; unfilled plan scaffolds never appear. When `plansOmitted` is above zero, say how many more open plans exist; when `roadmap.plansSkipped` is above zero, say on the last line how many plans were not read because the read budget ran out.
56
57
  5. **Paused work.** Each paused frame from `workflows` with its gauge and a resume offer: `lit-loop [▉▉▉░░░░] 3/7 paused, resume?` / `lit-loop [▉▉▉░░░░] 3/7 일시정지, 이어갈까요?`.
57
- 6. **One next step in a box.** Exactly one, from `next`: what it does, then "say go" (or the `say` word). When nothing can run on `go`, the box still recommends a real step from the records (a session waiting on the user, a paused workflow, an open plan item, the latest active session, the latest ended one) and says that nothing runs on its own.
58
+ 6. **One next step in a box.** Exactly one, from `next`: what it does, then "say go" (or the `say` word). When nothing can run on `go`, the box still recommends a real step from the records, in this order: the first item under `## Next` or `## Next Steps` in this worktree's own `HANDOFF*.md` (`handoffs[].next`), a session waiting on the user, a paused workflow, an open plan item, the latest active session, then the newest session that is not running (review what a done one finished, or pick up an idle or ended one). It says that nothing runs on its own, and a HANDOFF step never carries a word to type. A step that is already done is never offered again.
58
59
 
59
60
  **Progress gauges (D9), required.** One real gauge per session and per plan, each copied verbatim from the collector, in the form `[▉▉▉░░] 3/5` (filled `▉` done, light `░` remaining, then done/total). The remaining items are listed by name under each gauge, from its `remaining` array, clipped by the collector to fit. A gauge renders only when both numbers are real: an unknown total is the known-facts line (`… 3 steps`, or `… running` when nothing is counted), never `?`, never a guess, never an estimate, never a gauge of your own.
60
61
 
@@ -116,24 +117,27 @@ The collector (`plugin/lib/rewind/collect.mjs`, served as `litmuse rewind`) read
116
117
  | Source | What it gives | Missing reads |
117
118
  | --- | --- | --- |
118
119
  | Muse transcripts, `<data>/muse/sessions/*/*/*/session.jsonl` | per session: title, first and last ask, tools used, status | `transcripts: unknown` |
119
- | `plans/*.md` checkbox rows | open plans, current item, done/total, remaining names | `plans: unknown` |
120
+ | `plans/*.md` checkbox rows | plans changed inside the window (open and finished), current item, done/total, remaining names | `plans: unknown` |
120
121
  | `.litmuse/goals/*.json` | objective and passed/total criteria per goal | `litgoal: unknown` |
121
- | `HANDOFF*.md` and `.litmuse/handoff/latest.md` | headings from the first 40 lines only | `handoffs: unknown` |
122
+ | `HANDOFF*.md` and `.litmuse/handoff/latest.md` | headings from the first 40 lines; for this worktree's own files, the first item under `## Next` / `## Next Steps` | `handoffs: unknown` |
122
123
  | `git log --since` | commits in the window, matched to sessions by time and branch | `git: unknown` |
123
124
  | `.litmuse/workflow-stack/*.json` | running and paused disciplines with stage and k/n | `workflowStack: unknown` |
124
- | the session's own stage record | live start-work route and its plan, with the plan's own progress | `startWork: unknown` |
125
+ | the stage records (`$XDG_STATE_HOME/litmuse/stage/`) | this session's live start-work route and plan; any session's completed run (its plan fully ticked) | `startWork: unknown` |
126
+ | the liveness records (`stage/liveness/`) | each session's last hook event: `alive` (a tool call), `idle` (Stop), `ended` (SessionEnd) | the transcript time alone decides |
125
127
  | `herdr agent list` (only when `HERDR_ENV=1`) | agent panes | `herdr: unknown` |
126
128
 
127
- Transcripts 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; subagent transcripts are skipped. A session belongs to a worktree when the `cwd` its log records sits inside it. 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 `~`). Assistant text and changed files are not in this host's log records, so the brief shows commits and tool names instead.
129
+ Plans, goals, handoffs, workflow stacks and stage records are read first, then transcripts, so a busy session store never crowds out the plans; a plan the budget can no longer cover is counted in `roadmap.plansSkipped`, never half-read. Transcripts 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; subagent transcripts are skipped. A session belongs to a worktree when the `cwd` its log records sits inside it. 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 `~`). Assistant text and changed files are not in this host's log records, so the brief shows commits and tool names instead.
128
130
 
129
131
  ## Status taxonomy
130
132
 
131
133
  - `waiting-on-user`: the session's last word was the assistant asking something, or it holds a paused workflow frame or a start-work boundary awaiting `lit approve`.
132
134
  - `active`: written to within the last 15 minutes and not waiting.
133
- - `ended`: quiet for longer than that.
135
+ - `idle`: another session whose last hook event is a Stop no older than its last log line.
136
+ - `done`: another session whose start-work run completed (plan fully ticked) after its last log line.
137
+ - `ended`: quiet for longer than 15 minutes, or its last hook event was SessionEnd.
134
138
  - `unknown`: the transcript held no timestamp.
135
139
 
136
- Sessions waiting on the user come first, then active ones, newest first.
140
+ Sessions waiting on the user come first, then active, idle and done ones, newest first. This session always reads active while it runs the brief.
137
141
 
138
142
  ## The next step
139
143
 
@@ -172,7 +176,7 @@ lit-adhd is a read-only discipline: it never pushes, pauses or finishes a workfl
172
176
  ## Stop and report
173
177
 
174
178
  - Stop with `BLOCKED: lit-adhd collector unavailable (<reason>)` when the collector exits non-zero or is not found; never rebuild the brief from memory.
175
- - The reply is the six-part brief (summary, live cards, ended rows, roadmap, paused work, one next step) and nothing more. Name every `unknown` source once on the last line.
179
+ - The reply is the three opening lines and the six-part brief (summary, live cards, ended rows, roadmap, paused work, one next step) and nothing more. Name every `unknown` source once on the last line.
176
180
  - Start nothing: the user's `go` starts the next step, not this turn.
177
181
 
178
182
  ## Pitfalls
@@ -183,6 +187,9 @@ lit-adhd is a read-only discipline: it never pushes, pauses or finishes a workfl
183
187
  | `sources.transcripts: unknown` | No `muse/sessions/` folder under the data dir | Say no session records were found; still show plans and goals |
184
188
  | `budget.exhausted: true` | The 8 MiB budget ran out | Say older sessions were not read |
185
189
  | no gauge line on a card | No stack or start-work records progress for that session | Say the elapsed time and last action instead of a count; never print a placeholder pulse |
186
- | `next.kind: none` | No single next step runs on `go` | The box recommends a real step from the records; say it, do not run it or invent another |
190
+ | `next.kind: none` | No single next step runs on `go` | The box recommends a real step from the records (this worktree's HANDOFF Next item first); say it, do not run it or invent another |
191
+ | `roadmap.plansSkipped` above zero | The read budget ran out before those plan files | Say how many plans were not read |
192
+ | a plan you expected is missing | It was last changed before the window (`--since`, 7 days by default) | Say a wider `--since` lists it |
193
+ | `opener.doing: nothing running` | No frame, start-work or active session is on record | Print it as given |
187
194
 
188
195
  See `references/card-examples.md` for complete briefs in English and Korean and `references/status-taxonomy.md` for the status wording in both languages.