@litfamily/litopencode 1.0.31 → 1.0.32

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 (267) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/dist/activation-probe.js +1 -0
  5. package/dist/activation-prompt-utils.d.ts +1 -1
  6. package/dist/activation-reentry.d.ts +20 -0
  7. package/dist/activation-reentry.js +110 -1
  8. package/dist/activation-routing.d.ts +2 -1
  9. package/dist/activation-routing.js +100 -11
  10. package/dist/activation-workflow-prompts.d.ts +4 -0
  11. package/dist/activation-workflow-prompts.js +30 -0
  12. package/dist/activation.d.ts +1 -1
  13. package/dist/activation.js +44 -5
  14. package/dist/adhd-next.d.ts +51 -0
  15. package/dist/adhd-next.js +150 -0
  16. package/dist/cli/managed-skill-assets.d.ts +1 -1
  17. package/dist/cli/managed-skill-assets.js +7 -1
  18. package/dist/cli/native-skill-integrity.js +1 -1
  19. package/dist/cli.js +7 -1
  20. package/dist/commands.d.ts +28 -1
  21. package/dist/commands.js +48 -1
  22. package/dist/features.d.ts +80 -1
  23. package/dist/features.js +105 -1
  24. package/dist/ledger.js +12 -1
  25. package/dist/secret-shapes.js +5 -5
  26. package/dist/skills.d.ts +22 -1
  27. package/dist/skills.js +36 -0
  28. package/dist/start-work-typed.d.ts +34 -0
  29. package/dist/start-work-typed.js +156 -0
  30. package/dist/state.d.ts +2 -0
  31. package/dist/state.js +2 -0
  32. package/dist/tools.js +22 -2
  33. package/dist/tui/mission-band.d.ts +20 -1
  34. package/dist/tui/mission-band.js +73 -16
  35. package/dist/tui/mission-looks.d.ts +38 -0
  36. package/dist/tui/mission-looks.js +73 -0
  37. package/dist/tui/mission-model.d.ts +2 -0
  38. package/dist/tui/mission-model.js +3 -1
  39. package/dist/tui/mission-rainbow.d.ts +33 -0
  40. package/dist/tui/mission-rainbow.js +90 -0
  41. package/dist/tui/mission-source.d.ts +48 -0
  42. package/dist/tui/mission-source.js +184 -2
  43. package/dist/tui/mission-text.d.ts +2 -0
  44. package/dist/tui/mission-text.js +9 -1
  45. package/dist/tui/mission-threads.d.ts +50 -0
  46. package/dist/tui/mission-threads.js +119 -0
  47. package/dist/tui.js +84 -6
  48. package/dist/uiux-visual-catalog.js +8 -1
  49. package/dist/v2/session-hooks.js +38 -5
  50. package/dist/workflow-stack.d.ts +53 -0
  51. package/dist/workflow-stack.js +196 -0
  52. package/docs/assets/readme/badge-version.svg +1 -1
  53. package/docs/assets/skills/lit-schematic.webp +0 -0
  54. package/docs/reference-Ko-KR.md +30 -2
  55. package/docs/reference.md +34 -2
  56. package/package.json +1 -1
  57. package/skills/frontend-ui-ux/SKILL.md +3 -2
  58. package/skills/frontend-ui-ux/kit/components.css +414 -0
  59. package/skills/frontend-ui-ux/kit/fonts.json +22 -0
  60. package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  61. package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  62. package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  63. package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  64. package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  65. package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  66. package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  67. package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  68. package/skills/frontend-ui-ux/kit/ui.js +226 -0
  69. package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
  70. package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  71. package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
  72. package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
  73. package/skills/frontend-ui-ux/references/house-styles.md +236 -0
  74. package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
  75. package/skills/frontend-ui-ux/references/production.md +3 -1
  76. package/skills/frontend-ui-ux/references/slop-register.md +4 -4
  77. package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  78. package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  79. package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
  80. package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
  81. package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
  82. package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
  83. package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
  84. package/skills/lit-adhd/SKILL.md +208 -0
  85. package/skills/lit-adhd/references/brief-format.md +43 -0
  86. package/skills/lit-adhd/references/card-examples.md +82 -0
  87. package/skills/lit-adhd/references/status-taxonomy.md +29 -0
  88. package/skills/lit-adhd/scripts/rewind.mjs +835 -0
  89. package/skills/lit-diagram-drawer/SKILL.md +4 -3
  90. package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  91. package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  92. package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  93. package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  94. package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  95. package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
  96. package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
  97. package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
  98. package/skills/lit-docx/SKILL.md +2 -1
  99. package/skills/lit-handoff/SKILL.md +2 -0
  100. package/skills/lit-hijack/SKILL.md +422 -0
  101. package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
  102. package/skills/lit-hijack/references/clean-room-build.md +111 -0
  103. package/skills/lit-hijack/references/distil-and-licence.md +100 -0
  104. package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
  105. package/skills/lit-hijack/references/parity-and-report.md +110 -0
  106. package/skills/lit-hijack/scripts/hijack.mjs +993 -0
  107. package/skills/lit-plan/SKILL.md +2 -0
  108. package/skills/lit-pptx/SKILL.md +27 -11
  109. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  110. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  111. package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  112. package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
  113. package/skills/lit-pptx/references/density-and-fill.md +9 -3
  114. package/skills/lit-pptx/references/direction-step.md +21 -6
  115. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
  116. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
  117. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
  118. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
  119. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
  120. package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  121. package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  122. package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  123. package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  124. package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  125. package/skills/lit-pptx/references/layout-families.md +65 -47
  126. package/skills/lit-pptx/references/title-treatments.md +83 -134
  127. package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
  128. package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
  129. package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
  130. package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
  131. package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
  132. package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
  133. package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
  134. package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
  135. package/skills/lit-pptx/references/tonalities/night.md +1 -1
  136. package/skills/lit-pptx/references/tonalities/paper.md +2 -2
  137. package/skills/lit-pptx/references/tonalities/riso.md +137 -0
  138. package/skills/lit-pptx/references/tonalities/signal.md +3 -3
  139. package/skills/lit-pptx/references/tonalities/studio.md +7 -6
  140. package/skills/lit-pptx/run.mjs +2 -1
  141. package/skills/lit-pptx/scripts/compile-deck.js +1 -1
  142. package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
  143. package/skills/lit-pptx/scripts/craft_extras.py +56 -3
  144. package/skills/lit-pptx/scripts/deck_output.py +75 -90
  145. package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  146. package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
  147. package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
  148. package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  149. package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
  150. package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  151. package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  152. package/skills/lit-pptx/scripts/qa_deck.py +10 -11
  153. package/skills/lit-pptx/scripts/render_pages.py +118 -0
  154. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  155. package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  156. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  157. package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  158. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  159. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  160. package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  161. package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  162. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  163. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  164. package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  165. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  166. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  167. package/skills/lit-recap/SKILL.md +4 -0
  168. package/skills/lit-schematic/SKILL.md +118 -0
  169. package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  170. package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  171. package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  172. package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  173. package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  174. package/skills/lit-schematic/references/isometric-craft.md +101 -0
  175. package/skills/lit-schematic/references/journal-figures.md +72 -0
  176. package/skills/lit-schematic/references/kit-api.md +134 -0
  177. package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  178. package/skills/lit-schematic/references/pid-drafting.md +103 -0
  179. package/skills/lit-schematic/references/pid-symbols.md +163 -0
  180. package/skills/lit-schematic/references/verification.md +100 -0
  181. package/skills/lit-schematic/scripts/doctor.mjs +57 -0
  182. package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  183. package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
  184. package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  185. package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  186. package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  187. package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  188. package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  189. package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  190. package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  191. package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  192. package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  193. package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  194. package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  195. package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  196. package/skills/lit-schematic/scripts/motion.mjs +129 -0
  197. package/skills/lit-schematic/scripts/qa.mjs +92 -0
  198. package/skills/lit-schematic/scripts/render.mjs +233 -0
  199. package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  200. package/skills/lit-scientific-visualization/SKILL.md +27 -4
  201. package/skills/lit-typographic-motion/SKILL.md +19 -17
  202. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
  203. package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
  204. package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
  205. package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
  206. package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
  207. package/skills/lit-typographic-motion/engine/board.mjs +138 -0
  208. package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
  209. package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
  210. package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
  211. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
  212. package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
  213. package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
  214. package/skills/lit-typographic-motion/engine/house.mjs +42 -0
  215. package/skills/lit-typographic-motion/engine/look.mjs +65 -8
  216. package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
  217. package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
  218. package/skills/lit-typographic-motion/engine/score.mjs +594 -0
  219. package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
  220. package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
  221. package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
  222. package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
  223. package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
  224. package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
  225. package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
  226. package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
  227. package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
  228. package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
  229. package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
  230. package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
  231. package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
  232. package/skills/lit-typographic-motion/engine/text.mjs +1 -1
  233. package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
  234. package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  235. package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  236. package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  237. package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  238. package/skills/lit-typographic-motion/gate.mjs +18 -2
  239. package/skills/lit-typographic-motion/probe.mjs +13 -1
  240. package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
  241. package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
  242. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
  243. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
  244. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
  245. package/skills/lit-typographic-motion/references/cover.md +56 -13
  246. package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
  247. package/skills/lit-typographic-motion/references/depth.md +112 -0
  248. package/skills/lit-typographic-motion/references/runtime.md +21 -6
  249. package/skills/lit-typographic-motion/references/sound.md +76 -0
  250. package/skills/lit-typographic-motion/references/stage.md +50 -7
  251. package/skills/lit-typographic-motion/references/studio.md +162 -0
  252. package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
  253. package/skills/lit-typographic-motion/references/treatment.md +108 -9
  254. package/skills/lit-typographic-motion/render.mjs +60 -3
  255. package/skills/lit-typographic-motion/styles/paper.json +185 -0
  256. package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
  257. package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
  258. package/skills/lit-typographic-motion/styles/tidal.json +187 -0
  259. package/skills/litresearch/SKILL.md +2 -0
  260. package/skills/managed-skill-manifest.json +517 -88
  261. package/skills/readme-studio/SKILL.md +1 -1
  262. package/skills/review-work/SKILL.md +2 -0
  263. package/skills/start-work/SKILL.md +8 -0
  264. package/skills/workflow-loop/SKILL.md +4 -0
  265. package/tools/check-pack-payload.mjs +4 -2
  266. package/tools/payload-substance-parity.json +4 -3
  267. package/tools/qa-cover-recipes.mjs +4 -3
@@ -0,0 +1,835 @@
1
+ #!/usr/bin/env node
2
+ // lit-adhd collector. It reads what earlier OpenCode sessions in this worktree family left behind and
3
+ // prints one brief to pick the work back up. Everything it opens is opened read-only, every string it
4
+ // prints passes through redact(), and it never writes anything except the one-line next-step record
5
+ // that `go` follows (and only when asked with --write-next). Zero dependencies: Node built-ins, git on PATH.
6
+ import { execFileSync } from "node:child_process";
7
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ export const REWIND_SCHEMA = "litopencode.rewind/v1";
13
+ export const NEXT_SCHEMA = "litopencode.adhd-next/v1";
14
+ export const STACK_SCHEMA = "litopencode.workflow-stack/v1";
15
+ const SQL_TABLES = new Set(["session", "message", "part", "todo"]);
16
+ const TOTAL_BYTE_BUDGET = 8 * 1024 * 1024;
17
+ const FILE_CAP = 256 * 1024;
18
+ const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
19
+ const ACTIVE_WINDOW_MS = 2 * 60_000;
20
+ // Another worktree's plan is shown only when it changed inside this window; an older one is history.
21
+ const PLAN_WINDOW_MS = 7 * 24 * 60 * 60_000;
22
+ const ENDED_ROWS = 8;
23
+ const STATUS_WORDS = ["working", "waits-on-you", "blocked", "paused", "done", "idle"];
24
+
25
+ // ---------------------------------------------------------------------------------------------
26
+ // Redaction. The alternatives below are the credential shapes of the plugin's own secret filter; a
27
+ // test fails when they drift apart, because a skill script cannot import the plugin source.
28
+
29
+ const SECRET_ALTERNATIVES = [
30
+ String.raw`-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----`,
31
+ String.raw`(?<![A-Za-z0-9_])(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_])`,
32
+ String.raw`(?<![A-Za-z0-9_])github_pat[_-][A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])`,
33
+ String.raw`\bsk-[A-Za-z0-9_-]{20,}\b`,
34
+ String.raw`(?<![A-Za-z0-9_])npm_[A-Za-z0-9]{20,}(?![A-Za-z0-9_])`,
35
+ String.raw`(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])`,
36
+ String.raw`(?<![A-Za-z0-9_])(?:xox[baprs]|xapp)-[A-Za-z0-9-]{8,}(?![A-Za-z0-9_-])`,
37
+ String.raw`(?<![A-Za-z0-9_])whsec_[A-Za-z0-9_-]{24,}(?![A-Za-z0-9_])`,
38
+ String.raw`(?<![A-Za-z0-9_])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9_])`,
39
+ String.raw`(?<![A-Za-z0-9_])hf_[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_])`,
40
+ String.raw`(?<![A-Za-z0-9_])SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_])`,
41
+ String.raw`(?<![A-Za-z0-9_])glpat-[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])`,
42
+ String.raw`(?<![A-Za-z0-9_])eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?![A-Za-z0-9_])`,
43
+ String.raw`\b(?:authorization|proxy-authorization)\s*:\s*\S+\s+\S+`,
44
+ String.raw`\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}(?=$|[^A-Za-z0-9._~+/=-])`,
45
+ String.raw`\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?token)["']?\s*[:=]\s*[^\s,;]{8,}`,
46
+ String.raw`\b[A-Za-z][A-Za-z0-9]*(?:[_-][A-Za-z0-9]+)*[_-](?:token|secret|key)(?:[_-][A-Za-z0-9]+)*["']?\s*[:=]\s*[^\s,;]{8,}`,
47
+ String.raw`\b_?auth(?:[_-]?token)?["']?\s*[:=]\s*[^\s,;]{8,}`,
48
+ String.raw`(?<![A-Za-z0-9+.-])(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/[^/?#\s]+@`,
49
+ ];
50
+ export const SECRET_SHAPE_SOURCE = `(?:${SECRET_ALTERNATIVES.join("|")})`;
51
+ const SECRET_SPANS = new RegExp(SECRET_SHAPE_SOURCE, "giu");
52
+ const LOWERCASE_TOKEN = /\btoken\s+[A-Za-z0-9._~+/=-]{8,}(?=$|[^A-Za-z0-9._~+/=-])/gu;
53
+ const NAMED_ASSIGNMENT = /(?<![A-Za-z0-9_])(?:password|passwd|passphrase|secret|token|api[_-]?key|access[_-]?token|auth[_-]?token|[A-Za-z][A-Za-z0-9]*(?:[_-][A-Za-z0-9]+)*[_-](?:password|passwd|passphrase|secret|token|key)(?:[_-][A-Za-z0-9]+)*)["']?\s*[=:]\s*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s,;]+)/giu;
54
+ const SECRET_ENV_NAME = /(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|SESSION)/iu;
55
+ const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F‪-‮⁦-⁩]/gu;
56
+
57
+ const secretEnvValues = () =>
58
+ Object.entries(process.env)
59
+ .filter(([name, value]) => SECRET_ENV_NAME.test(name) && typeof value === "string" && value.length >= 8)
60
+ .map(([, value]) => value);
61
+
62
+ export function redact(value, extra = {}) {
63
+ let text = String(value ?? "").replace(CONTROL, " ");
64
+ // A secret value is also caught in the forms a URL or a header carries it in.
65
+ for (const secret of extra.envValues ?? secretEnvValues()) {
66
+ for (const form of new Set([secret, encodeURIComponent(secret), Buffer.from(secret).toString("base64")])) text = text.split(form).join("[redacted]");
67
+ }
68
+ text = text.replace(SECRET_SPANS, "[redacted]").replace(NAMED_ASSIGNMENT, "[redacted]").replace(LOWERCASE_TOKEN, "[redacted]");
69
+ const home = extra.home ?? homedir();
70
+ if (home.length > 1) text = text.split(home).join("~");
71
+ return text;
72
+ }
73
+
74
+ export function clip(value, max) {
75
+ const flat = String(value ?? "").replace(/\s+/gu, " ").trim();
76
+ const chars = [...flat];
77
+ return chars.length <= max ? flat : `${chars.slice(0, Math.max(0, max - 1)).join("")}…`;
78
+ }
79
+
80
+ const clean = (value, max) => clip(redact(value), max);
81
+
82
+ // ---------------------------------------------------------------------------------------------
83
+ // Terminal width: Hangul, kana, CJK and emoji take two cells. Same table as the TUI's cellWidth.
84
+
85
+ const isWide = (code) =>
86
+ (code >= 0x1100 && code <= 0x115f) || (code >= 0x2e80 && code <= 0x303e) || (code >= 0x3041 && code <= 0x33ff) ||
87
+ (code >= 0x3400 && code <= 0x4dbf) || (code >= 0x4e00 && code <= 0x9fff) || (code >= 0xa000 && code <= 0xa4cf) ||
88
+ (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe4f) ||
89
+ (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) || (code >= 0x1f300 && code <= 0x1f64f) ||
90
+ (code >= 0x1f900 && code <= 0x1f9ff) || (code >= 0x20000 && code <= 0x3fffd);
91
+ const isZeroWidth = (code) => (code >= 0x0300 && code <= 0x036f) || code === 0x200d || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x1160 && code <= 0x11ff);
92
+
93
+ export function cellWidth(value) {
94
+ let width = 0;
95
+ for (const char of String(value)) {
96
+ const code = char.codePointAt(0) ?? 0;
97
+ if (code < 0x20 || isZeroWidth(code)) continue;
98
+ width += isWide(code) ? 2 : 1;
99
+ }
100
+ return width;
101
+ }
102
+
103
+ export function cutCells(value, max) {
104
+ const flat = String(value).replace(/\s+/gu, " ").trim();
105
+ if (cellWidth(flat) <= max) return flat;
106
+ let out = "";
107
+ let width = 0;
108
+ for (const char of flat) {
109
+ const next = cellWidth(char);
110
+ if (width + next > max - 1) break;
111
+ out += char;
112
+ width += next;
113
+ }
114
+ return `${out}…`;
115
+ }
116
+
117
+ const padCells = (value, width) => value + " ".repeat(Math.max(0, width - cellWidth(value)));
118
+
119
+ // ---------------------------------------------------------------------------------------------
120
+ // Progress without placeholders: a bar only for a real total, known facts in words otherwise.
121
+
122
+ export const isRealCount = (done, total) => Number.isInteger(done) && Number.isInteger(total) && total > 0 && done >= 0 && done <= total;
123
+
124
+ export function gauge(done, total, { ascii = false, cells = 10 } = {}) {
125
+ if (!isRealCount(done, total)) return undefined;
126
+ const filled = Math.min(cells, Math.round((done / total) * cells));
127
+ const on = ascii ? "#" : "▉";
128
+ const off = ascii ? "-" : "░";
129
+ return `[${on.repeat(filled)}${off.repeat(cells - filled)}] ${done}/${total}`;
130
+ }
131
+
132
+ export const pulse = (ascii = false) => (ascii ? "~=~" : "░▒▓▒░");
133
+
134
+ export function elapsedWords(minutes) {
135
+ if (!Number.isFinite(minutes) || minutes < 1) return undefined;
136
+ const whole = Math.round(minutes);
137
+ if (whole < 60) return `${whole}m`;
138
+ if (whole < 24 * 60) return `${Math.floor(whole / 60)}h ${whole % 60}m`;
139
+ return `${Math.floor(whole / 1440)}d`;
140
+ }
141
+
142
+ export function factsLine(facts) {
143
+ const parts = [];
144
+ if (Number.isInteger(facts.steps) && facts.steps > 0) parts.push(`${facts.steps} step${facts.steps === 1 ? "" : "s"}`);
145
+ const elapsed = elapsedWords(facts.elapsedMin);
146
+ if (elapsed !== undefined) parts.push(elapsed);
147
+ if (Number.isInteger(facts.lanes) && facts.lanes > 0) parts.push(`${facts.lanes} lane${facts.lanes === 1 ? "" : "s"}`);
148
+ if (facts.lastAction) parts.push(`last: ${facts.lastAction}`);
149
+ return parts.length === 0 ? "nothing recorded yet" : parts.join(" · ");
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------------------------
153
+ // Guarded reads
154
+
155
+ export function guardSql(sql) {
156
+ if (!/^\s*SELECT\b/iu.test(sql) || sql.includes(";")) throw new Error("only a single SELECT is allowed");
157
+ const tables = [...sql.matchAll(/\b(?:from|join)\s+([A-Za-z_][A-Za-z0-9_]*)/giu)].map((match) => match[1].toLowerCase());
158
+ if (tables.length === 0 || tables.some((table) => !SQL_TABLES.has(table))) throw new Error("table outside the allowlist");
159
+ return sql;
160
+ }
161
+
162
+ const budget = { left: TOTAL_BYTE_BUDGET };
163
+
164
+ function readSmall(file, max = FILE_CAP) {
165
+ let stat;
166
+ try {
167
+ stat = lstatSync(file);
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ if (!stat.isFile() || stat.size > max || stat.size > budget.left) return undefined;
172
+ let fd;
173
+ try {
174
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
175
+ if (!fstatSync(fd).isFile()) return undefined;
176
+ const buffer = Buffer.alloc(stat.size);
177
+ const got = readSync(fd, buffer, 0, stat.size, 0);
178
+ budget.left -= got;
179
+ return buffer.subarray(0, got).toString("utf8");
180
+ } catch {
181
+ return undefined;
182
+ } finally {
183
+ if (fd !== undefined) closeSync(fd);
184
+ }
185
+ }
186
+
187
+ function readJson(file, max) {
188
+ const text = readSmall(file, max);
189
+ if (text === undefined) return undefined;
190
+ try {
191
+ return JSON.parse(text);
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
198
+ const str = (value) => (typeof value === "string" ? value : "");
199
+
200
+ function git(cwd, args) {
201
+ try {
202
+ return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024 * 1024 });
203
+ } catch {
204
+ return "";
205
+ }
206
+ }
207
+
208
+ export function worktreeFamily(root) {
209
+ const out = git(root, ["worktree", "list", "--porcelain"]);
210
+ const entries = [];
211
+ let current;
212
+ for (const line of out.split("\n")) {
213
+ if (line.startsWith("worktree ")) {
214
+ current = { dir: line.slice(9).trim(), branch: "" };
215
+ entries.push(current);
216
+ } else if (line.startsWith("branch ") && current) current.branch = line.slice(7).replace(/^refs\/heads\//u, "").trim();
217
+ else if (line === "detached" && current) current.branch = "(detached)";
218
+ }
219
+ if (entries.length === 0) entries.push({ dir: resolve(root), branch: git(root, ["rev-parse", "--abbrev-ref", "HEAD"]).trim() });
220
+ return entries.slice(0, 12);
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------------------------
224
+ // Local state of one worktree: plans, goal, bounded run, workflow stacks, handoff headings, history.
225
+
226
+ export function planChecklist(text) {
227
+ let done = 0;
228
+ let total = 0;
229
+ const rows = [];
230
+ for (const line of text.split(/\r?\n/u)) {
231
+ const match = /^- \[( |x|X)\] +(.*)$/u.exec(line);
232
+ if (match === null) continue;
233
+ total += 1;
234
+ const finished = match[1] !== " ";
235
+ if (finished) done += 1;
236
+ rows.push({ done: finished, title: match[2].split(" — ")[0].trim() });
237
+ }
238
+ return total === 0 ? undefined : { done, total, rows };
239
+ }
240
+
241
+ // A plan scaffold nobody filled in: a row still holding a `<placeholder>`, or every row the same.
242
+ export const isScaffold = (rows) => rows.some((row) => /<[^<>]{1,40}>/u.test(row.title)) || (rows.length > 1 && new Set(rows.map((row) => row.title)).size === 1);
243
+
244
+ function goalOf(raw) {
245
+ if (!isRecord(raw)) return undefined;
246
+ let goal = raw;
247
+ if (Array.isArray(raw.goals)) {
248
+ const list = raw.goals.filter((entry) => isRecord(entry) && Array.isArray(entry.criteria));
249
+ goal = raw.activeGoalId === undefined ? list.at(-1) : list.find((entry) => entry.id === raw.activeGoalId);
250
+ } else if (isRecord(raw.goal) && Array.isArray(raw.goal.criteria)) goal = raw.goal;
251
+ if (!isRecord(goal) || !Array.isArray(goal.criteria)) return undefined;
252
+ const passing = new Set(["pass", "passed", "complete", "completed", "done"]);
253
+ const criteria = goal.criteria.filter(isRecord);
254
+ return {
255
+ title: str(goal.title) || str(goal.objective),
256
+ done: criteria.filter((entry) => passing.has(str(entry.status))).length,
257
+ total: criteria.length,
258
+ open: criteria.filter((entry) => !passing.has(str(entry.status))).map((entry) => str(entry.scenario)).filter(Boolean),
259
+ closed: criteria.filter((entry) => passing.has(str(entry.status))).map((entry) => str(entry.scenario)).filter(Boolean),
260
+ };
261
+ }
262
+
263
+ export function readLocalState(dir) {
264
+ const state = { plans: [], goal: undefined, work: undefined, stacks: new Map(), handoff: [], commits: [] };
265
+ const litLoop = join(dir, ".litopencode", "litgoal", "lit-loop");
266
+ for (const planDir of [join(dir, "plans"), join(dir, ".litopencode", "plans")]) {
267
+ let names = [];
268
+ try {
269
+ names = readdirSync(planDir).filter((name) => name.endsWith(".md")).slice(0, 40);
270
+ } catch {
271
+ continue;
272
+ }
273
+ for (const name of names) {
274
+ const text = readSmall(join(planDir, name), FILE_CAP);
275
+ const checklist = text === undefined ? undefined : planChecklist(text);
276
+ if (checklist === undefined || isScaffold(checklist.rows)) continue;
277
+ const heading = /^#\s+(.+)$/mu.exec(text);
278
+ let changedMs = 0;
279
+ try {
280
+ changedMs = lstatSync(join(planDir, name)).mtimeMs;
281
+ } catch {
282
+ // gone since the listing
283
+ }
284
+ state.plans.push({ path: relative(dir, join(planDir, name)), title: heading ? heading[1].replace(/\s*—\s*Work Plan$/u, "").trim() : name, changedMs, ...checklist });
285
+ }
286
+ }
287
+ state.goal = goalOf(readJson(join(litLoop, "goals.json")));
288
+ const work = readJson(join(litLoop, "work-schema-3.json"));
289
+ if (isRecord(work) && work.schemaVersion === 3) {
290
+ state.work = {
291
+ sessionID: str(work.sessionID),
292
+ status: str(work.status),
293
+ planPath: isRecord(work.plan) ? str(work.plan.path) : "",
294
+ boundary: isRecord(work.pendingBoundary) ? `${str(work.pendingBoundary.action)} on ${basename(str(work.pendingBoundary.root))}` : undefined,
295
+ };
296
+ }
297
+ const stackDir = join(dir, ".litopencode", "workflow-stack");
298
+ try {
299
+ for (const name of readdirSync(stackDir).filter((entry) => entry.endsWith(".json")).slice(0, 40)) {
300
+ const stack = readJson(join(stackDir, name), 64 * 1024);
301
+ if (isRecord(stack) && stack.schema === STACK_SCHEMA && Array.isArray(stack.frames)) state.stacks.set(str(stack.sessionID), stack);
302
+ }
303
+ } catch {
304
+ // no stacks recorded for this worktree
305
+ }
306
+ try {
307
+ for (const name of readdirSync(dir).filter((entry) => /^HANDOFF.*\.md$/u.test(entry)).slice(0, 3)) {
308
+ const text = readSmall(join(dir, name), FILE_CAP);
309
+ if (text !== undefined) state.handoff.push(...[...text.matchAll(/^#{1,3}\s+(.+)$/gmu)].slice(0, 4).map((match) => clean(match[1], 80)));
310
+ }
311
+ } catch {
312
+ // no handoff file
313
+ }
314
+ state.commits = git(dir, ["log", "-n", "6", "--format=%cI%x09%s"])
315
+ .split("\n")
316
+ .filter(Boolean)
317
+ .map((line) => {
318
+ const [at, ...subject] = line.split("\t");
319
+ return { at, subject: clean(subject.join("\t"), 90) };
320
+ });
321
+ return state;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------------------------
325
+ // Stores. Both return the same shapes so the card logic is shared.
326
+
327
+ const TOOL_VERB = { edit: "Edit", write: "Write", read: "Read", bash: "Run", task: "Task", grep: "Search", glob: "Find", webfetch: "Fetch", question: "Ask", todowrite: "Plan" };
328
+
329
+ function lastActionOf(tool, input) {
330
+ const verb = TOOL_VERB[tool] ?? tool;
331
+ // A shell call's own description already says what it does ("Run the gates"); never its command line.
332
+ if (tool === "bash" && isRecord(input) && str(input.description) !== "") return clean(input.description, 60);
333
+ const target = isRecord(input) ? str(input.filePath) || str(input.path) || str(input.description) || str(input.command) || str(input.pattern) : "";
334
+ return clean(target === "" ? verb : `${verb} ${basename(target) === target ? target : basename(target)}`, 60);
335
+ }
336
+
337
+ function summarize(sessionRow, parts, firstPrompt, todos, steps) {
338
+ const tools = [];
339
+ let lastText = "";
340
+ for (const part of parts) {
341
+ if (part.type === "tool" && isRecord(part.state)) tools.push({ tool: str(part.tool), status: str(part.state.status), input: part.state.input });
342
+ else if (part.type === "text" && lastText === "" && part.role === "assistant" && str(part.text).trim() !== "") lastText = str(part.text);
343
+ }
344
+ return { ...sessionRow, firstPrompt, lastText, tools, steps, todos };
345
+ }
346
+
347
+ export async function openSqlite(file) {
348
+ let DatabaseSync;
349
+ try {
350
+ ({ DatabaseSync } = await import("node:sqlite"));
351
+ } catch {
352
+ return { error: "node:sqlite is not available in this Node (22.5 or newer is needed)" };
353
+ }
354
+ let db;
355
+ try {
356
+ db = new DatabaseSync(file, { readOnly: true });
357
+ db.exec("PRAGMA query_only = ON");
358
+ } catch {
359
+ return { error: "the session store could not be opened read-only" };
360
+ }
361
+ const all = (sql, ...params) => db.prepare(guardSql(sql)).all(...params);
362
+ const json = (text) => {
363
+ try {
364
+ return JSON.parse(text);
365
+ } catch {
366
+ return undefined;
367
+ }
368
+ };
369
+ return {
370
+ kind: "sqlite",
371
+ sessions(dirs, limit) {
372
+ if (dirs.length === 0) return [];
373
+ const marks = dirs.map(() => "?").join(",");
374
+ return all(
375
+ `SELECT id, directory, title, time_created AS created, time_updated AS updated FROM session WHERE parent_id IS NULL AND time_archived IS NULL AND directory IN (${marks}) ORDER BY time_updated DESC LIMIT ?`,
376
+ ...dirs,
377
+ limit,
378
+ ).map((row) => ({ id: str(row.id), directory: str(row.directory), title: str(row.title), created: Number(row.created), updated: Number(row.updated) }));
379
+ },
380
+ lanes(ids) {
381
+ if (ids.length === 0) return new Map();
382
+ const marks = ids.map(() => "?").join(",");
383
+ const rows = all(`SELECT parent_id AS parent, COUNT(*) AS n FROM session WHERE parent_id IN (${marks}) AND time_archived IS NULL GROUP BY parent_id`, ...ids);
384
+ return new Map(rows.map((row) => [str(row.parent), Number(row.n)]));
385
+ },
386
+ detail(row) {
387
+ const messages = all("SELECT id, data FROM message WHERE session_id = ? AND length(data) <= 65536 ORDER BY time_created ASC LIMIT 400", row.id)
388
+ .map((entry) => ({ id: str(entry.id), role: str(json(entry.data)?.role) }));
389
+ const role = new Map(messages.map((message) => [message.id, message.role]));
390
+ const parts = all("SELECT message_id AS mid, data FROM part WHERE session_id = ? AND length(data) <= 65536 ORDER BY time_created DESC LIMIT 80", row.id)
391
+ .map((entry) => ({ ...(json(entry.data) ?? {}), role: role.get(str(entry.mid)) ?? "" }));
392
+ const firstUser = messages.find((message) => message.role === "user");
393
+ let firstPrompt = "";
394
+ if (firstUser !== undefined) {
395
+ const first = all("SELECT data FROM part WHERE message_id = ? AND length(data) <= 65536 ORDER BY time_created ASC LIMIT 6", firstUser.id)
396
+ .map((entry) => json(entry.data))
397
+ .find((part) => isRecord(part) && part.type === "text" && !part.synthetic);
398
+ firstPrompt = first ? str(first.text) : "";
399
+ }
400
+ const steps = Number(all("SELECT COUNT(*) AS n FROM part WHERE session_id = ? AND json_extract(data, '$.type') = 'tool'", row.id)[0]?.n ?? 0);
401
+ const todos = all("SELECT content, status FROM todo WHERE session_id = ? ORDER BY position LIMIT 100", row.id).map((entry) => ({ content: str(entry.content), status: str(entry.status) }));
402
+ return summarize(row, parts, firstPrompt, todos, steps);
403
+ },
404
+ };
405
+ }
406
+
407
+ export function openLegacy(storageDir) {
408
+ const sessionRoot = join(storageDir, "session");
409
+ try {
410
+ readdirSync(sessionRoot);
411
+ } catch {
412
+ return { error: "no session store was found" };
413
+ }
414
+ const listJson = (dir, limit) => {
415
+ try {
416
+ return readdirSync(dir).filter((name) => name.endsWith(".json")).slice(0, limit).map((name) => join(dir, name));
417
+ } catch {
418
+ return [];
419
+ }
420
+ };
421
+ return {
422
+ kind: "legacy",
423
+ sessions(dirs, limit) {
424
+ const wanted = new Set(dirs);
425
+ const found = [];
426
+ for (const project of readdirSync(sessionRoot).slice(0, 200)) {
427
+ for (const file of listJson(join(sessionRoot, project), 400)) {
428
+ const info = readJson(file, 64 * 1024);
429
+ if (!isRecord(info) || info.parentID || !wanted.has(str(info.directory))) continue;
430
+ const time = isRecord(info.time) ? info.time : {};
431
+ found.push({ id: str(info.id), directory: str(info.directory), title: str(info.title), created: Number(time.created ?? 0), updated: Number(time.updated ?? 0) });
432
+ }
433
+ }
434
+ return found.sort((a, b) => b.updated - a.updated).slice(0, limit);
435
+ },
436
+ lanes() {
437
+ return new Map();
438
+ },
439
+ detail(row) {
440
+ if (!SAFE_SESSION.test(row.id)) return summarize(row, [], "", [], 0);
441
+ const messages = listJson(join(storageDir, "message", row.id), 400).map((file) => readJson(file, 64 * 1024)).filter(isRecord);
442
+ messages.sort((a, b) => Number(a.time?.created ?? 0) - Number(b.time?.created ?? 0));
443
+ const parts = [];
444
+ let steps = 0;
445
+ let firstPrompt = "";
446
+ for (const message of messages) {
447
+ const id = str(message.id);
448
+ if (!SAFE_SESSION.test(id)) continue;
449
+ for (const part of listJson(join(storageDir, "part", id), 60).map((file) => readJson(file, 64 * 1024)).filter(isRecord)) {
450
+ if (part.type === "tool") steps += 1;
451
+ if (firstPrompt === "" && message.role === "user" && part.type === "text" && !part.synthetic) firstPrompt = str(part.text);
452
+ parts.push({ ...part, role: str(message.role) });
453
+ }
454
+ }
455
+ const todoFile = readJson(join(storageDir, "todo", `${row.id}.json`), 64 * 1024);
456
+ const todos = Array.isArray(todoFile) ? todoFile.filter(isRecord).map((entry) => ({ content: str(entry.content), status: str(entry.status) })) : [];
457
+ return summarize(row, parts.slice(-80).reverse(), firstPrompt, todos, steps);
458
+ },
459
+ };
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------------------------
463
+ // Cards
464
+
465
+ export function countChain({ sessionID, directory, local, detail }) {
466
+ const stack = local.stacks.get(sessionID);
467
+ const frame = stack?.frames?.find((entry) => isRecord(entry) && Number.isInteger(entry.done) && Number.isInteger(entry.total));
468
+ if (frame && isRealCount(frame.done, frame.total)) return { done: frame.done, total: frame.total, source: `${str(frame.skill)} stage` };
469
+ if (local.work?.sessionID === sessionID && local.work.planPath) {
470
+ const plan = local.plans.find((entry) => local.work.planPath.endsWith(entry.path));
471
+ if (plan && isRealCount(plan.done, plan.total)) return { done: plan.done, total: plan.total, source: "plan steps of the run" };
472
+ }
473
+ const open = local.plans.filter((entry) => entry.done < entry.total);
474
+ if (open.length === 1 && isRealCount(open[0].done, open[0].total)) return { done: open[0].done, total: open[0].total, source: "plan steps" };
475
+ if (local.goal && isRealCount(local.goal.done, local.goal.total)) return { done: local.goal.done, total: local.goal.total, source: "goal criteria" };
476
+ const todos = detail.todos;
477
+ if (todos.length > 0 && isRealCount(todos.filter((todo) => todo.status === "completed").length, todos.length)) {
478
+ return { done: todos.filter((todo) => todo.status === "completed").length, total: todos.length, source: "todo list" };
479
+ }
480
+ return undefined;
481
+ }
482
+
483
+ export function statusOf({ detail, local, sessionID, now }) {
484
+ const pending = detail.tools.find((entry) => entry.tool === "question" && (entry.status === "running" || entry.status === "pending"));
485
+ if (pending || (local.work?.sessionID === sessionID && local.work.status === "paused" && local.work.boundary)) return "waits-on-you";
486
+ const latest = detail.tools[0];
487
+ if (latest && latest.status === "error" && now - detail.updated > ACTIVE_WINDOW_MS) return "blocked";
488
+ const stack = local.stacks.get(sessionID);
489
+ if (stack?.frames?.some((entry) => isRecord(entry) && entry.status === "paused")) return "paused";
490
+ if (now - detail.updated <= ACTIVE_WINDOW_MS) return "working";
491
+ const count = countChain({ sessionID, directory: detail.directory, local, detail });
492
+ if (count && count.done === count.total) return "done";
493
+ return "idle";
494
+ }
495
+
496
+ function nextOf(detail, local, sessionID) {
497
+ const todo = detail.todos.find((entry) => entry.status !== "completed" && entry.status !== "cancelled");
498
+ if (todo) return clean(todo.content, 100);
499
+ const open = local.plans.filter((entry) => entry.done < entry.total);
500
+ if (open.length === 1) {
501
+ const row = open[0].rows.find((entry) => !entry.done);
502
+ if (row) return clean(row.title, 100);
503
+ }
504
+ if (local.goal?.open[0]) return clean(local.goal.open[0], 100);
505
+ return undefined;
506
+ }
507
+
508
+ function doneOf(detail, local) {
509
+ const finished = detail.todos.filter((entry) => entry.status === "completed").slice(-3).map((entry) => clean(entry.content, 80));
510
+ if (finished.length > 0) return finished;
511
+ if (local.goal?.closed.length) return local.goal.closed.slice(-2).map((entry) => clean(entry, 80));
512
+ const sentence = detail.lastText.split(/\n/u).map((line) => line.trim()).find((line) => !line.startsWith("```") && /[\p{L}]{3}/u.test(line));
513
+ if (sentence !== undefined) return [clean(sentence, 100)];
514
+ return [];
515
+ }
516
+
517
+ // What a session touched, from its recent tool calls: edited files by base name, and the tools it used
518
+ // most. Only the newest parts are read, so tools are named, never counted in the brief.
519
+ function touchedOf(detail) {
520
+ const edited = [];
521
+ const uses = new Map();
522
+ for (const entry of detail.tools) {
523
+ const verb = TOOL_VERB[entry.tool] ?? entry.tool;
524
+ if (verb) uses.set(verb, (uses.get(verb) ?? 0) + 1);
525
+ const file = isRecord(entry.input) ? str(entry.input.filePath) : "";
526
+ if ((entry.tool === "edit" || entry.tool === "write") && entry.status === "completed" && file !== "" && !edited.includes(basename(file))) edited.push(basename(file));
527
+ }
528
+ const tools = [...uses.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([verb]) => verb);
529
+ return { edited: edited.slice(0, 20).map((name) => clean(name, 60)), tools };
530
+ }
531
+
532
+ export function buildCard({ row, detail, local, lanes, now }) {
533
+ const count = countChain({ sessionID: row.id, directory: row.directory, local, detail });
534
+ const status = statusOf({ detail: { ...detail, updated: row.updated, directory: row.directory }, local, sessionID: row.id, now });
535
+ const latest = detail.tools[0];
536
+ const defaultTitle = /^(?:new session|child session|untitled)/iu.test(row.title) || row.title === "";
537
+ const touched = touchedOf(detail);
538
+ return {
539
+ id: row.id,
540
+ status,
541
+ goal: clean(defaultTitle ? detail.firstPrompt || "(no title)" : row.title, 160),
542
+ done: doneOf(detail, local),
543
+ next: nextOf(detail, local, row.id) ?? null,
544
+ edited: touched.edited,
545
+ tools: touched.tools,
546
+ ...(count ? { counts: count } : {}),
547
+ facts: {
548
+ steps: detail.steps,
549
+ elapsedMin: Math.max(0, Math.round((row.updated - row.created) / 60_000)),
550
+ ...(lanes > 0 ? { lanes } : {}),
551
+ ...(latest ? { lastAction: lastActionOf(latest.tool, latest.input) } : {}),
552
+ },
553
+ updated: new Date(row.updated).toISOString(),
554
+ boundary: local.work?.sessionID === row.id && local.work.boundary ? clean(local.work.boundary, 80) : undefined,
555
+ };
556
+ }
557
+
558
+ function roadmapOf(local) {
559
+ const out = [];
560
+ for (const commit of local.commits.slice(0, 3).reverse()) out.push({ mark: "✓", date: commit.at.slice(5, 10), text: commit.subject });
561
+ const open = local.plans.filter((entry) => entry.done < entry.total);
562
+ if (open.length === 1) {
563
+ const upcoming = open[0].rows.filter((row) => !row.done);
564
+ if (upcoming[0]) out.push({ mark: "▶", date: "now", text: clean(upcoming[0].title, 90) });
565
+ for (const row of upcoming.slice(1, 3)) out.push({ mark: "○", date: "later", text: clean(row.title, 90) });
566
+ }
567
+ return out;
568
+ }
569
+
570
+ export function recommend({ groups, sessionID }) {
571
+ const cards = groups.flatMap((group) => group.sessions.map((card) => ({ card, group })));
572
+ const waiting = cards.find(({ card }) => card.status === "waits-on-you");
573
+ if (waiting) {
574
+ const where = waiting.card.boundary ? `approve or deny ${waiting.card.boundary}` : "answer the open question";
575
+ return { kind: "answer", sessionID: waiting.card.id, text: `In "${clip(waiting.card.goal, 50)}", ${where}.` };
576
+ }
577
+ const blocked = cards.find(({ card }) => card.status === "blocked");
578
+ if (blocked) return { kind: "session", sessionID: blocked.card.id, text: `Open "${clip(blocked.card.goal, 50)}": its last step failed and nothing has run since.` };
579
+ const paused = cards.find(({ card }) => card.status === "paused");
580
+ if (paused) {
581
+ return paused.card.id === sessionID
582
+ ? { kind: "continue", sessionID, text: `Return to the paused discipline in "${clip(paused.card.goal, 50)}" where it stopped.` }
583
+ : { kind: "session", sessionID: paused.card.id, text: `Open "${clip(paused.card.goal, 50)}" and say lit continue.` };
584
+ }
585
+ const working = cards.find(({ card }) => card.status === "working" && card.id !== sessionID);
586
+ if (working) {
587
+ const doing = working.card.facts.lastAction ? ` (now: ${working.card.facts.lastAction})` : "";
588
+ return { kind: "session", sessionID: working.card.id, text: `"${clip(working.card.goal, 50)}" in ${working.group.worktree} is still running${doing}; check on it before starting more.` };
589
+ }
590
+ const run = groups.find((group) => group.work?.status === "active");
591
+ if (sessionID !== undefined && run?.work.sessionID === sessionID) return { kind: "work", sessionID, text: "Keep the running start-work run going from its checked plan." };
592
+ const here = groups.find((group) => group.current);
593
+ const open = here?.plans.filter((plan) => plan.done < plan.total) ?? [];
594
+ if (open.length === 1) return { kind: "plan", sessionID, target: open[0].path, text: `Start work on ${open[0].path}: ${open[0].total - open[0].done} step${open[0].total - open[0].done === 1 ? "" : "s"} left.` };
595
+ const unfinished = cards.find(({ card }) => card.status === "idle" && card.next);
596
+ if (unfinished) return { kind: "session", sessionID: unfinished.card.id, text: `Pick up "${clip(unfinished.card.goal, 50)}": next is ${clip(unfinished.card.next, 60)}.` };
597
+ // Nothing can run on go, so the box still names a real step from the records: an open plan in another
598
+ // worktree, else the most recent session that stopped, else what to do with an empty window.
599
+ for (const group of groups.filter((entry) => !entry.current)) {
600
+ const elsewhere = group.plans.filter((plan) => plan.done < plan.total);
601
+ if (elsewhere.length === 1 && elsewhere[0].next) return { kind: "suggest", sessionID, text: `Next in ${group.worktree}/${elsewhere[0].path}: ${clip(elsewhere[0].next, 60)}.`, short: `${clip(elsewhere[0].next, 40)} (${group.worktree})` };
602
+ }
603
+ const stopped = cards.find(({ card }) => card.id !== sessionID && (card.status === "idle" || card.status === "done"));
604
+ if (stopped) return { kind: "session", sessionID: stopped.card.id, text: `Pick up "${clip(stopped.card.goal, 50)}" in ${stopped.group.worktree}${stopped.card.status === "done" ? ": it finished; check the result" : ""}.` };
605
+ return { kind: "none", sessionID, text: "No earlier session or open plan here: say what to start." };
606
+ }
607
+
608
+ export function writeNext(root, record) {
609
+ if (!SAFE_SESSION.test(record.sessionID)) return false;
610
+ const dir = join(root, ".litopencode", "adhd");
611
+ mkdirSync(dir, { recursive: true });
612
+ const file = join(dir, `next-${record.sessionID}.json`);
613
+ try {
614
+ if (!lstatSync(file).isFile()) return false;
615
+ } catch {
616
+ // no earlier record
617
+ }
618
+ const temp = `${file}.${process.pid}.tmp`;
619
+ writeFileSync(temp, JSON.stringify({ schema: NEXT_SCHEMA, ...record }), { mode: 0o600 });
620
+ renameSync(temp, file);
621
+ return true;
622
+ }
623
+
624
+ export async function collect(options = {}) {
625
+ const root = resolve(options.root ?? process.cwd());
626
+ const now = options.now ?? Date.now();
627
+ budget.left = TOTAL_BYTE_BUDGET;
628
+ const family = options.allDirs ? [] : worktreeFamily(root);
629
+ const dirs = family.map((entry) => resolve(entry.dir));
630
+ const limit = Math.min(Math.max(options.maxSessions ?? 10, 1), 30);
631
+ const dataHome = process.env.XDG_DATA_HOME && isAbsolute(process.env.XDG_DATA_HOME) ? process.env.XDG_DATA_HOME : join(homedir(), ".local", "share");
632
+ const dbFile = options.db ?? join(dataHome, "opencode", "opencode.db");
633
+ let store = lstatOk(dbFile) ? await openSqlite(dbFile) : { error: "no session database was found" };
634
+ if (store.error) {
635
+ const legacy = openLegacy(options.storage ?? join(dataHome, "opencode", "storage"));
636
+ store = legacy.error ? { ...store, error: `${store.error}; ${legacy.error}` } : legacy;
637
+ }
638
+ const report = { schema: REWIND_SCHEMA, generatedAt: new Date(now).toISOString(), root: clean(root, 200), source: { store: store.kind ?? "none", ...(store.error ? { note: store.error } : {}) }, groups: [] };
639
+ const rows = store.kind ? store.sessions(dirs, limit) : [];
640
+ const lanes = store.kind ? store.lanes(rows.map((row) => row.id)) : new Map();
641
+ for (const entry of family) {
642
+ const dir = resolve(entry.dir);
643
+ const local = readLocalState(dir);
644
+ if (dir !== root) local.plans = local.plans.filter((plan) => now - plan.changedMs <= PLAN_WINDOW_MS);
645
+ const sessions = rows.filter((row) => resolve(row.directory) === dir).map((row) => buildCard({ row, detail: store.detail(row), local, lanes: lanes.get(row.id) ?? 0, now }));
646
+ if (sessions.length === 0 && local.plans.length === 0 && local.work === undefined) continue;
647
+ report.groups.push({
648
+ worktree: clean(basename(dir), 60),
649
+ branch: clean(entry.branch, 60),
650
+ current: dir === root,
651
+ sessions,
652
+ plans: local.plans.map(({ path, title, done, total, rows }) => ({ path, title: clean(title, 80), done, total, next: clean(rows.find((entry) => !entry.done)?.title ?? "", 90) })),
653
+ work: local.work ? { sessionID: local.work.sessionID, status: local.work.status } : undefined,
654
+ handoff: local.handoff,
655
+ roadmap: roadmapOf(local),
656
+ });
657
+ }
658
+ report.recommended = recommend({ groups: report.groups, sessionID: options.session });
659
+ const all = report.groups.flatMap((group) => group.sessions);
660
+ const waits = all.filter((card) => card.status === "waits-on-you").length;
661
+ report.headline = all.length === 0
662
+ ? "No earlier sessions found here"
663
+ : `${all.length} session${all.length === 1 ? "" : "s"} · ${waits} waiting on you · ${all.filter((card) => card.status === "working").length} active · next: ${clip(report.recommended.short ?? report.recommended.text, 70)}`;
664
+ report.nextRecord = null;
665
+ const kind = report.recommended.kind;
666
+ const forThisSession = options.session !== undefined && report.recommended.sessionID === options.session;
667
+ if (options.writeNext && forThisSession && (kind === "continue" || kind === "work" || kind === "plan")) {
668
+ const record = { kind, target: report.recommended.target ?? kind, at: now, sessionID: options.session };
669
+ if (writeNext(root, record)) report.nextRecord = { kind, target: record.target };
670
+ } else if (options.writeNext && options.session !== undefined && kind !== "none") {
671
+ // The step belongs to another session or worktree: go here cannot run it, so it answers with where to go.
672
+ const record = { kind: "elsewhere", target: clip(report.recommended.text, 300), at: now, sessionID: options.session };
673
+ if (writeNext(root, record)) report.nextRecord = { kind: "elsewhere", target: record.target };
674
+ }
675
+ return report;
676
+ }
677
+
678
+ function lstatOk(file) {
679
+ try {
680
+ return lstatSync(file).isFile();
681
+ } catch {
682
+ return false;
683
+ }
684
+ }
685
+
686
+ // ---------------------------------------------------------------------------------------------
687
+ // Brief, shape B, in Markdown so OpenCode's chat draws it with colour: a headline, live sessions as
688
+ // cards grouped by worktree and branch, ended sessions folded into one table, the roadmap, one boxed
689
+ // recommendation and `go`. `--ascii` swaps every emoji and box glyph for a plain character.
690
+
691
+ const BADGE = {
692
+ "working": ["🟢", "*", "ACTIVE"],
693
+ "waits-on-you": ["🟠", "!", "WAITS ON YOU"],
694
+ "blocked": ["🔴", "x", "BLOCKED"],
695
+ "paused": ["⏸️", "=", "PAUSED"],
696
+ "done": ["✅", "v", "DONE"],
697
+ "idle": ["⚪", "o", "IDLE"],
698
+ };
699
+ const LIVE = new Set(["working", "waits-on-you", "blocked", "paused"]);
700
+
701
+ // Inline code that a backtick in the data cannot break, and one line of data as Markdown text.
702
+ const code = (text) => `\`${String(text).replace(/`/gu, "'")}\``;
703
+ const md = (text) => String(text).replace(/\s+/gu, " ").trim().replace(/[<>`]/gu, (ch) => ({ "<": "‹", ">": "›", "`": "'" })[ch]).replace(/[*_|[\]\\]/gu, (ch) => `\\${ch}`);
704
+ const shortId = (id) => String(id).replace(/^ses_/u, "").slice(-8);
705
+ const agoOf = (iso, now) => {
706
+ const at = Date.parse(iso);
707
+ if (!Number.isFinite(at)) return undefined;
708
+ return now - at < 60_000 ? "just now" : `${elapsedWords((now - at) / 60_000)} ago`;
709
+ };
710
+
711
+ // Wraps the step to the box at word breaks, three lines at most, the last one cut.
712
+ function wrapCells(text, width, most = 3) {
713
+ const out = [""];
714
+ for (const word of String(text).replace(/\s+/gu, " ").trim().split(" ")) {
715
+ const joined = out.at(-1) ? `${out.at(-1)} ${word}` : word;
716
+ if (cellWidth(joined) <= width || !out.at(-1)) out[out.length - 1] = joined;
717
+ else out.push(word);
718
+ }
719
+ const kept = out.slice(0, most);
720
+ if (out.length > most) kept[most - 1] = `${kept[most - 1]} ${out.slice(most).join(" ")}`;
721
+ return kept.map((line) => cutCells(line, width));
722
+ }
723
+
724
+ function cardLines(card, now, sessionID, ascii) {
725
+ const [emoji, plainMark, word] = BADGE[card.status] ?? BADGE.idle;
726
+ const ago = agoOf(card.updated, now);
727
+ const head = `${ascii ? `[${plainMark}]` : emoji} **${word}** · ${code(shortId(card.id))}${ago ? ` · ${ago}` : ""}${card.id === sessionID ? " · this session" : ""}${card.boundary ? ` · waits for approval of ${md(card.boundary)}` : ""}`;
728
+ const lines = [head, `**${md(cutCells(card.goal, 100))}**`];
729
+ const bar = card.counts ? gauge(card.counts.done, card.counts.total, { ascii }) : undefined;
730
+ const hourglass = ascii ? "~" : "⏳";
731
+ if (bar) lines.push(`${code(bar)} ${md(card.counts.source)} · ${md(factsLine(card.facts))}`);
732
+ else lines.push(`${hourglass} ${md(factsLine(card.facts))}${card.status === "working" ? ` ${code(pulse(ascii))}` : ""}`);
733
+ const done = card.done.length > 0
734
+ ? card.done.map(md).join(" · ")
735
+ : (card.edited ?? []).length > 0
736
+ ? `edited ${card.edited.slice(0, 3).map(code).join(", ")}${card.edited.length > 3 ? ` (+${card.edited.length - 3})` : ""}`
737
+ : (card.tools ?? []).length > 0 ? `no edits yet; used ${card.tools.map(md).join(", ")}` : "nothing recorded yet";
738
+ lines.push(`${ascii ? "+" : "✅"} Done: ${cutCells(done, 110)}`);
739
+ const next = card.next ? md(card.next) : card.status === "working" && card.facts.lastAction ? `now: ${md(card.facts.lastAction)}` : "nothing pending on record";
740
+ lines.push(`${ascii ? ">" : "▶"} Next: ${cutCells(next, 110)}`);
741
+ // A hard line break after every line but the last keeps the card's lines apart inside one quote.
742
+ return lines.map((line, index) => `> ${line}${index < lines.length - 1 ? " " : ""}`);
743
+ }
744
+
745
+ function endedTable(rows, now) {
746
+ const cell = (text) => md(text) || "·";
747
+ const body = rows.slice(0, ENDED_ROWS).map(({ card, group }) => {
748
+ const at = Date.parse(card.updated);
749
+ const age = Number.isFinite(at) ? (elapsedWords((now - at) / 60_000) ?? "0m").split(" ")[0] : "·";
750
+ const edits = (card.edited ?? []).length;
751
+ const done = card.status === "done" ? "finished" : edits > 0 ? `${edits} file${edits === 1 ? "" : "s"}` : "·";
752
+ return `| ${code(shortId(card.id))} | ${cell(cutCells(group.worktree, 20))} | ${cell(cutCells(card.goal, 44))} | ${age} | ${done} |`;
753
+ });
754
+ const more = rows.length > ENDED_ROWS ? ["", `*…and ${rows.length - ENDED_ROWS} more ended*`] : [];
755
+ return ["| id | where | goal | ago | done |", "|---|---|---|---|---|", ...body, ...more];
756
+ }
757
+
758
+ export function renderBrief(report, { ascii = false, width = 72, sessionID } = {}) {
759
+ const glyph = (fancy, plain) => (ascii ? plain : fancy);
760
+ const now = Date.parse(report.generatedAt) || Date.now();
761
+ const lines = [`## ${glyph("🧭 ", "")}${md(cutCells(report.headline, 110))}`];
762
+ const worktrees = report.groups.length;
763
+ lines.push(`*this worktree family · ${worktrees} worktree${worktrees === 1 ? "" : "s"} with records${report.source.note ? ` · ${md(report.source.note)}` : ""}*`);
764
+ const ended = [];
765
+ for (const group of report.groups) {
766
+ for (const card of group.sessions.filter((entry) => !LIVE.has(entry.status))) ended.push({ card, group });
767
+ const live = group.sessions.filter((entry) => LIVE.has(entry.status));
768
+ if (live.length === 0) continue;
769
+ lines.push("", `### ${glyph("🌿 ", "")}${code(group.branch || "(no branch)")} · ${md(group.worktree)}${group.current ? " (this one)" : ""}`);
770
+ for (const card of live) lines.push("", ...cardLines(card, now, sessionID, ascii));
771
+ }
772
+ if (ended.length > 0) {
773
+ ended.sort((a, b) => Date.parse(b.card.updated) - Date.parse(a.card.updated));
774
+ lines.push("", `### ${glyph("⚪ ", "")}Ended · ${ended.length}`, "", ...endedTable(ended, now));
775
+ }
776
+ const roadmap = report.groups.filter((group) => group.roadmap.length > 0);
777
+ if (roadmap.length > 0) {
778
+ lines.push("", `### ${glyph("🗺️ ", "")}Roadmap`, "");
779
+ for (const group of roadmap) {
780
+ const where = group.current ? "" : `${code(group.worktree)} `;
781
+ for (const item of group.roadmap) {
782
+ const mark = glyph(item.mark, { "✓": "v", "▶": ">", "○": "o" }[item.mark]);
783
+ lines.push(`- ${mark} ${item.date === "now" || item.date === "later" ? `**${item.date}**` : item.date} · ${where}${md(item.text)}`);
784
+ }
785
+ }
786
+ }
787
+ const inner = width - 4;
788
+ const title = " Recommended next step ";
789
+ lines.push("", "```text");
790
+ lines.push(`${glyph("╭─", "+-")}${title}${glyph("─", "-").repeat(Math.max(1, inner + 1 - cellWidth(title)))}${glyph("╮", "+")}`);
791
+ for (const text of wrapCells(report.recommended.text, inner)) lines.push(`${glyph("│", "|")} ${padCells(text, inner)} ${glyph("│", "|")}`);
792
+ const runsHere = report.nextRecord && report.nextRecord.kind !== "elsewhere";
793
+ if (runsHere) lines.push(`${glyph("│", "|")} ${padCells("type: go", inner)} ${glyph("│", "|")}`);
794
+ lines.push(`${glyph("╰", "+")}${glyph("─", "-").repeat(inner + 2)}${glyph("╯", "+")}`, "```", "");
795
+ lines.push(runsHere ? "Type **`go`** to do it." : report.nextRecord ? "Nothing runs on its own here: open that session, or say what to do (`go` says where)." : "Nothing runs on its own here: open that session, or say what to do.");
796
+ return lines.join("\n");
797
+ }
798
+
799
+ // ---------------------------------------------------------------------------------------------
800
+
801
+ function parseArgs(argv) {
802
+ const options = { format: "json" };
803
+ for (let index = 0; index < argv.length; index += 1) {
804
+ const arg = argv[index];
805
+ const value = () => argv[++index];
806
+ if (arg === "--json") options.format = "json";
807
+ else if (arg === "--brief") options.format = "brief";
808
+ else if (arg === "--ascii") options.ascii = true;
809
+ else if (arg === "--write-next") options.writeNext = true;
810
+ else if (arg === "--all-dirs") options.allDirs = true;
811
+ else if (arg === "--root") options.root = value();
812
+ else if (arg === "--db") options.db = value();
813
+ else if (arg === "--storage") options.storage = value();
814
+ else if (arg === "--session") options.session = value();
815
+ else if (arg === "--max-sessions") options.maxSessions = Number(value());
816
+ else if (arg === "--now") options.now = Date.parse(value());
817
+ else throw new Error(`unknown argument ${arg}`);
818
+ }
819
+ if (options.session !== undefined && !SAFE_SESSION.test(options.session)) throw new Error("--session must match [A-Za-z0-9_-]");
820
+ if (options.now !== undefined && Number.isNaN(options.now)) throw new Error("--now must be an ISO date");
821
+ return options;
822
+ }
823
+
824
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
825
+ try {
826
+ const options = parseArgs(process.argv.slice(2));
827
+ const report = await collect(options);
828
+ process.stdout.write(`${options.format === "brief" ? renderBrief(report, { ascii: options.ascii === true || process.env.NO_COLOR !== undefined && process.env.TERM === "dumb", sessionID: options.session }) : JSON.stringify(report, null, 2)}\n`);
829
+ } catch (error) {
830
+ process.stderr.write(`rewind: ${error instanceof Error ? error.message : String(error)}\n`);
831
+ process.exitCode = 2;
832
+ }
833
+ }
834
+
835
+ export { STATUS_WORDS };