@litfamily/litmuse 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +20 -0
  3. package/README_ko-KR.md +20 -0
  4. package/bin/litmuse.mjs +4 -0
  5. package/lib/stage-card.mjs +180 -5
  6. package/package.json +1 -1
  7. package/plugin/.muse-plugin/plugin.json +26 -11
  8. package/plugin/commands/lit-status.md +2 -1
  9. package/plugin/commands/lit.md +1 -1
  10. package/plugin/hooks/lib/digest.mjs +1 -1
  11. package/plugin/hooks/lib/next-step.mjs +68 -0
  12. package/plugin/hooks/lib/routes.mjs +59 -0
  13. package/plugin/hooks/lib/workflow-stack.mjs +318 -0
  14. package/plugin/hooks/session-start.mjs +29 -2
  15. package/plugin/hooks/user-prompt-submit.mjs +138 -7
  16. package/plugin/lib/capped-fetch.mjs +25 -0
  17. package/plugin/lib/pinned-fetch.mjs +79 -0
  18. package/plugin/lib/plan-task-rows.mjs +32 -0
  19. package/plugin/lib/render_pages.py +29 -1
  20. package/plugin/lib/rewind/collect.mjs +679 -0
  21. package/plugin/lib/rewind/gauge.mjs +72 -0
  22. package/plugin/lib/rewind/next-step.mjs +91 -0
  23. package/plugin/lib/rewind/redact.mjs +83 -0
  24. package/plugin/lib/rewind/transcripts.mjs +227 -0
  25. package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
  26. package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  27. package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
  28. package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  29. package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  30. package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  31. package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  32. package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  33. package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  34. package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  35. package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  36. package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  37. package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  38. package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
  39. package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
  40. package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
  41. package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
  42. package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
  43. package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  44. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
  45. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
  46. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
  47. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
  48. package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
  49. package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  50. package/plugin/skills/lit-adhd/SKILL.md +188 -0
  51. package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
  52. package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
  53. package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
  54. package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
  55. package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
  56. package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
  57. package/plugin/skills/lit-docx/SKILL.md +1 -1
  58. package/plugin/skills/lit-hijack/SKILL.md +589 -0
  59. package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  60. package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
  61. package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  62. package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  63. package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
  64. package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
  65. package/plugin/skills/lit-pptx/SKILL.md +20 -9
  66. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  67. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  68. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  69. package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
  70. package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
  71. package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  72. package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  73. package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  74. package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  75. package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  76. package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
  77. package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
  78. package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
  79. package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
  80. package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
  81. package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
  82. package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
  83. package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  84. package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
  85. package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  86. package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
  87. package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  88. package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  89. package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
  90. package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  91. package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  92. package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  93. package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  94. package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  95. package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  96. package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  97. package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  98. package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  99. package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  100. package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  101. package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  102. package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  103. package/plugin/skills/lit-recap/SKILL.md +3 -0
  104. package/plugin/skills/lit-schematic/SKILL.md +241 -0
  105. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
  106. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
  107. package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
  108. package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
  109. package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
  110. package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
  111. package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
  112. package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
  113. package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
  114. package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
  115. package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
  116. package/plugin/skills/lit-schematic/references/verification.md +24 -0
  117. package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
  118. package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
  119. package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
  120. package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
  121. package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
  122. package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
  123. package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
  124. package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
  125. package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
  126. package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
  127. package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
  128. package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
  129. package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
  130. package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
  131. package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
  132. package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
  133. package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  134. package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
  135. package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
  136. package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
  137. package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
  138. package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
  139. package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
  140. package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
  141. package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
  142. package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
  143. package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
  144. package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
  145. package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
  146. package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
  147. package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
  148. package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
  149. package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
  150. package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
  151. package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  152. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  153. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  154. package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  155. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
  156. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  157. package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
  158. package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
  159. package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
  160. package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
  161. package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
  162. package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
  163. package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  164. package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  165. package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  166. package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  167. package/plugin/skills/readme-studio/SKILL.md +1 -1
  168. package/plugin/skills/start-work/SKILL.md +6 -0
@@ -0,0 +1,679 @@
1
+ // The lit-adhd record collector behind `litmuse rewind`. It reads what is on disk for every
2
+ // worktree of this repo (Muse transcripts, the litgoal records, plan rows, handoff headings, git
3
+ // log, workflow stacks and the session's stage record, and herdr when HERDR_ENV=1) and returns one
4
+ // redacted, capped JSON brief. A source that is not there reads `unknown`; nothing is inferred.
5
+ // It writes one file only: this session's next-step record for `go`.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { basename, dirname, join, resolve } from 'node:path';
10
+
11
+ import { parsePlanTaskRows } from '../plan-task-rows.mjs';
12
+ import { PULSE_UNKNOWN, aggregateProgress, clipToWidth, displayWidth, progressOf } from './gauge.mjs';
13
+ import { chooseNextStep, writeNextStep } from './next-step.mjs';
14
+ import { makeRedactor } from './redact.mjs';
15
+ import { findTranscripts, makeBudget, readTranscriptLines, sessionRecord } from './transcripts.mjs';
16
+ import { rainbowLine } from '../../../lib/stage-card.mjs';
17
+
18
+ const SMALL_FILE_BYTES = 1024 * 1024;
19
+ const HERDR_TIMEOUT_MS = 2000;
20
+ const GIT_TIMEOUT_MS = 5000;
21
+ const DEFAULT_SINCE = '7d';
22
+ const DEFAULT_MAX = 12;
23
+ const NAME_MAX = 90;
24
+ const PLAN_LIMIT = 8;
25
+
26
+ /** `7d`, `12h`, `30m` in milliseconds, or null. */
27
+ export const parseSince = (text) => {
28
+ const match = /^(\d{1,4})([dhm])$/u.exec(String(text ?? ''));
29
+ if (!match) return null;
30
+ return Number(match[1]) * { d: 86_400_000, h: 3_600_000, m: 60_000 }[match[2]];
31
+ };
32
+
33
+ const readSmallFile = (path, budget) => {
34
+ let fd;
35
+ try {
36
+ if (!lstatSync(path).isFile()) return null;
37
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
38
+ const { size } = fstatSync(fd);
39
+ const length = Math.min(size, SMALL_FILE_BYTES, budget.limit - budget.used);
40
+ if (length < size) budget.exhausted = budget.exhausted || length < Math.min(size, SMALL_FILE_BYTES);
41
+ const buffer = Buffer.alloc(Math.max(0, length));
42
+ const read = length > 0 ? readSync(fd, buffer, 0, length, 0) : 0;
43
+ budget.used += read;
44
+ return buffer.subarray(0, read).toString('utf8');
45
+ } catch {
46
+ return null;
47
+ } finally {
48
+ if (fd !== undefined) closeSync(fd);
49
+ }
50
+ };
51
+ const readJson = (path, budget) => {
52
+ const text = readSmallFile(path, budget);
53
+ if (text === null) return null;
54
+ try {
55
+ return JSON.parse(text);
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+ const entries = (directory) => {
61
+ try {
62
+ return readdirSync(directory, { withFileTypes: true });
63
+ } catch {
64
+ return null;
65
+ }
66
+ };
67
+
68
+ const defaultExec = (file, args, options) => execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...options });
69
+
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
+ const worktreesOf = (cwd, exec) => {
83
+ try {
84
+ const out = exec('git', ['worktree', 'list', '--porcelain'], { cwd, timeout: GIT_TIMEOUT_MS });
85
+ const paths = out.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => line.slice(9).trim()).filter(Boolean);
86
+ return paths.length ? paths : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ };
91
+
92
+ // A plan scaffold nobody filled in: a row that is still a `<placeholder>`, or every row the same.
93
+ const isTemplate = (rows) => rows.some((row) => /<[^<>]{1,40}>/u.test(row.text)) || (rows.length > 1 && new Set(rows.map((row) => row.text.trim())).size === 1);
94
+ const mtimeOf = (path) => {
95
+ try {
96
+ return lstatSync(path).mtimeMs;
97
+ } catch {
98
+ return null;
99
+ }
100
+ };
101
+
102
+ const plansIn = (root, budget, redact, local) => {
103
+ const list = entries(join(root, 'plans'));
104
+ if (!list) return null;
105
+ 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);
107
+ if (text === null) return [];
108
+ const rows = parsePlanTaskRows(text);
109
+ if (!rows.length || isTemplate(rows)) return [];
110
+ const open = rows.filter((row) => !row.checked);
111
+ if (!open.length) return [];
112
+ const done = rows.length - open.length;
113
+ return [{
114
+ worktree: redact(root),
115
+ plan: `plans/${entry.name}`,
116
+ local,
117
+ changedMs: mtimeOf(join(root, 'plans', entry.name)),
118
+ rows: rows.map((row) => ({ checked: row.checked, title: clipToWidth(redact(row.text), NAME_MAX) })),
119
+ current: clipToWidth(redact(open[0].text), NAME_MAX),
120
+ progress: progressOf(done, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX))),
121
+ }];
122
+ });
123
+ };
124
+
125
+ const goalsOf = (root, budget, redact) => {
126
+ const dir = join(root, '.litmuse', 'goals');
127
+ const list = entries(dir);
128
+ if (!list) return null;
129
+ return list.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
130
+ const goal = readJson(join(dir, entry.name), budget);
131
+ if (!goal || !Array.isArray(goal.criteria)) return [];
132
+ const passed = goal.criteria.filter((criterion) => criterion?.status === 'pass');
133
+ const remaining = goal.criteria.filter((criterion) => criterion?.status !== 'pass').map((criterion) => clipToWidth(redact(String(criterion?.text ?? criterion?.id ?? 'unknown')), NAME_MAX));
134
+ return [{
135
+ worktree: redact(root),
136
+ file: entry.name,
137
+ status: typeof goal.status === 'string' ? goal.status : 'unknown',
138
+ objective: clipToWidth(redact(String(goal.objective ?? 'unknown').split('\n')[0]), 120),
139
+ progress: progressOf(passed.length, goal.criteria.length, remaining),
140
+ }];
141
+ });
142
+ };
143
+
144
+ const handoffsOf = (root, budget, redact) => {
145
+ const files = (entries(root) ?? [])
146
+ .filter((entry) => entry.isFile() && /^HANDOFF.*\.md$/u.test(entry.name))
147
+ .map((entry) => join(root, entry.name));
148
+ const latest = join(root, '.litmuse', 'handoff', 'latest.md');
149
+ if (existsSync(latest)) files.push(latest);
150
+ return files.map((path) => {
151
+ const text = readSmallFile(path, budget) ?? '';
152
+ 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 };
154
+ });
155
+ };
156
+
157
+ const commitsOf = (root, sinceMs, now, exec, redact) => {
158
+ try {
159
+ const since = new Date(now - sinceMs).toISOString();
160
+ const branch = exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, timeout: GIT_TIMEOUT_MS }).trim();
161
+ const out = exec('git', ['log', `--since=${since}`, '--max-count=20', '--format=%H%x09%cI%x09%s'], { cwd: root, timeout: GIT_TIMEOUT_MS });
162
+ return out.split('\n').filter(Boolean).map((line) => {
163
+ const [hash, at, ...subject] = line.split('\t');
164
+ return { worktree: redact(root), branch: redact(branch), hash: hash.slice(0, 7), at, atMs: Date.parse(at), subject: clipToWidth(redact(subject.join('\t')), 100) };
165
+ });
166
+ } catch {
167
+ return null;
168
+ }
169
+ };
170
+
171
+ const stacksOf = (root, budget, redact) => {
172
+ const directory = join(root, '.litmuse', 'workflow-stack');
173
+ const list = entries(directory);
174
+ if (!list) return null;
175
+ return list.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).flatMap((entry) => {
176
+ const stack = readJson(join(directory, entry.name), budget);
177
+ if (!stack || !Array.isArray(stack.frames)) return [];
178
+ return stack.frames.slice(0, 3).map((frame) => ({
179
+ worktree: redact(root),
180
+ sessionId: typeof stack.sessionId === 'string' ? stack.sessionId : null,
181
+ id8: typeof stack.sessionId === 'string' ? stack.sessionId.slice(0, 8) : 'unknown',
182
+ skill: typeof frame.skill === 'string' ? frame.skill : 'unknown',
183
+ stage: typeof frame.stage === 'string' ? frame.stage : 'unknown',
184
+ role: frame.role === 'spine' ? 'spine' : 'side',
185
+ status: frame.status === 'paused' ? 'paused' : 'active',
186
+ at: typeof frame.at === 'string' ? frame.at : 'unknown',
187
+ progress: progressOf(frame.n, frame.total, Array.isArray(frame.remaining) ? frame.remaining.map((name) => clipToWidth(redact(String(name)), NAME_MAX)) : undefined),
188
+ }));
189
+ });
190
+ };
191
+
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;
201
+ const planText = plan ? readSmallFile(join(root, plan), budget) : null;
202
+ const rows = planText === null ? [] : parsePlanTaskRows(planText);
203
+ const open = rows.filter((row) => !row.checked);
204
+ return [{
205
+ worktree: redact(root),
206
+ workId: `stage-${record.session ?? sessionId ?? 'unknown'}`,
207
+ 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',
211
+ progress: rows.length
212
+ ? progressOf(rows.length - open.length, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX)))
213
+ : progressOf(null, null),
214
+ }];
215
+ };
216
+
217
+ const herdrOf = (env, exec, redact) => {
218
+ if (env.HERDR_ENV !== '1') return { status: 'unknown', agents: [] };
219
+ try {
220
+ const out = exec('herdr', ['agent', 'list'], { timeout: HERDR_TIMEOUT_MS });
221
+ return { status: 'ok', agents: out.split('\n').filter((line) => line.trim()).slice(0, 20).map((line) => clipToWidth(redact(line.trim()), 120)) };
222
+ } catch {
223
+ return { status: 'unknown', agents: [] };
224
+ }
225
+ };
226
+
227
+ const sessionStoreDir = (env) => {
228
+ if (env.LITMUSE_SESSION_STORE) return env.LITMUSE_SESSION_STORE;
229
+ const dataHome = env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share');
230
+ return join(dataHome, 'muse', 'sessions');
231
+ };
232
+
233
+ const known = (list) => (list === null ? 'unknown' : 'ok');
234
+ const statusRank = { 'waiting-on-user': 0, active: 1, ended: 2, unknown: 3 };
235
+
236
+ /**
237
+ * Collects the brief. Options: `cwd`, `here` (cwd only), `since` (`7d`), `max` (12), `sessionId`
238
+ * (this session, for the next step and waiting state), `env`, `now`, `exec` (git/herdr runner).
239
+ */
240
+ export const collectRewind = ({
241
+ cwd = process.cwd(),
242
+ here = false,
243
+ since = DEFAULT_SINCE,
244
+ max = DEFAULT_MAX,
245
+ sessionId = null,
246
+ env = process.env,
247
+ now = Date.now(),
248
+ exec = defaultExec,
249
+ record = true,
250
+ } = {}) => {
251
+ const home = env.HOME || homedir();
252
+ const redact = makeRedactor({ env, home });
253
+ const budget = makeBudget();
254
+ const sinceMs = parseSince(since) ?? parseSince(DEFAULT_SINCE);
255
+ const limit = Number.isInteger(max) && max > 0 ? Math.min(max, 50) : DEFAULT_MAX;
256
+ const start = resolve(cwd);
257
+ const stateRoot = outermostRoot(start);
258
+ const listed = here ? null : worktreesOf(start, exec);
259
+ const roots = [...new Set([start, ...(here ? [] : listed ?? [stateRoot])])];
260
+
261
+ const transcripts = findTranscripts(sessionStoreDir(env), roots);
262
+ const sessions = [];
263
+ for (const file of transcripts.files) {
264
+ if (sessions.length >= limit || budget.exhausted) break;
265
+ const lines = readTranscriptLines(file.path, budget);
266
+ const session = lines ? sessionRecord(file.sessionId, lines, { redact, now }) : null;
267
+ if (!session || (session.lastMs !== null && now - session.lastMs > sinceMs)) continue;
268
+ if (here && session.cwd && resolve(session.cwd) !== start) continue;
269
+ sessions.push(session);
270
+ }
271
+
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);
286
+
287
+ // A session waits on the user when it asked something, or holds a paused frame or a boundary.
288
+ for (const session of sessions) {
289
+ const frames = (workflows ?? []).filter((frame) => frame.sessionId === session.sessionId);
290
+ const top = frames.at(-1);
291
+ const work = (startWork ?? []).find((item) => item.sessionId === session.sessionId);
292
+ if (frames.some((frame) => frame.status === 'paused') && session.status !== 'waiting-on-user') {
293
+ session.status = 'waiting-on-user';
294
+ session.waitingReason = 'paused workflow';
295
+ }
296
+ if (work?.status === 'paused') {
297
+ session.status = 'waiting-on-user';
298
+ session.waitingReason = 'start-work boundary';
299
+ }
300
+ session.route = top?.skill ?? (work ? 'start-work' : 'unknown');
301
+ session.progress = top?.progress ?? work?.progress ?? progressOf(null, null);
302
+ session.worked = workedOf(session);
303
+ session.done.commits = (commits ?? [])
304
+ .filter((commit) => session.lastMs !== null && Date.parse(session.started) <= commit.atMs && commit.atMs <= session.lastMs + 5 * 60_000
305
+ && (session.branch === 'unknown' || commit.branch === session.branch))
306
+ .slice(0, 5)
307
+ .map(({ hash, subject }) => ({ hash, subject }));
308
+ }
309
+ sessions.sort((a, b) => statusRank[a.status] - statusRank[b.status] || (b.lastMs ?? 0) - (a.lastMs ?? 0));
310
+
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);
316
+ const orderedPlans = (plans ?? []).filter(recent).sort((a, b) => Number(b.local) - Number(a.local) || (b.changedMs ?? 0) - (a.changedMs ?? 0));
317
+ const shownPlans = orderedPlans.slice(0, PLAN_LIMIT);
318
+ const localGoals = (litgoal ?? []).filter((goal) => goal.local);
319
+ const roadmapItems = [...orderedPlans.filter((plan) => plan.local).map((plan) => plan.progress), ...localGoals.map((goal) => goal.progress)];
320
+ const data = {
321
+ version: 1,
322
+ generatedAt: new Date(now).toISOString(),
323
+ scope: { here, since: sinceMs === parseSince(since) ? since : DEFAULT_SINCE, max: limit, worktrees: roots.map(redact) },
324
+ sources: {
325
+ transcripts: transcripts.status,
326
+ litgoal: known(litgoal),
327
+ plans: known(plans),
328
+ handoffs: handoffs.length ? 'ok' : 'unknown',
329
+ git: known(commits),
330
+ workflowStack: known(workflows),
331
+ startWork: known(startWork),
332
+ herdr: herdr.status,
333
+ },
334
+ budget: { bytesRead: budget.used, limit: budget.limit, exhausted: budget.exhausted },
335
+ summary: {
336
+ sessions: sessions.length,
337
+ waitingOnUser: sessions.filter((session) => session.status === 'waiting-on-user').length,
338
+ active: sessions.filter((session) => session.status === 'active').length,
339
+ },
340
+ sessions: sessions.map(({ cwd: _cwd, lastMs: _lastMs, sessionId: id, ...rest }) => ({ ...rest, self: Boolean(sessionId) && id === sessionId })),
341
+ roadmap: {
342
+ plans: shownPlans,
343
+ plansOmitted: orderedPlans.length - shownPlans.length,
344
+ // Another worktree's finished objective is history, not roadmap.
345
+ litgoal: (litgoal ?? []).filter((goal) => goal.local || goal.status !== 'complete'),
346
+ progress: roadmapItems.length ? aggregateProgress(roadmapItems) : progressOf(null, null),
347
+ },
348
+ workflows: (workflows ?? []).map((frame) => ({ ...frame, self: Boolean(sessionId) && frame.sessionId === sessionId })),
349
+ startWork: startWork ?? [],
350
+ handoffs,
351
+ commits: (commits ?? []).slice(0, 20).map(({ atMs: _atMs, ...commit }) => commit),
352
+ herdr,
353
+ };
354
+ const next = chooseNextStep(data, sessionId);
355
+ data.next = next;
356
+ data.nextRecorded = false;
357
+ if (record && sessionId) {
358
+ try {
359
+ writeNextStep(stateRoot, sessionId, next, new Date(now));
360
+ data.nextRecorded = true;
361
+ } catch {
362
+ data.nextRecorded = false;
363
+ }
364
+ }
365
+ for (const frame of data.workflows) delete frame.sessionId;
366
+ for (const work of data.startWork) delete work.sessionId;
367
+ return data;
368
+ };
369
+
370
+ const remainingLine = (names, width) => {
371
+ if (!names.length || (names.length === 1 && names[0] === 'unknown')) return null;
372
+ let out = ' left:';
373
+ for (let index = 0; index < names.length; index += 1) {
374
+ const name = names[index];
375
+ const piece = ` ${name}${index < names.length - 1 ? ',' : ''}`;
376
+ if (displayWidth(out + piece) > width - 4) return `${out} …(+${names.length - index})`;
377
+ out += piece;
378
+ }
379
+ return out;
380
+ };
381
+
382
+ // ANSI pieces never reach the width math: every row is clipped raw, then painted.
383
+ const ESC = '\u001b[';
384
+ 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' };
386
+ const BADGE_PAINT = {
387
+ 'waiting-on-user': (text) => paint('33;1', text),
388
+ active: (text) => paint('32;1', text),
389
+ ended: (text) => paint('2', text),
390
+ unknown: (text) => paint('2', text),
391
+ };
392
+
393
+ // Source keys never reach the user; the footer names what was not read in plain words.
394
+ const SOURCE_LABELS = {
395
+ transcripts: 'sessions', litgoal: 'goals', plans: 'plans', handoffs: 'handoffs',
396
+ git: 'commits', workflowStack: 'workflows', startWork: 'start-work', herdr: 'agents',
397
+ };
398
+
399
+ /** Pads raw text (no escapes) to `columns` display columns. */
400
+ const pad = (text, columns) => {
401
+ const missing = columns - displayWidth(text);
402
+ return missing > 0 ? `${text}${' '.repeat(missing)}` : text;
403
+ };
404
+
405
+ // Head-and-tail reads undercount calls, so tools are named, never counted.
406
+ const toolsLine = (tools) => {
407
+ const names = Object.keys(tools ?? {}).sort();
408
+ return names.length ? names.join(' · ') : null;
409
+ };
410
+
411
+ const sessionNext = (session, data) => {
412
+ const frame = data.workflows.find((item) => item.id8 === session.id8 && item.status === 'paused' && data.next.say);
413
+ if (frame) return `say "${data.next.say}" to resume the paused ${frame.skill}`;
414
+ if (session.status === 'waiting-on-user') return session.waitingReason === 'start-work boundary' ? 'answer the boundary: `lit approve` or `lit deny`' : 'answer its question';
415
+ return null;
416
+ };
417
+
418
+ /**
419
+ * Emits one row from `[text, paint|null]` segments. The joined raw text is clipped first, so
420
+ * ANSI pieces never reach the width math; each surviving slice keeps its segment's paint.
421
+ */
422
+ const emit = (out, color, width, segments) => {
423
+ const raw = segments.map(([text]) => text).join('');
424
+ const clipped = clipToWidth(raw, width);
425
+ if (!color) {
426
+ out.push(clipped);
427
+ return;
428
+ }
429
+ const target = displayWidth(clipped);
430
+ const cut = clipped.endsWith('…') && raw !== clipped;
431
+ let used = 0;
432
+ let row = '';
433
+ for (const [text, fn] of segments) {
434
+ if (used >= target) break;
435
+ const room = target - used - (cut ? 1 : 0);
436
+ let slice = text;
437
+ if (displayWidth(text) > room) {
438
+ slice = '';
439
+ let taken = 0;
440
+ for (const ch of text) {
441
+ const w = displayWidth(ch);
442
+ if (taken + w > Math.max(room - 1, 0)) break;
443
+ slice += ch;
444
+ taken += w;
445
+ }
446
+ }
447
+ if (slice) row += fn ? fn(slice) : slice;
448
+ used += displayWidth(slice);
449
+ if (slice !== text) break;
450
+ }
451
+ if (cut) row += '…';
452
+ out.push(row);
453
+ };
454
+
455
+ /** `Nm of work` between the first and last stamp, or null when either end is unknown. */
456
+ const workedOf = (session) => {
457
+ const start = Date.parse(session.started);
458
+ const end = session.lastMs ?? null;
459
+ if (!Number.isFinite(start) || end === null || end < start) return null;
460
+ const s = Math.floor((end - start) / 1000);
461
+ if (s < 60) return `${s}s of work`;
462
+ if (s < 3600) return `${Math.floor(s / 60)}m of work`;
463
+ if (s < 172800) return `${Math.floor(s / 3600)}h of work`;
464
+ return `${Math.floor(s / 86400)}d of work`;
465
+ };
466
+
467
+ /** One session card: status badge, goal in plain words, facts, gauge, done, now, next. */
468
+ const sessionCard = (session, data, emitLine, now) => {
469
+ const head = [[STATUS_BADGE[session.status] ?? session.status, BADGE_PAINT[session.status] ?? null], [' · ', null], [session.id8, (text) => paint('33;1', text)]];
470
+ if (session.route && session.route !== 'unknown') head.push([' · ', null], [session.route, (text) => paint('36', text)]);
471
+ head.push([' · ', null], [`${agoOf(session.last, now)} ago`, (text) => paint('2', text)]);
472
+ emitLine(head);
473
+ if (session.title && session.title !== 'unknown') emitLine([[session.title, (text) => paint('1', text)]]);
474
+ const facts = [];
475
+ const worked = session.worked ?? workedOf(session);
476
+ if (worked) facts.push(`⏳ ${worked}`);
477
+ const asks = (session.asked ?? []).filter((text) => text !== 'unknown');
478
+ if (asks.length) facts.push(`last ask: "${clipToWidth(asks.at(-1), 60)}"`);
479
+ if (facts.length) emitLine([[facts.join(' · '), (text) => paint('2', text)]]);
480
+ // A card whose progress is fully unknown shows no pulse line: the badge, the
481
+ // elapsed time and the last action already say what is known. A known
482
+ // finished count (`[…] 3 steps`) still shows.
483
+ const { gauge: pace, remaining } = session.progress ?? { gauge: PULSE_UNKNOWN, remaining: [] };
484
+ const leftovers = (remaining ?? []).filter((name) => name !== 'unknown');
485
+ if (pace.startsWith('[▉') || pace.startsWith('[░')) emitLine([[`${pace} · left: ${leftovers.join(', ') || '—'}`, null]]);
486
+ else if (leftovers.length) emitLine([[`${pace} · ${leftovers.join(', ')}`, null]]);
487
+ else if (pace !== PULSE_UNKNOWN) emitLine([[pace, null]]);
488
+ const tools = toolsLine(session.done?.tools);
489
+ const commits = (session.done?.commits ?? []).map((commit) => `${commit.hash} ${commit.subject}`);
490
+ if (tools || commits.length) emitLine([['✅ Done: ', (text) => paint('32', text)], [[tools, ...commits].filter(Boolean).join(' · '), null]]);
491
+ // 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]]);
494
+ const next = sessionNext(session, data);
495
+ if (next) emitLine([['➤ Next: ', (text) => paint('36;1', text)], [next, null]]);
496
+ };
497
+
498
+ // Short cells, so an ended row fits the frame: id, place, goal, age, and done.
499
+ const ENDED_ROWS = 8;
500
+ const agoOf = (last, now) => {
501
+ const at = Date.parse(last);
502
+ if (!Number.isFinite(at)) return '·';
503
+ const s = Math.max(0, Math.floor((now - at) / 1000));
504
+ if (s < 60) return `${s}s`;
505
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
506
+ if (s < 172800) return `${Math.floor(s / 3600)}h`;
507
+ return `${Math.floor(s / 86400)}d`;
508
+ };
509
+
510
+ /** One ended session as table cells: id, place, goal, age, done. */
511
+ const endedCells = (session, now) => {
512
+ const commits = session.done?.commits?.length ?? 0;
513
+ const files = session.done?.files?.length ?? 0;
514
+ const done = commits ? `${commits} ${commits === 1 ? 'commit' : 'commits'}` : files ? `${files} files` : '·';
515
+ const place = session.worktree && session.worktree !== 'unknown' ? basename(String(session.worktree)) : '·';
516
+ const goal = session.title && session.title !== 'unknown' ? session.title : session.asked?.[0] ?? 'unknown';
517
+ return [session.id8, place, goal, agoOf(session.last, now), done];
518
+ };
519
+
520
+ const ENDED_HEAD = ['id', 'tree', 'goal', 'ago', 'done'];
521
+
522
+ /**
523
+ * The ended sessions as a ruled table, or plain one-line rows when the frame is too narrow
524
+ * for columns. Column widths: id 8, tree 14, ago 4, done 10, the goal takes the rest.
525
+ */
526
+ const endedTable = (sessions, now, emitLine, width) => {
527
+ const rows = sessions.slice(0, ENDED_ROWS).map((session) => endedCells(session, now));
528
+ const title = ` Ended · ${sessions.length} `;
529
+ if (width >= 60) {
530
+ const tree = 14;
531
+ const goal = Math.max(width - 38 - tree, 16);
532
+ const widths = [8, tree, goal, 4, 10];
533
+ const rule = (left, mid, right) => left + widths.map((w) => '─'.repeat(w + 2)).join(mid) + right;
534
+ const cells = (items) => `│ ${items.map((item, i) => pad(clipToWidth(item, widths[i]), widths[i])).join(' │ ')} │`;
535
+ const cap = Math.max(width - title.length - 2, 2);
536
+ emitLine([[`┌${title}${'─'.repeat(cap)}┐`, null]]);
537
+ emitLine([[cells(ENDED_HEAD), (text) => paint('1', text)]]);
538
+ emitLine([[rule('├', '┼', '┤'), null]]);
539
+ for (const row of rows) emitLine([[cells(row), null]]);
540
+ emitLine([[rule('└', '┴', '┘'), null]]);
541
+ } else {
542
+ emitLine([[`-- ended (${sessions.length}) --`, null]]);
543
+ for (const [id, place, goal, ago, done] of rows) emitLine([[`${id} · ${place} · ${clipToWidth(goal, 44)} · ${ago} · ${done}`, null]]);
544
+ }
545
+ if (sessions.length > ENDED_ROWS) emitLine([[` …(+${sessions.length - ENDED_ROWS} more ended)`, null]]);
546
+ };
547
+
548
+ const planMark = (plan) => (plan.progress.total !== null && plan.progress.done >= plan.progress.total ? '✓' : plan.current ? '▶' : '○');
549
+
550
+ // When no step can run on `go`, the brief still recommends one from what it read, in this order:
551
+ // a session that waits on the user, a paused workflow, the next item of an open plan, the most
552
+ // recent other active session, the most recent ended one. It is shown, never recorded.
553
+ const suggestion = (data) => {
554
+ const sessions = data.sessions ?? [];
555
+ const place = (session) => (session.worktree && session.worktree !== 'unknown' ? basename(String(session.worktree)) : 'unknown');
556
+ const goal = (session) => clipToWidth(String(session.title && session.title !== 'unknown' ? session.title : session.asked?.[0] ?? 'unknown'), 50);
557
+ const waiting = sessions.find((session) => session.status === 'waiting-on-user' && !session.self);
558
+ if (waiting) return { text: `Answer ${waiting.id8} in ${place(waiting)}`, say: null };
559
+ const paused = (data.workflows ?? []).find((frame) => frame.status === 'paused');
560
+ const owner = paused ? sessions.find((session) => session.id8 === paused.id8) : null;
561
+ if (paused) return { text: `Resume ${paused.skill} at ${paused.stage} in ${paused.id8} (${owner ? place(owner) : 'unknown'})`, say: 'lit continue' };
562
+ // A plan in the worktree of a live session comes first, then this worktree's, then the newest.
563
+ const liveTrees = new Set(sessions.filter((session) => session.status !== 'ended').map((session) => session.worktree));
564
+ const open = (data.roadmap?.plans ?? []).filter((plan) => plan.current);
565
+ const plan = open.find((item) => liveTrees.has(item.worktree)) ?? open.find((item) => item.local) ?? open[0];
566
+ if (plan) return { text: `Next in ${plan.local ? plan.plan : `${basename(String(plan.worktree))}/${plan.plan}`}: ${clipToWidth(String(plan.current), 60)}`, say: 'go' };
567
+ const active = sessions.find((session) => session.status === 'active' && !session.self);
568
+ 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 };
571
+ return { text: 'No session or plan in this window: say what to start', say: null };
572
+ };
573
+
574
+ // Wraps a step to the box at word breaks, three lines at most, the last one clipped.
575
+ const wrap = (text, width, rows = 3) => {
576
+ const out = [''];
577
+ for (const word of String(text).split(' ').filter(Boolean)) {
578
+ const joined = out.at(-1) ? `${out.at(-1)} ${word}` : word;
579
+ if (displayWidth(joined) <= width || !out.at(-1)) out[out.length - 1] = joined;
580
+ else out.push(word);
581
+ }
582
+ const kept = out.slice(0, rows);
583
+ if (out.length > rows) kept[rows - 1] = `${kept[rows - 1]} ${out.slice(rows).join(' ')}`;
584
+ return kept.map((row) => clipToWidth(row, width));
585
+ };
586
+
587
+ /** Plain-text brief: headline, one card per live session, ended table, roadmap, one next step in a box. */
588
+ export const renderRewind = (data, width = 100, { color = false } = {}) => {
589
+ const out = [];
590
+ const emitLine = (segments) => emit(out, color, width, segments);
591
+ const put = (raw, fn = null) => emitLine([[raw, fn]]);
592
+ const frame = Math.max(width, 20);
593
+ const step = data.next?.kind && data.next.kind !== 'none' ? null : suggestion(data);
594
+ const nextKind = data.next.kind.replace(/-/g, ' ');
595
+ const nextSay = data.next.say ? ` (say "${data.next.say}")` : '';
596
+ // The one static rainbow line of this surface marks the adhd turn (no animation on this host).
597
+ const headline = `lit-adhd: ${data.summary.sessions} session(s) · ${data.summary.waitingOnUser} waiting on you · next: ${step ? step.text : `${nextKind}${nextSay}`}`;
598
+ put(headline, color ? (text) => rainbowLine(text) : null);
599
+ if (data.scope) {
600
+ const folders = data.scope.worktrees?.length ?? 1;
601
+ put(`last ${data.scope.since ?? '7d'} · ${folders} folder${folders === 1 ? '' : 's'} read`);
602
+ }
603
+ const now = Date.parse(data.generatedAt);
604
+ // Sessions that are live or wait on the user come first, as full cards grouped by worktree.
605
+ const live = data.sessions.filter((session) => session.status !== 'ended');
606
+ const ended = data.sessions.filter((session) => session.status === 'ended');
607
+ const groups = new Map();
608
+ for (const session of live) {
609
+ const key = session.worktree && session.worktree !== 'unknown' ? session.worktree : '';
610
+ if (!groups.has(key)) groups.set(key, []);
611
+ groups.get(key).push(session);
612
+ }
613
+ for (const [worktree, sessions] of groups) {
614
+ if (worktree) put(`🌿 ${basename(worktree)}`, (text) => paint('32', text));
615
+ for (const session of sessions) {
616
+ sessionCard(session, data, emitLine, now);
617
+ put('');
618
+ }
619
+ }
620
+ if (ended.length) {
621
+ endedTable(ended, now, emitLine, frame);
622
+ put('');
623
+ }
624
+ if (data.roadmap.plans.length || data.roadmap.litgoal.length) {
625
+ put('-- roadmap --');
626
+ for (const plan of data.roadmap.plans) {
627
+ const head = `${plan.local ? '' : `${basename(plan.worktree)}: `}${plan.plan} ${plan.progress.gauge}`;
628
+ put(planMark(plan) === '✓' ? `✓ ${head}` : plan.current ? `▶ ${head} now: ${plan.current}` : `○ ${head}`);
629
+ const left = remainingLine(plan.progress.remaining.filter((name) => name !== 'unknown' && name !== plan.current), width);
630
+ if (left) put(` ○ later: ${left.replace(/^ left: /, '')}`);
631
+ }
632
+ if (data.roadmap.plansOmitted) put(` …(+${data.roadmap.plansOmitted} more open plans)`);
633
+ for (const goal of data.roadmap.litgoal) put(`○ goal ${goal.progress.gauge} ${goal.objective}`);
634
+ for (const commit of data.commits.slice(0, 5)) put(`✓ ${commit.hash} ${commit.at.slice(0, 10)} ${commit.subject}`);
635
+ put('');
636
+ }
637
+ // The recommended step sits in a box of its own; the closing line says what will not happen.
638
+ const boxTitle = 'RECOMMENDED NEXT STEP';
639
+ put(`┌ ${boxTitle} ${'─'.repeat(Math.max(frame - boxTitle.length - 4, 2))}┐`, (text) => paint('32;1', text));
640
+ const target = data.next.target?.plan ?? data.next.target?.skill ?? '';
641
+ const body = step
642
+ ? [...wrap(step.text, frame - 4), ...(step.say ? [`say "${step.say}" to continue`] : [])]
643
+ : wrap(`${nextKind}${target ? `: ${target}` : ''}${nextSay}. Say \`go\` to continue.`, frame - 4);
644
+ for (const row of body) put(`│ ${pad(row, frame - 4)} │`);
645
+ put(`└${'─'.repeat(frame - 2)}┘`, (text) => paint('32;1', text));
646
+ put('Nothing runs on its own here: open that session, or say what to do.');
647
+ const unread = Object.entries(data.sources).filter(([, status]) => status !== 'ok').map(([name]) => SOURCE_LABELS[name] ?? name);
648
+ if (unread.length) put(`Not read: ${unread.join(', ')}.`);
649
+ return `${out.join('\n')}\n`;
650
+ };
651
+
652
+ /** `litmuse rewind [--json] [--here] [--since 7d] [--max 12] [--session <id>] [--width N]`. */
653
+ export const runRewindCli = (args, cwd = process.cwd(), { env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) => {
654
+ const options = { cwd, env, here: false, since: DEFAULT_SINCE, max: DEFAULT_MAX, sessionId: null };
655
+ let json = false;
656
+ let width = Number(stdout.columns) || 100;
657
+ const usage = 'Usage: litmuse rewind [--json] [--here] [--since 7d] [--max 12] [--session <id>] [--width N]';
658
+ if (args.some((arg) => arg === '--help' || arg === '-h')) {
659
+ stdout.write(`${usage}\n`);
660
+ return 0;
661
+ }
662
+ for (let index = 0; index < args.length; index += 1) {
663
+ const arg = args[index];
664
+ if (arg === '--json') json = true;
665
+ else if (arg === '--here') options.here = true;
666
+ else if (arg === '--since' && parseSince(args[index + 1])) options.since = args[++index];
667
+ else if (arg === '--max' && /^\d{1,2}$/u.test(args[index + 1] ?? '')) options.max = Number(args[++index]);
668
+ else if (arg === '--session' && /^[A-Za-z0-9._:-]{1,160}$/u.test(args[index + 1] ?? '')) options.sessionId = args[++index];
669
+ else if (arg === '--width' && /^\d{2,3}$/u.test(args[index + 1] ?? '')) width = Number(args[++index]);
670
+ else {
671
+ stderr.write(`litmuse rewind: unknown argument ${JSON.stringify(basename(String(arg)))}. ${usage}\n`);
672
+ return 64;
673
+ }
674
+ }
675
+ const data = collectRewind(options);
676
+ // The static rainbow marks the adhd turn on a real terminal only, never in pipes.
677
+ stdout.write(json ? `${JSON.stringify(data, null, 2)}\n` : renderRewind(data, width, { color: stdout.isTTY === true }));
678
+ return 0;
679
+ };