@litfamily/lithermes 1.0.28 → 1.0.30

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 (167) hide show
  1. package/README.md +12 -9
  2. package/README_Ko-KR.md +12 -9
  3. package/assets/lithermes-plugin/README.md +15 -2
  4. package/assets/lithermes-plugin/__init__.py +49 -10
  5. package/assets/lithermes-plugin/adhd_stack.py +228 -0
  6. package/assets/lithermes-plugin/core.py +30 -4
  7. package/assets/lithermes-plugin/core_commands.py +99 -0
  8. package/assets/lithermes-plugin/core_contexts.py +54 -4
  9. package/assets/lithermes-plugin/core_contract.py +2 -2
  10. package/assets/lithermes-plugin/core_routing.py +126 -0
  11. package/assets/lithermes-plugin/diagnostics.py +3 -2
  12. package/assets/lithermes-plugin/lit_mark.py +75 -0
  13. package/assets/lithermes-plugin/payload-version.json +472 -60
  14. package/assets/lithermes-plugin/plugin.yaml +1 -1
  15. package/assets/lithermes-plugin/scientific_visualization.py +23 -0
  16. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +7 -7
  17. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  18. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/fonts.json +42 -0
  19. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  20. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  25. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  26. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  27. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  28. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/README.md +2 -4
  29. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +4 -4
  30. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/craft-floor.md +10 -0
  31. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  32. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe-rules.json +6 -1
  33. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +76 -4
  34. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +194 -0
  35. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +55 -0
  36. package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +86 -0
  37. package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +40 -0
  38. package/assets/lithermes-plugin/skills/lit-adhd/scripts/adhd.mjs +11 -0
  39. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +554 -0
  40. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/gauge.mjs +89 -0
  41. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/hermes-sessions.mjs +219 -0
  42. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +101 -0
  43. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
  44. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/redact.mjs +42 -0
  45. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/secret-shapes.mjs +59 -0
  46. package/assets/lithermes-plugin/skills/lit-diagram-drawer/SKILL.md +7 -4
  47. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  48. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  49. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  50. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  51. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  52. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/verifier-guide.md +5 -0
  53. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/diagram_quality.py +4 -4
  54. package/assets/lithermes-plugin/skills/lit-docx/SKILL.md +1 -1
  55. package/assets/lithermes-plugin/skills/lit-hijack/SKILL.md +239 -0
  56. package/assets/lithermes-plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  57. package/assets/lithermes-plugin/skills/lit-hijack/references/clean-room-build.md +128 -0
  58. package/assets/lithermes-plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  59. package/assets/lithermes-plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  60. package/assets/lithermes-plugin/skills/lit-hijack/references/parity-and-report.md +131 -0
  61. package/assets/lithermes-plugin/skills/lit-hijack/scripts/hijack.mjs +995 -0
  62. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +1 -0
  63. package/assets/lithermes-plugin/skills/lit-pptx/SKILL.md +11 -6
  64. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  65. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  66. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  67. package/assets/lithermes-plugin/skills/lit-pptx/fonts/provenance.json +1 -0
  68. package/assets/lithermes-plugin/skills/lit-pptx/references/anti-slop-checklist.md +3 -0
  69. package/assets/lithermes-plugin/skills/lit-pptx/references/authoring-guide.md +1 -1
  70. package/assets/lithermes-plugin/skills/lit-pptx/references/consensus-protocol.md +1 -1
  71. package/assets/lithermes-plugin/skills/lit-pptx/references/direction-step.md +8 -2
  72. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +175 -0
  73. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +156 -0
  74. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +141 -0
  75. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +160 -0
  76. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +183 -0
  77. package/assets/lithermes-plugin/skills/lit-pptx/references/layout-families.md +1 -1
  78. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/bauhaus.md +105 -0
  79. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +111 -0
  80. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +114 -0
  81. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/newsroom.md +106 -0
  82. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/riso.md +101 -0
  83. package/assets/lithermes-plugin/skills/lit-pptx/scripts/craft_extras.py +49 -0
  84. package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +4 -2
  85. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -10
  86. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +50 -6
  87. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +103 -0
  88. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  89. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  90. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  91. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  92. package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +1 -1
  93. package/assets/lithermes-plugin/skills/lit-schematic/SKILL.md +144 -0
  94. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  95. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  96. package/assets/lithermes-plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  97. package/assets/lithermes-plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  98. package/assets/lithermes-plugin/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  99. package/assets/lithermes-plugin/skills/lit-schematic/references/isometric-craft.md +101 -0
  100. package/assets/lithermes-plugin/skills/lit-schematic/references/journal-figures.md +72 -0
  101. package/assets/lithermes-plugin/skills/lit-schematic/references/kit-api.md +134 -0
  102. package/assets/lithermes-plugin/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  103. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-drafting.md +103 -0
  104. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-symbols.md +163 -0
  105. package/assets/lithermes-plugin/skills/lit-schematic/references/verification.md +100 -0
  106. package/assets/lithermes-plugin/skills/lit-schematic/scripts/doctor.mjs +57 -0
  107. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  108. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
  109. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  110. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  111. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  112. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  113. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  114. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  115. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  116. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  117. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  118. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  119. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  120. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  121. package/assets/lithermes-plugin/skills/lit-schematic/scripts/motion.mjs +129 -0
  122. package/assets/lithermes-plugin/skills/lit-schematic/scripts/qa.mjs +92 -0
  123. package/assets/lithermes-plugin/skills/lit-schematic/scripts/render.mjs +233 -0
  124. package/assets/lithermes-plugin/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  125. package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +19 -1
  126. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +12 -11
  127. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +56 -2
  128. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +129 -4
  129. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +9 -0
  130. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  131. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender.mjs +154 -0
  132. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/board.mjs +132 -0
  133. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/completion.mjs +48 -5
  134. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/gate.mjs +13 -0
  135. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/house.mjs +44 -0
  136. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/scaffold.mjs +238 -0
  137. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/score.mjs +643 -0
  138. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound-dsp.mjs +21 -0
  139. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +171 -0
  140. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-qa.mjs +23 -3
  141. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +124 -17
  142. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/styles.mjs +240 -0
  143. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +152 -10
  144. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  145. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  146. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  147. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  148. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +67 -0
  149. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  150. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/depth.md +74 -0
  151. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/studio.md +87 -0
  152. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/style-bibles.md +9 -2
  153. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +24 -8
  154. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  155. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  156. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  157. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  158. package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -0
  159. package/assets/lithermes-plugin/skills/readme-studio/SKILL.md +1 -1
  160. package/assets/lithermes-plugin/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
  161. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  162. package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
  163. package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
  164. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  165. package/package.json +1 -1
  166. package/readme-assets/badge-version.svg +1 -1
  167. package/src/lib/skillPayload.js +1 -1
@@ -0,0 +1,554 @@
1
+ // The lit-adhd record collector behind `node scripts/adhd.mjs --json`. It reads what is on disk
2
+ // for every worktree of this repo (Hermes sessions from the state database, the litgoal records,
3
+ // plan rows, handoff headings, git log, the adhd stack file, start-work runs and bounded-work
4
+ // states, and herdr when HERDR_ENV=1) and returns one redacted, capped JSON brief. A source that
5
+ // is not there reads `unknown`; nothing is inferred. It writes one file only: this session's
6
+ // next-step record for `go`.
7
+ import { execFileSync } from 'node:child_process';
8
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, existsSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { basename, dirname, join, resolve } from 'node:path';
11
+
12
+ import { parsePlanTaskRows } from './plan-task-rows.mjs';
13
+ import { aggregateProgress, clipToWidth, displayWidth, progressOf } from './gauge.mjs';
14
+ import { chooseNextStep, suggestNext, writeNextStep } from './next-step.mjs';
15
+ import { makeRedactor } from './redact.mjs';
16
+ import { findHermesSessions, hermesSessionRecord, hermesStorePath, makeBudget, normalizeRoot, readHermesMessages } from './hermes-sessions.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
+ const GOAL_LIMIT = 8;
26
+
27
+ /** `7d`, `12h`, `30m` in milliseconds, or null. */
28
+ export const parseSince = (text) => {
29
+ const match = /^(\d{1,4})([dhm])$/u.exec(String(text ?? ''));
30
+ if (!match) return null;
31
+ return Number(match[1]) * { d: 86_400_000, h: 3_600_000, m: 60_000 }[match[2]];
32
+ };
33
+
34
+ const readSmallFile = (path, budget) => {
35
+ let fd;
36
+ try {
37
+ if (!lstatSync(path).isFile()) return null;
38
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
39
+ const { size } = fstatSync(fd);
40
+ const length = Math.min(size, SMALL_FILE_BYTES, budget.limit - budget.used);
41
+ if (length < size) budget.exhausted = budget.exhausted || length < Math.min(size, SMALL_FILE_BYTES);
42
+ const buffer = Buffer.alloc(Math.max(0, length));
43
+ const read = length > 0 ? readSync(fd, buffer, 0, length, 0) : 0;
44
+ budget.used += read;
45
+ return buffer.subarray(0, read).toString('utf8');
46
+ } catch {
47
+ return null;
48
+ } finally {
49
+ if (fd !== undefined) closeSync(fd);
50
+ }
51
+ };
52
+ const readJson = (path, budget) => {
53
+ const text = readSmallFile(path, budget);
54
+ if (text === null) return null;
55
+ try {
56
+ return JSON.parse(text);
57
+ } catch {
58
+ return null;
59
+ }
60
+ };
61
+ const entries = (directory) => {
62
+ try {
63
+ return readdirSync(directory, { withFileTypes: true });
64
+ } catch {
65
+ return null;
66
+ }
67
+ };
68
+
69
+ const defaultExec = (file, args, options) => execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...options });
70
+
71
+ const outermostRoot = (startDir) => {
72
+ const start = resolve(startDir);
73
+ let root = null;
74
+ let current = start;
75
+ for (;;) {
76
+ if (existsSync(join(current, '.git'))) root = current;
77
+ const parent = dirname(current);
78
+ if (parent === current) return root ?? start;
79
+ current = parent;
80
+ }
81
+ };
82
+
83
+ const worktreesOf = (cwd, exec) => {
84
+ try {
85
+ const out = exec('git', ['worktree', 'list', '--porcelain'], { cwd, timeout: GIT_TIMEOUT_MS });
86
+ const paths = out.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => line.slice(9).trim()).filter(Boolean);
87
+ return paths.length ? paths : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+
93
+ const plansIn = (root, budget, redact, local) => {
94
+ const list = entries(join(root, 'plans'));
95
+ if (!list) return null;
96
+ return list.filter((entry) => entry.isFile() && entry.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
97
+ const text = readSmallFile(join(root, 'plans', entry.name), budget);
98
+ if (text === null) return [];
99
+ // Column-zero `- [ ] N. title` and `- [ ] FN. title` rows; a non-numbered checkbox row
100
+ // still counts, the same way the source collector counts every checkbox row.
101
+ const rows = parsePlanTaskRows(text);
102
+ if (!rows.length) return [];
103
+ const open = rows.filter((row) => !row.checked);
104
+ if (!open.length) return [];
105
+ const done = rows.length - open.length;
106
+ return [{
107
+ worktree: redact(root),
108
+ plan: `plans/${entry.name}`,
109
+ local,
110
+ rows: rows.map((row) => ({ checked: row.checked, title: clipToWidth(redact(row.text), NAME_MAX) })),
111
+ current: clipToWidth(redact(open[0].text), NAME_MAX),
112
+ progress: progressOf(done, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX))),
113
+ }];
114
+ });
115
+ };
116
+
117
+ // Hermes keeps goals in `.hermes/lithermes/litgoal/`: one `goals.json` per session scope plus the
118
+ // legacy root one, each holding a `goals` list with `status` and per-criterion `status`.
119
+ const goalsOf = (root, budget, redact) => {
120
+ const files = [];
121
+ const legacy = join(root, '.hermes', 'lithermes', 'litgoal', 'goals.json');
122
+ if (existsSync(legacy)) files.push(legacy);
123
+ const scopes = entries(join(root, '.hermes', 'lithermes', 'litgoal', 'sessions'));
124
+ if (scopes) {
125
+ for (const scope of scopes) {
126
+ if (!scope.isDirectory()) continue;
127
+ const path = join(root, '.hermes', 'lithermes', 'litgoal', 'sessions', scope.name, 'goals.json');
128
+ if (existsSync(path)) files.push(path);
129
+ }
130
+ }
131
+ if (!files.length) return null;
132
+ return files.flatMap((path) => {
133
+ const store = readJson(path, budget);
134
+ const goals = Array.isArray(store?.goals) ? store.goals : [];
135
+ return goals.slice(0, GOAL_LIMIT).filter((goal) => goal && Array.isArray(goal.criteria)).map((goal) => {
136
+ const criteria = goal.criteria.filter((criterion) => criterion && typeof criterion === 'object');
137
+ const passed = criteria.filter((criterion) => criterion.status === 'pass');
138
+ const remaining = criteria.filter((criterion) => criterion.status !== 'pass').map((criterion) => clipToWidth(redact(String(criterion.id ?? 'unknown')), NAME_MAX));
139
+ return {
140
+ worktree: redact(root),
141
+ file: basename(dirname(path) === 'litgoal' ? path : dirname(path)),
142
+ status: typeof goal.status === 'string' ? goal.status : 'unknown',
143
+ objective: clipToWidth(redact(String(goal.objective ?? goal.title ?? 'unknown').split('\n')[0]), 120),
144
+ progress: progressOf(passed.length, criteria.length, remaining),
145
+ };
146
+ });
147
+ });
148
+ };
149
+
150
+ const handoffsOf = (root, budget, redact) => {
151
+ const files = (entries(root) ?? [])
152
+ .filter((entry) => entry.isFile() && /^HANDOFF.*\.md$/u.test(entry.name))
153
+ .map((entry) => join(root, entry.name));
154
+ const dotted = join(root, '.handoff', 'HANDOFF.md');
155
+ if (existsSync(dotted)) files.push(dotted);
156
+ return files.map((path) => {
157
+ const text = readSmallFile(path, budget) ?? '';
158
+ const headings = text.split('\n').slice(0, 40).filter((line) => /^#{1,3} /u.test(line)).map((line) => clipToWidth(redact(line), 100));
159
+ return { worktree: redact(root), file: basename(path), headings };
160
+ });
161
+ };
162
+
163
+ const commitsOf = (root, sinceMs, now, exec, redact) => {
164
+ try {
165
+ const since = new Date(now - sinceMs).toISOString();
166
+ const branch = exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, timeout: GIT_TIMEOUT_MS }).trim();
167
+ const out = exec('git', ['log', `--since=${since}`, '--max-count=20', '--format=%H%x09%cI%x09%s'], { cwd: root, timeout: GIT_TIMEOUT_MS });
168
+ return out.split('\n').filter(Boolean).map((line) => {
169
+ const [hash, at, ...subject] = line.split('\t');
170
+ return { worktree: redact(root), branch: redact(branch), hash: hash.slice(0, 7), at, atMs: Date.parse(at), subject: clipToWidth(redact(subject.join('\t')), 100) };
171
+ });
172
+ } catch {
173
+ return null;
174
+ }
175
+ };
176
+
177
+ // The adhd stack file the Python hooks write: active disciplines per session. Hermes has no
178
+ // pausable stack, so frames are active or finished and carry no k/n; progress reads `unknown`.
179
+ const stacksOf = (root, budget, redact) => {
180
+ const stack = readJson(join(root, '.hermes', 'lithermes', 'adhd', 'stack.json'), budget);
181
+ const sessions = stack && typeof stack === 'object' && stack.sessions && typeof stack.sessions === 'object' ? stack.sessions : null;
182
+ if (!sessions) return null;
183
+ return Object.entries(sessions).flatMap(([sessionId, record]) => {
184
+ const frames = record && Array.isArray(record.frames) ? record.frames : [];
185
+ return frames.slice(0, 3).map((frame) => ({
186
+ worktree: redact(root),
187
+ sessionId: typeof sessionId === 'string' ? sessionId : null,
188
+ id8: typeof sessionId === 'string' ? sessionId.slice(0, 8) : 'unknown',
189
+ skill: typeof frame?.skill === 'string' ? frame.skill : 'unknown',
190
+ stage: typeof frame?.stage === 'string' ? frame.stage : 'unknown',
191
+ role: 'spine',
192
+ status: frame?.status === 'finished' ? 'finished' : 'active',
193
+ at: typeof frame?.at === 'string' ? frame.at : 'unknown',
194
+ progress: progressOf(null, null),
195
+ }));
196
+ });
197
+ };
198
+
199
+ // Hermes start-work state: newest `runs/*/state.json` with `command: start-work` (a run has no
200
+ // status field, so an unfinished plan reads active and a finished plan reads ended), plus
201
+ // bounded-work `work/*/state.json` records, which carry a real status and a pending boundary.
202
+ const startWorkOf = (root, budget, redact, sessionId) => {
203
+ const items = [];
204
+ const runs = entries(join(root, '.hermes', 'lithermes', 'runs')) ?? [];
205
+ let newestRun = null;
206
+ for (const entry of runs) {
207
+ if (!entry.isDirectory()) continue;
208
+ const state = readJson(join(root, '.hermes', 'lithermes', 'runs', entry.name, 'state.json'), budget);
209
+ if (!state || state.command !== 'start-work') continue;
210
+ if (!newestRun || String(state.created_at ?? '') > String(newestRun.created_at ?? '')) newestRun = state;
211
+ }
212
+ if (newestRun) {
213
+ const plan = typeof newestRun.plan === 'string' && newestRun.plan ? newestRun.plan : null;
214
+ const planText = plan ? readSmallFile(plan, budget) : null;
215
+ const rows = planText === null ? [] : parsePlanTaskRows(planText);
216
+ const open = rows.filter((row) => !row.checked);
217
+ items.push({
218
+ worktree: redact(root),
219
+ workId: `run-${String(newestRun.run_id ?? 'unknown').slice(0, 16)}`,
220
+ plan: plan ? redact(plan) : 'unknown',
221
+ status: rows.length && !open.length ? 'ended' : 'active',
222
+ sessionId: sessionId ?? null,
223
+ id8: sessionId ? String(sessionId).slice(0, 8) : 'unknown',
224
+ progress: rows.length
225
+ ? progressOf(rows.length - open.length, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX)))
226
+ : progressOf(null, null),
227
+ });
228
+ }
229
+ const works = entries(join(root, '.hermes', 'lithermes', 'work')) ?? [];
230
+ for (const entry of works) {
231
+ if (!entry.isDirectory()) continue;
232
+ const state = readJson(join(root, '.hermes', 'lithermes', 'work', entry.name, 'state.json'), budget);
233
+ if (!state || typeof state !== 'object') continue;
234
+ const status = state.status;
235
+ if (!['active', 'paused', 'completed'].includes(status)) continue;
236
+ const plan = typeof state.plan === 'string' && state.plan ? state.plan : null;
237
+ const planText = plan ? readSmallFile(plan, budget) : null;
238
+ const rows = planText === null ? [] : parsePlanTaskRows(planText);
239
+ const open = rows.filter((row) => !row.checked);
240
+ const steps = Array.isArray(state.progress) ? state.progress : [];
241
+ const passed = steps.filter((item) => item && typeof item === 'object' && item.status === 'pass').length;
242
+ const boundary = state.status === 'paused' && state.boundary && typeof state.boundary === 'object' ? state.boundary : null;
243
+ const owner = typeof state.active_session_id === 'string' && state.active_session_id ? state.active_session_id : null;
244
+ items.push({
245
+ worktree: redact(root),
246
+ workId: typeof state.work_id === 'string' ? state.work_id : entry.name,
247
+ plan: plan ? redact(plan) : 'unknown',
248
+ status,
249
+ sessionId: owner,
250
+ id8: owner ? owner.slice(0, 8) : 'unknown',
251
+ ...(boundary ? { pendingBoundary: { boundary: clipToWidth(redact(String(boundary.id ?? 'unknown')), NAME_MAX) } } : {}),
252
+ progress: steps.length
253
+ ? progressOf(passed, steps.length)
254
+ : rows.length
255
+ ? progressOf(rows.length - open.length, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX)))
256
+ : progressOf(null, null),
257
+ });
258
+ }
259
+ return items.length ? items : null;
260
+ };
261
+
262
+ const herdrOf = (env, exec, redact) => {
263
+ if (env.HERDR_ENV !== '1') return { status: 'unknown', agents: [] };
264
+ try {
265
+ const out = exec('herdr', ['agent', 'list'], { timeout: HERDR_TIMEOUT_MS });
266
+ return { status: 'ok', agents: out.split('\n').filter((line) => line.trim()).slice(0, 20).map((line) => clipToWidth(redact(line.trim()), 120)) };
267
+ } catch {
268
+ return { status: 'unknown', agents: [] };
269
+ }
270
+ };
271
+
272
+ const known = (list) => (list === null ? 'unknown' : 'ok');
273
+ const statusRank = { 'waiting-on-user': 0, active: 1, ended: 2, unknown: 3 };
274
+
275
+ /**
276
+ * Collects the brief. Options: `cwd`, `here` (cwd only), `since` (`7d`), `max` (12), `sessionId`
277
+ * (this session, for the next step and waiting state), `env`, `now`, `exec` (git/herdr/sqlite3 runner).
278
+ */
279
+ export const collectAdhd = ({
280
+ cwd = process.cwd(),
281
+ here = false,
282
+ since = DEFAULT_SINCE,
283
+ max = DEFAULT_MAX,
284
+ sessionId = null,
285
+ env = process.env,
286
+ now = Date.now(),
287
+ exec = defaultExec,
288
+ record = true,
289
+ } = {}) => {
290
+ const home = env.HOME || homedir();
291
+ const redact = makeRedactor({ env, home });
292
+ const budget = makeBudget();
293
+ const sinceMs = parseSince(since) ?? parseSince(DEFAULT_SINCE);
294
+ const limit = Number.isInteger(max) && max > 0 ? Math.min(max, 50) : DEFAULT_MAX;
295
+ const start = normalizeRoot(resolve(cwd));
296
+ const stateRoot = outermostRoot(start);
297
+ const listed = here ? null : worktreesOf(start, exec);
298
+ const roots = [...new Set([start, ...(here ? [] : listed ?? [stateRoot])])];
299
+
300
+ const found = findHermesSessions(hermesStorePath(env), roots, { exec });
301
+ const sessions = [];
302
+ for (const entry of found.sessions) {
303
+ if (sessions.length >= limit || budget.exhausted) break;
304
+ const messages = readHermesMessages(hermesStorePath(env), entry.sessionId, budget, { exec });
305
+ const session = messages ? hermesSessionRecord(entry, messages, { redact, now }) : null;
306
+ if (!session || (session.lastMs !== null && now - session.lastMs > sinceMs)) continue;
307
+ if (here && session.cwd && resolve(session.cwd) !== start) continue;
308
+ sessions.push(session);
309
+ }
310
+
311
+ const collectEach = (read) => {
312
+ const results = roots.map(read);
313
+ return results.every((value) => value === null) ? null : results.flatMap((value) => value ?? []);
314
+ };
315
+ const plans = collectEach((root) => plansIn(root, budget, redact, resolve(root) === stateRoot || resolve(root) === start));
316
+ const litgoal = collectEach((root) => {
317
+ const goals = goalsOf(root, budget, redact);
318
+ return goals ? goals.map((goal) => ({ ...goal, local: resolve(root) === stateRoot || resolve(root) === start })) : null;
319
+ });
320
+ const handoffs = roots.flatMap((root) => handoffsOf(root, budget, redact));
321
+ const commits = collectEach((root) => commitsOf(root, sinceMs, now, exec, redact));
322
+ const workflows = collectEach((root) => stacksOf(root, budget, redact));
323
+ const startWork = collectEach((root) => startWorkOf(root, budget, redact, sessionId));
324
+ const herdr = herdrOf(env, exec, redact);
325
+
326
+ // A session waits on the user when it asked something, or holds a paused boundary.
327
+ for (const session of sessions) {
328
+ const work = (startWork ?? []).find((item) => item.sessionId === session.sessionId);
329
+ if (work?.status === 'paused') {
330
+ session.status = 'waiting-on-user';
331
+ session.waitingReason = 'bounded-work boundary';
332
+ }
333
+ const top = work;
334
+ session.progress = top?.progress ?? progressOf(null, null);
335
+ session.done.commits = (commits ?? [])
336
+ .filter((commit) => session.lastMs !== null && Date.parse(session.started) <= commit.atMs && commit.atMs <= session.lastMs + 5 * 60_000
337
+ && (session.branch === 'unknown' || commit.branch === session.branch))
338
+ .slice(0, 5)
339
+ .map(({ hash, subject }) => ({ hash, subject }));
340
+ }
341
+ sessions.sort((a, b) => statusRank[a.status] - statusRank[b.status] || (b.lastMs ?? 0) - (a.lastMs ?? 0));
342
+
343
+ // Every worktree's open plans are listed, this one's first, up to a short cap; the roadmap gauge
344
+ // counts this worktree only, since a sum over unrelated branches measures nothing.
345
+ const orderedPlans = [...(plans ?? [])].sort((a, b) => Number(b.local) - Number(a.local));
346
+ const shownPlans = orderedPlans.slice(0, PLAN_LIMIT);
347
+ const localGoals = (litgoal ?? []).filter((goal) => goal.local);
348
+ const roadmapItems = [...orderedPlans.filter((plan) => plan.local).map((plan) => plan.progress), ...localGoals.map((goal) => goal.progress)];
349
+ const data = {
350
+ version: 1,
351
+ generatedAt: new Date(now).toISOString(),
352
+ scope: { here, since: sinceMs === parseSince(since) ? since : DEFAULT_SINCE, max: limit, worktrees: roots.map(redact) },
353
+ sources: {
354
+ sessions: found.status,
355
+ litgoal: known(litgoal),
356
+ plans: known(plans),
357
+ handoffs: handoffs.length ? 'ok' : 'unknown',
358
+ git: known(commits),
359
+ workflowStack: known(workflows),
360
+ startWork: known(startWork),
361
+ herdr: herdr.status,
362
+ },
363
+ budget: { bytesRead: budget.used, limit: budget.limit, exhausted: budget.exhausted },
364
+ summary: {
365
+ sessions: sessions.length,
366
+ waitingOnUser: sessions.filter((session) => session.status === 'waiting-on-user').length,
367
+ active: sessions.filter((session) => session.status === 'active').length,
368
+ },
369
+ sessions: sessions.map(({ cwd: _cwd, lastMs: _lastMs, sessionId: id, ...rest }) => ({ ...rest, self: Boolean(sessionId) && id === sessionId })),
370
+ roadmap: {
371
+ plans: shownPlans,
372
+ plansOmitted: orderedPlans.length - shownPlans.length,
373
+ litgoal: litgoal ?? [],
374
+ progress: roadmapItems.length ? aggregateProgress(roadmapItems) : progressOf(null, null),
375
+ },
376
+ workflows: (workflows ?? []).map((frame) => ({ ...frame, self: Boolean(sessionId) && frame.sessionId === sessionId })),
377
+ startWork: startWork ?? [],
378
+ handoffs,
379
+ commits: (commits ?? []).slice(0, 20).map(({ atMs: _atMs, ...commit }) => commit),
380
+ herdr,
381
+ };
382
+ const next = chooseNextStep(data, sessionId);
383
+ data.next = next;
384
+ data.nextRecorded = false;
385
+ if (record && sessionId) {
386
+ try {
387
+ // The record lives beside the working directory the hook reads (`go`
388
+ // uses `Path.cwd()`), not at the outermost git root: a brief run from a
389
+ // subdirectory must be continued from that same directory.
390
+ writeNextStep(start, sessionId, next, new Date(now));
391
+ data.nextRecorded = true;
392
+ } catch {
393
+ data.nextRecorded = false;
394
+ }
395
+ }
396
+ for (const frame of data.workflows) delete frame.sessionId;
397
+ for (const work of data.startWork) delete work.sessionId;
398
+ return data;
399
+ };
400
+
401
+ const line = (text, width) => clipToWidth(text, width);
402
+ const remainingLine = (names, width) => {
403
+ if (!names.length || (names.length === 1 && names[0] === 'unknown')) return null;
404
+ let out = ' left:';
405
+ for (let index = 0; index < names.length; index += 1) {
406
+ const name = names[index];
407
+ const piece = ` ${name}${index < names.length - 1 ? ',' : ''}`;
408
+ if (displayWidth(out + piece) > width - 4) return `${out} …(+${names.length - index})`;
409
+ out += piece;
410
+ }
411
+ return out;
412
+ };
413
+
414
+ const STATUS_BADGE = { 'waiting-on-user': '● waiting on you', active: '▶ active', ended: '✓ ended', unknown: '○ unknown' };
415
+
416
+ const toolsLine = (tools) => {
417
+ const names = Object.entries(tools ?? {}).sort((a, b) => b[1] - a[1]);
418
+ return names.length ? names.map(([name, count]) => (count > 1 ? `${name} ×${count}` : name)).join(' · ') : null;
419
+ };
420
+
421
+ // Hermes has no typed `go`: the next step names a slash command when one continues the work
422
+ // (`/start-work plans/<f>.md`), otherwise the user answers in chat.
423
+ const sessionNext = (session, data) => {
424
+ const boundary = (data.startWork ?? []).find((item) => item.id8 === session.id8 && item.status === 'paused' && item.pendingBoundary);
425
+ if (boundary) return 'answer in chat to approve or deny the boundary';
426
+ if (session.status === 'waiting-on-user') return 'answer its question';
427
+ return null;
428
+ };
429
+
430
+ /** One session card: status badge, goal in plain words, gauge or known-facts line, done, next. */
431
+ const sessionCard = (session, data, push) => {
432
+ push(`== ${STATUS_BADGE[session.status] ?? session.status} · ${session.id8} · ${session.title}`);
433
+ for (const ask of session.asked.filter((text) => text !== 'unknown').slice(0, 2)) push(`asked: ${ask}`);
434
+ const { gauge: pace, remaining } = session.progress;
435
+ push(pace.startsWith('[▉') || pace.startsWith('[░') ? `${pace} · left: ${remaining.filter((name) => name !== 'unknown').join(', ') || '—'}` : `${pace}${remaining.filter((name) => name !== 'unknown').length ? ` · ${remaining.filter((name) => name !== 'unknown').join(', ')}` : ''}`);
436
+ if (session.status === 'active' && session.lastTool) {
437
+ const target = session.lastTool.target ? ` "${clipToWidth(session.lastTool.target, 50)}"` : '';
438
+ push(`now: ${session.lastTool.tool}${target}`);
439
+ }
440
+ const tools = toolsLine(session.done.tools);
441
+ const commits = session.done.commits.map((commit) => `${commit.hash} ${commit.subject}`);
442
+ if (tools || commits.length) push(`did: ${[tools, ...commits].filter(Boolean).join(' · ')}`);
443
+ const next = sessionNext(session, data);
444
+ if (next) push(`next: ${next}`);
445
+ };
446
+
447
+ const planMark = (plan) => (plan.progress.total !== null && plan.progress.done >= plan.progress.total ? '✓' : plan.current ? '▶' : '○');
448
+
449
+ const ENDED_ROWS = 8;
450
+ const placeOf = (session) => (session.worktree && session.worktree !== 'unknown' ? basename(String(session.worktree)) : 'unknown');
451
+ const goalOf = (session) => (session.title && session.title !== 'unknown' ? session.title : session.asked?.[0] ?? 'unknown');
452
+
453
+ /** Largest unit since `lastMs`, for the ended table's ago column. */
454
+ const ageOf = (lastMs, nowMs) => {
455
+ if (!Number.isFinite(lastMs) || !Number.isFinite(nowMs)) return '·';
456
+ const seconds = Math.max(0, Math.floor((nowMs - lastMs) / 1000));
457
+ if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d`;
458
+ if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h`;
459
+ if (seconds >= 60) return `${Math.floor(seconds / 60)}m`;
460
+ return `${seconds}s`;
461
+ };
462
+
463
+ /** Ended sessions, one table row each, newest first: history, not something to read now. */
464
+ const endedTable = (sessions, nowMs) => {
465
+ const cell = (text) => text || '·';
466
+ const rows = sessions.slice(0, ENDED_ROWS).map((session) => {
467
+ const commits = session.done?.commits?.length ?? 0;
468
+ const tools = toolsLine(session.done?.tools);
469
+ const done = commits ? `${commits} commit${commits === 1 ? '' : 's'}` : tools ? `used ${tools}` : '·';
470
+ const at = Date.parse(session.last);
471
+ return `| ${session.id8} | ${cell(clipToWidth(placeOf(session), 20))} | ${cell(clipToWidth(goalOf(session), 44))} | ${cell(Number.isFinite(at) ? ageOf(at, nowMs) : '·')} | ${cell(done)} |`;
472
+ });
473
+ const more = sessions.length > ENDED_ROWS ? [`…(+${sessions.length - ENDED_ROWS} more ended)`] : [];
474
+ return ['| id | where | goal | ago | done |', '|---|---|---|---|---|', ...rows, ...more];
475
+ };
476
+
477
+ /** Plain-text brief: headline, cards for live sessions, ended table, roadmap, one next step in a box. */
478
+ export const renderAdhd = (data, width = 100, sessionId = null) => {
479
+ const out = [];
480
+ const push = (text) => out.push(line(text, width));
481
+ const recorded = data.next.kind !== 'none';
482
+ const step = recorded ? data.next : suggestNext(data, sessionId);
483
+ const stepName = recorded ? data.next.kind.replace(/-/gu, ' ') : step.label;
484
+ const stepSay = recorded && data.next.say ? ` (say "${data.next.say}")` : '';
485
+ const stepRun = step.command ? ` (run ${step.command})` : recorded ? '' : ' (continue in chat)';
486
+ push(`lit-adhd: ${data.summary.sessions} session(s) · ${data.summary.waitingOnUser} waiting on you · next: ${stepName}${stepSay}${stepRun}`);
487
+ const live = data.sessions.filter((session) => session.status !== 'ended');
488
+ const ended = data.sessions.filter((session) => session.status === 'ended');
489
+ const groups = new Map();
490
+ for (const session of live) {
491
+ const key = session.worktree && session.worktree !== 'unknown' ? session.worktree : '';
492
+ if (!groups.has(key)) groups.set(key, []);
493
+ groups.get(key).push(session);
494
+ }
495
+ for (const [worktree, groupSessions] of groups) {
496
+ if (groups.size > 1 && worktree) push(`-- ${basename(worktree)} --`);
497
+ for (const session of groupSessions) sessionCard(session, data, push);
498
+ }
499
+ if (ended.length) {
500
+ push(`-- ended · ${ended.length} --`);
501
+ for (const row of endedTable(ended, Date.parse(data.generatedAt))) push(row);
502
+ }
503
+ if (data.roadmap.plans.length || data.roadmap.litgoal.length) {
504
+ push('-- roadmap --');
505
+ for (const plan of data.roadmap.plans) {
506
+ const head = `${plan.local ? '' : `${basename(plan.worktree)}: `}${plan.plan} ${plan.progress.gauge}`;
507
+ push(planMark(plan) === '✓' ? `✓ ${head}` : plan.current ? `▶ ${head} now: ${plan.current}` : `○ ${head}`);
508
+ const left = remainingLine(plan.progress.remaining.filter((name) => name !== 'unknown' && name !== plan.current), width);
509
+ if (left) push(` ○ later: ${left.replace(/^ left: /, '')}`);
510
+ }
511
+ if (data.roadmap.plansOmitted) push(` …(+${data.roadmap.plansOmitted} more open plans)`);
512
+ for (const goal of data.roadmap.litgoal) push(`○ litgoal ${goal.progress.gauge} ${goal.objective}`);
513
+ for (const commit of data.commits.slice(0, 5)) push(`✓ ${commit.hash} ${commit.at.slice(0, 10)} ${commit.subject}`);
514
+ }
515
+ {
516
+ if (recorded) {
517
+ const target = data.next.target?.plan ?? data.next.target?.workId ?? '';
518
+ push(`-- next --\n> ${data.next.kind.replace(/-/gu, ' ')}${target ? `: ${target}` : ''}${stepSay}${stepRun}.`);
519
+ } else {
520
+ push(`-- next --\n> ${step.say}${stepRun}.`);
521
+ }
522
+ }
523
+ const unknownSources = Object.entries(data.sources).filter(([, status]) => status !== 'ok').map(([name]) => name);
524
+ if (unknownSources.length) push(`unknown: ${unknownSources.join(', ')}`);
525
+ return `${out.join('\n')}\n`;
526
+ };
527
+
528
+ /** `adhd [--json] [--here] [--since 7d] [--max 12] [--session <id>] [--width N]`. */
529
+ export const runAdhdCli = (args, cwd = process.cwd(), { env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) => {
530
+ const options = { cwd, env, here: false, since: DEFAULT_SINCE, max: DEFAULT_MAX, sessionId: null };
531
+ let json = false;
532
+ let width = Number(stdout.columns) || 100;
533
+ const usage = 'Usage: adhd [--json] [--here] [--since 7d] [--max 12] [--session <id>] [--width N]';
534
+ if (args.some((arg) => arg === '--help' || arg === '-h')) {
535
+ stdout.write(`${usage}\n`);
536
+ return 0;
537
+ }
538
+ for (let index = 0; index < args.length; index += 1) {
539
+ const arg = args[index];
540
+ if (arg === '--json') json = true;
541
+ else if (arg === '--here') options.here = true;
542
+ else if (arg === '--since' && parseSince(args[index + 1])) options.since = args[++index];
543
+ else if (arg === '--max' && /^\d{1,2}$/u.test(args[index + 1] ?? '')) options.max = Number(args[++index]);
544
+ else if (arg === '--session' && /^[A-Za-z0-9._:-]{1,160}$/u.test(args[index + 1] ?? '')) options.sessionId = args[++index];
545
+ else if (arg === '--width' && /^\d{2,3}$/u.test(args[index + 1] ?? '')) width = Number(args[++index]);
546
+ else {
547
+ stderr.write(`adhd: unknown argument ${JSON.stringify(basename(String(arg)))}. ${usage}\n`);
548
+ return 64;
549
+ }
550
+ }
551
+ const data = collectAdhd(options);
552
+ stdout.write(json ? `${JSON.stringify(data, null, 2)}\n` : renderAdhd(data, width, options.sessionId));
553
+ return 0;
554
+ };
@@ -0,0 +1,89 @@
1
+ // Progress gauges for the lit-adhd brief. Code draws them so the model copies them and never
2
+ // invents its own: `[▉▉▉░░] 3/5`, one cell per item up to ten, scaled to ten cells above that.
3
+ // A gauge renders only when both numbers are real. Otherwise the brief shows what is known as a
4
+ // fact line: an indeterminate pulse plus the finished count, or just the pulse. A `?` glyph must
5
+ // never reach the user on any surface.
6
+
7
+ const MAX_CELLS = 10;
8
+ const known = (value) => Number.isInteger(value) && value >= 0;
9
+
10
+ /** `[▉▉▉░░] 3/5`; an unknown total reads `[…] n steps`, an unknown count `[…] running`. */
11
+ export const gauge = (done, total) => {
12
+ if (!known(total)) return known(done) ? `[…] ${done} step${done === 1 ? "" : "s"}` : "[…] running";
13
+ const k = known(done) ? Math.min(done, total) : 0;
14
+ const cells = Math.min(total, MAX_CELLS);
15
+ let filled = k;
16
+ if (total > MAX_CELLS) filled = k === total ? MAX_CELLS : k === 0 ? 0 : Math.min(MAX_CELLS - 1, Math.max(1, Math.floor((k * MAX_CELLS) / total)));
17
+ return `[${"▉".repeat(filled)}${"░".repeat(cells - filled)}] ${k}/${total}`;
18
+ };
19
+
20
+ /** `{done, total, gauge, remaining}` with `remaining` as recorded, or `["unknown"]`. */
21
+ export const progressOf = (done, total, remaining) => ({
22
+ done: known(done) ? done : null,
23
+ total: known(total) ? total : null,
24
+ gauge: gauge(done, total),
25
+ remaining: Array.isArray(remaining) && remaining.length ? remaining : known(total) && known(done) && done >= total ? [] : ["unknown"],
26
+ });
27
+
28
+ /** One gauge over several: the summed totals, or the pulse line when any total is unknown. */
29
+ export const aggregateProgress = (items) => {
30
+ const done = items.reduce((sum, item) => sum + (known(item.done) ? item.done : 0), 0);
31
+ const total = items.length && items.every((item) => known(item.total)) ? items.reduce((sum, item) => sum + item.total, 0) : null;
32
+ return { done, total, gauge: gauge(items.length ? done : null, total) };
33
+ };
34
+
35
+ // Terminal columns a character takes: combining marks and Hangul medial/final jamo take none, East
36
+ // Asian wide and fullwidth characters and most emoji take two.
37
+ const charWidth = (codePoint) => {
38
+ if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0;
39
+ if (/\p{Mn}|\p{Me}|\p{Cf}/u.test(String.fromCodePoint(codePoint)) || (codePoint >= 0x1160 && codePoint <= 0x11ff)) return 0;
40
+ if (
41
+ (codePoint >= 0x1100 && codePoint <= 0x115f)
42
+ || (codePoint >= 0x2e80 && codePoint <= 0xa4cf)
43
+ || (codePoint >= 0xac00 && codePoint <= 0xd7a3)
44
+ || (codePoint >= 0xf900 && codePoint <= 0xfaff)
45
+ || (codePoint >= 0xfe30 && codePoint <= 0xfe4f)
46
+ || (codePoint >= 0xff00 && codePoint <= 0xff60)
47
+ || (codePoint >= 0xffe0 && codePoint <= 0xffe6)
48
+ || (codePoint >= 0x1f300 && codePoint <= 0x1faff)
49
+ || (codePoint >= 0x20000 && codePoint <= 0x3fffd)
50
+ ) return 2;
51
+ return 1;
52
+ };
53
+
54
+ export const displayWidth = (text) => [...String(text)].reduce((sum, ch) => sum + charWidth(ch.codePointAt(0)), 0);
55
+
56
+ /** Clips to `width` display columns, ending in `…` when anything was cut. */
57
+ /**
58
+ * Strip terminal control sequences and bytes, keeping tab and newline. CSI
59
+ * (`ESC [ … letter`) and OSC (`ESC ] … BEL`) sequences go whole so no parameter
60
+ * bytes survive; other C0/C1 controls go byte by byte. A tab joins commit
61
+ * subjects and a newline separates brief lines; neither one drives a terminal.
62
+ */
63
+ /**
64
+ * Strip terminal control sequences and bytes, keeping tab and newline. CSI
65
+ * (`ESC [ ... letter`) and OSC (`ESC ] ... BEL`) sequences go whole so no
66
+ * parameter bytes survive; other C0/C1 controls go byte by byte. A tab joins
67
+ * commit subjects and a newline separates brief lines; neither drives a
68
+ * terminal.
69
+ */
70
+ export const stripTerminalControls = (text) => String(text)
71
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/gu, '')
72
+ .replace(/\x1b\[[\d;?]*[A-Za-z]/gu, '')
73
+ .replace(/[\0-\b\v\f\x0e-\x1f\x7f-\x9f]/gu, '');
74
+
75
+ export const clipToWidth = (text, width) => {
76
+ // Strip first, so a clipped renderer never emits an escape and widths
77
+ // count visible columns.
78
+ const value = stripTerminalControls(text);
79
+ if (displayWidth(value) <= width) return value;
80
+ let out = "";
81
+ let used = 0;
82
+ for (const ch of value) {
83
+ const w = charWidth(ch.codePointAt(0));
84
+ if (used + w > width - 1) break;
85
+ out += ch;
86
+ used += w;
87
+ }
88
+ return `${out}…`;
89
+ };