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