@litfamily/litclaude 1.0.21 → 1.0.22

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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +34 -92
  3. package/README_ko-KR.md +42 -95
  4. package/bin/icon-font.mjs +264 -0
  5. package/bin/litclaude-ai.js +155 -18
  6. package/docs/assets/readme/badge-version.svg +1 -1
  7. package/docs/hooks.md +100 -5
  8. package/docs/privacy.md +2 -2
  9. package/package.json +13 -3
  10. package/plugins/litclaude/.claude-plugin/plugin.json +22 -2
  11. package/plugins/litclaude/bin/litclaude-hook.js +13 -7
  12. package/plugins/litclaude/bin/litclaude-hud.js +36 -19
  13. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  14. package/plugins/litclaude/hooks/hooks.json +1 -0
  15. package/plugins/litclaude/hooks/lit-band-client.mjs +44 -0
  16. package/plugins/litclaude/hooks/lit-mod.mjs +269 -0
  17. package/plugins/litclaude/lib/auto-handoff.mjs +2 -0
  18. package/plugins/litclaude/lib/automatic-update-refresh.mjs +4 -0
  19. package/plugins/litclaude/lib/automatic-update.mjs +148 -1
  20. package/plugins/litclaude/lib/cache-measurement.mjs +62 -1
  21. package/plugins/litclaude/lib/canonical-skill-resources.mjs +9 -9
  22. package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
  23. package/plugins/litclaude/lib/interface-probe-gate.mjs +2 -1
  24. package/plugins/litclaude/lib/jev-skill-hint.mjs +2 -0
  25. package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
  26. package/plugins/litclaude/lib/litgoal/autoloop.mjs +2 -0
  27. package/plugins/litclaude/lib/litgoal/cli.mjs +2 -0
  28. package/plugins/litclaude/lib/litgoal/ledger.mjs +3 -0
  29. package/plugins/litclaude/lib/litgoal/state.mjs +4 -0
  30. package/plugins/litclaude/lib/mod/frame.mjs +378 -0
  31. package/plugins/litclaude/lib/mod/layout.mjs +76 -0
  32. package/plugins/litclaude/lib/mod/motion.mjs +101 -0
  33. package/plugins/litclaude/lib/mod/render.mjs +24 -0
  34. package/plugins/litclaude/lib/mod/settings.mjs +57 -0
  35. package/plugins/litclaude/lib/mod/state.mjs +374 -0
  36. package/plugins/litclaude/lib/mod/timeline.mjs +124 -0
  37. package/plugins/litclaude/lib/mod/view.mjs +232 -0
  38. package/plugins/litclaude/lib/motion-render-gate.mjs +2 -1
  39. package/plugins/litclaude/lib/office-runtime.mjs +6 -0
  40. package/plugins/litclaude/lib/project-state-root.mjs +34 -2
  41. package/plugins/litclaude/lib/rules/engine.mjs +2 -1
  42. package/plugins/litclaude/lib/rules/format.mjs +24 -3
  43. package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
  44. package/plugins/litclaude/lib/secure-path-read.mjs +46 -0
  45. package/plugins/litclaude/lib/start-work-lifecycle.mjs +4 -0
  46. package/plugins/litclaude/lib/wikify-knowledge.mjs +2 -1
  47. package/plugins/litclaude/scripts/scaffold-plan.mjs +2 -1
  48. package/plugins/litclaude/skills/lit-team/scripts/team.mjs +2 -1
  49. package/plugins/litclaude/skills/lit-typographic-motion/scripts/motion-runtime.mjs +7 -0
package/docs/hooks.md CHANGED
@@ -102,7 +102,7 @@ silent no-op for callers that still invoke it directly.
102
102
 
103
103
  | Event | Runner | Purpose |
104
104
  | --- | --- | --- |
105
- | `SessionStart` | `plugins/litclaude/bin/litclaude-hook.js session-start` | Runs the bounded foreground automatic-update barrier on a fresh session, discovers repo-local rule files, names the newest valid `plans/<slug>.md`, and on `source: compact` spends one of two durable re-injection reservations at reduced caps and, with automatic handoff on, reloads the fresh handoff once. |
105
+ | `SessionStart` | `plugins/litclaude/bin/litclaude-hook.js session-start` | Runs the bounded foreground automatic-update barrier on a fresh session (or starts the detached version-cache refresh when the cache is stale), discovers repo-local rule files, names the newest valid `plans/<slug>.md`, and on `source: compact` spends one of two durable re-injection reservations at reduced caps and, with automatic handoff on, reloads the fresh handoff once. |
106
106
  | `UserPromptSubmit` | `plugins/litclaude/bin/litclaude-hook.js user-prompt-submit` | Detects prompt routes and injects workflow context; independently delivers static rules not already recorded for this session; with `LITCLAUDE_JEV=1` and `TYPESAFE_API_KEY` set, adds at most one Jev skill hint line to an unrouted turn; answers the exact prompts `lit-handoff auto on <percent>`, `lit-handoff auto off` and `lit-handoff auto status` by changing or reporting the automatic handoff setting. |
107
107
  | `PreToolUse` | `plugins/litclaude/bin/litclaude-hook.js pre-tool-use` | Enforces semantic action/root grants before Write, Edit, MultiEdit, NotebookEdit, Bash, Agent, and bounded read tools execute; denies supported reader-facing text writes with a humanizer block-tier finding. |
108
108
  | `PostToolUse` | `plugins/litclaude/bin/litclaude-hook.js post-tool-use` | Names the post-edit checks the edit actually earned, injects any glob-scoped rule matching the edited paths, and checks successfully created DOCX/PPTX/PDF files for humanizer findings, including paths reported by Bash output. |
@@ -182,8 +182,27 @@ commands. `--no-auto-update`, `LITCLAUDE_NO_AUTO_UPDATE`,
182
182
  `NO_UPDATE_NOTIFIER`, and `LITCLAUDE_NO_UPDATE_CHECK` disable it. CI, JSON,
183
183
  dry-run, non-TTY management commands, import, and tool surfaces remain no-op;
184
184
  the host-owned SessionStart hook is the lifecycle exception to the TTY check.
185
- The detached registry cache refresh described in the update-notifier section is
186
- advisory and never performs an install.
185
+
186
+ SessionStart also keeps that cache fresh. When the cache is missing or older than
187
+ 24 hours, and no attempt was recorded in the last 24 hours, the hook records the
188
+ attempt and starts a detached child
189
+ (`plugins/litclaude/lib/automatic-update-refresh.mjs`) that makes one GET for the
190
+ package's latest version, with a 3-second limit, no redirect and a 64 KiB cap. The
191
+ hook does not wait for it. The child writes `latest.json` only if its attempt still
192
+ owns the cache, and never installs anything. A cache file that is not LitClaude's own record, such as another package's, is left untouched; the next fresh session applies the
193
+ version through the transaction above. The request goes to the registry in
194
+ `npm_config_registry` when that is a plain origin, the same registry the install
195
+ uses, and to `registry.npmjs.org` otherwise. Every opt-out above stops the request
196
+ too. Offline, the attempt fails quietly and the next try waits a day.
197
+
198
+ Before installing, the hook reads `installed_plugins.json` under the Claude home. If
199
+ the registered LitClaude version is already the cached version or newer, it skips the
200
+ install. A long-lived Claude Code process keeps running the hook from the version
201
+ folder it started with, so without this check every new session in that process
202
+ would install the same version again. The hook also skips a version whose latest receipt
203
+ under `update-notifier/automatic/` is `rolled-back` or `failed` and less than 24 hours old,
204
+ so a version that just failed is retried once a day, not on every session. A newer version
205
+ is tried at once.
187
206
 
188
207
  ### Jev skill hint (optional)
189
208
 
@@ -262,9 +281,9 @@ through. The `.litclaude` and `.litclaude/jev` folders must be real folders too:
262
281
  is a symlink, the hint reads and writes nothing there, sends no request, and shows no hint or note. Because `TYPESAFE_API_KEY` is exported in the shell that starts Claude Code, the
263
282
  agent's own tools can read it too; use a key dedicated to this feature, with a low spend limit.
264
283
  `litclaude doctor` prints `Jev skill hint: off`, `Jev skill hint: on`,
265
- or `Jev skill hint: flag on but TYPESAFE_API_KEY missing`. The GitHub README shows each
284
+ or `Jev skill hint: flag on but TYPESAFE_API_KEY missing`. The Jev page shows each
266
285
  visible state in sample output under
267
- [What you will see](https://github.com/wjgoarxiv/litclaude#what-you-will-see). The offline regression tests,
286
+ [What you will see](https://github.com/wjgoarxiv/litclaude/blob/main/docs/jev.md#what-you-will-see). The offline regression tests,
268
287
  which replace `fetch` in the real hook process, live in `test/jev-skill-hint.test.mjs`.
269
288
 
270
289
  ## Automatic handoff
@@ -320,6 +339,82 @@ The pieces, in the order they run:
320
339
  project it runs in. The model still has to follow the block reason to write the file; the hook
321
340
  can require nothing more than that.
322
341
 
342
+ ## The LIT band (Claude Code mod)
343
+
344
+ From Claude Code 2.1.287 a plugin can ship a mod: a hooks module that draws inside Claude Code
345
+ itself. LitClaude's is `plugins/litclaude/hooks/lit-mod.mjs`, named under `modules` in
346
+ `hooks/hooks.json` next to the settings hooks above. It installs with the plugin and needs no
347
+ setup. The status line stays as it is; the band adds a live layer above the prompt.
348
+
349
+ While LitClaude work is active in the session (a start-work plan, an open litgoal goal, a lit
350
+ discipline on a running turn, or live subagents) a band sits above the prompt:
351
+
352
+ - A ribbon of rounded pills, `LIT · PLAN · WORK n/m · REVIEW`, then a pink-to-cyan progress bar
353
+ whose leading cell fades in as it fills, the next task, the number of lanes, and the autoloop
354
+ count with the time left in its 30-minute budget. It is fitted to the terminal width, with Hangul
355
+ and emoji counted as two cells; parts drop in a fixed order when the row is narrow.
356
+ - A card with a round border when two or more lanes run or a warning needs a row: the phase
357
+ line, the next task, each lane with its state and elapsed time, and the gates (plan saved,
358
+ interface probe, motion gate, auto-handoff).
359
+ - Amber when something needs attention: a lane quiet for more than 10 minutes, or context within
360
+ five points of the auto-handoff percent (the WORK pill crossfades to amber and the ribbon shows
361
+ `ctx 76% → 80%`). A failed interface probe adds a red row and turns the border red.
362
+ - A notice row and a toast when a gate fires: lit-plan not saved, interface probe pending or
363
+ failed, motion gate, context near the auto-handoff percent, auto-handoff fired.
364
+ - A completion moment when the last task is checked, the work completes or the goal is met: a
365
+ light passes over the brand, embers scatter, and `COMPLETE` shows for about a second.
366
+
367
+ The spinner gains ` · <discipline> · n/m` after its ellipsis. `/lit-evidence` opens a pane with
368
+ three tabs (keys `1`, `2`, `3`): the goal's criteria with their last evidence, blockers and the
369
+ newest litgoal ledger entries; the lanes; and the gates. When the session is idle the band is not
370
+ drawn at all.
371
+
372
+ Motion: entrances pop in 60 ms apart with a slight overshoot (160 ms each), fills and colour
373
+ changes ease in and out (300 ms and 140 ms), notices slide in over 220 ms and leave over 150 ms,
374
+ and the finish lasts at most 1.2 s. The band's `Client` element animates on Claude Code's frame
375
+ clock only while something moves and stops its timer as soon as everything settles. In Claude
376
+ Code 2.1.292 a terminal row whose only change is a non-ASCII glyph is not repainted, so every
377
+ moving part changes ASCII text or colour: bar cells are coloured spaces, embers are ASCII sparks,
378
+ and a glyph swaps only together with its words.
379
+
380
+ Options, in `/config` under LitClaude (stored in `pluginConfigs`):
381
+
382
+ | Option | Values | Default |
383
+ | --- | --- | --- |
384
+ | `motion` | `full`, `subtle` (no overshoot, shimmer or embers, shorter), `off` (final frames only) | `full` |
385
+ | `glyphs` | `auto` (Nerd glyphs when the icon font is installed), `nerd`, or `basic` | `auto` |
386
+ | `band` | `auto` (only while work is active), `always`, `off` | `auto` |
387
+
388
+ Unknown values fall back to the default. The fields are plain strings with no `options` list on
389
+ purpose: Claude Code 2.1.270 and older refuse to load a plugin whose `userConfig` has one, and
390
+ that would take every LitClaude hook down with it. Nerd glyphs reach only the terminal; the
391
+ desktop app and the other surfaces always get the basic set.
392
+
393
+ The rounded pill caps (U+E0B6, U+E0B4) and icons come from a Nerd Font. `litclaude-ai install
394
+ --icon-font`, or a Yes to the install question (default No), adds the symbols-only Nerd Font,
395
+ Symbols Nerd Font Mono (MIT), to the per-user font folder (`~/Library/Fonts` on macOS,
396
+ `~/.local/share/fonts` on Linux, then `fc-cache -f` if present; on Windows it prints how to
397
+ install it by hand). Your terminal font stays as it is: most macOS terminals take the missing
398
+ icons from the installed font through system font fallback, and the installer prints one line for
399
+ your terminal (from `TERM_PROGRAM`) in case it does not. The archive is the pinned Nerd Fonts
400
+ release asset, refused unless its SHA-256 matches; `LITCLAUDE_ICON_FONT_SOURCE` names an offline
401
+ copy of the same archive. The licence goes next to the font, a manifest under `~/.litclaude`
402
+ records the two files, `uninstall` removes only those (and only while unchanged), and `doctor`
403
+ prints `ICON_FONT:`. With `glyphs` on `auto`, the band checks those font folders by stat at session
404
+ start and says what it decided on the `/lit-evidence` gates tab.
405
+
406
+ The mod is read-only. It reads LitClaude's own state and nothing else: the ignition record in
407
+ the per-user temp folder, `.litclaude/boulder.json` (found the way the hooks find it, across a
408
+ linked worktree), and under the outermost Git root's `.litclaude/` the litgoal goal, autoloop
409
+ counter and ledger, the lit-plan, interface-probe and motion turn records, and the auto-handoff
410
+ settings and session record. Each file must be a regular file under its size cap that resolves
411
+ exactly where its path says, so a symlinked file or state folder is skipped. It writes nothing,
412
+ starts no process, uses no network and calls no model. Its pure parts live in
413
+ `plugins/litclaude/lib/mod/` and are tested under Node.
414
+
415
+ On Claude Code releases before 2.1.287 the `modules` key is ignored and every settings hook runs
416
+ as before; there is no band.
417
+
323
418
  ## Bounded-authority start-work lifecycle
324
419
 
325
420
  The hook and CLI share `plugins/litclaude/lib/start-work-lifecycle.mjs`.
package/docs/privacy.md CHANGED
@@ -11,8 +11,8 @@ transcript, model request, or diagnostic report contains no sensitive informatio
11
11
  | Surface | Destination and data | Controls and limits |
12
12
  | --- | --- | --- |
13
13
  | Package installation | npm and its configured registry retrieve package metadata and package bytes; ordinary package-manager connection metadata is visible to those services. Global installation can run LitClaude's postinstall setup. | `LITCLAUDE_AUTO_INSTALL=0` or `LITCLAUDE_POSTINSTALL_SKIP=1` skips automatic plugin/HUD setup. It does not stop npm from fetching the package. |
14
- | Update notice | Interactive `install`, `update`, and `doctor` can schedule an HTTPS GET to `registry.npmjs.org` for the package's latest-version metadata. The request identifies the package and sends an update-notifier user-agent; the request body does not contain project files or prompts. | Attempts are throttled using a 24-hour local cache. `NO_UPDATE_NOTIFIER` or `LITCLAUDE_NO_UPDATE_CHECK` disables the notice/check path, even when the variable is empty. CI, JSON, dry-run, and non-TTY management calls are gated. |
15
- | Automatic update | An eligible cached newer version can trigger npm to fetch and run the installer, then doctor. This can occur on `SessionStart` or interactive management commands. | `LITCLAUDE_NO_AUTO_UPDATE` disables automatic installation but leaves update checks eligible. `LITCLAUDE_NO_UPDATE_CHECK` or `NO_UPDATE_NOTIFIER` disables both paths. SessionStart is a piped host hook, so it does not use the management TTY requirement. |
14
+ | Update notice | Interactive `install`, `update`, and `doctor` can schedule an HTTPS GET to `registry.npmjs.org` for the package's latest-version metadata. A fresh Claude Code session (`SessionStart`) does the same in a detached background process when the cached result is missing or older than 24 hours; that request goes to `npm_config_registry` when it is set to a plain origin. The request identifies the package and sends an update-notifier user-agent; the request body does not contain project files or prompts. | Attempts are throttled using a 24-hour local cache. `NO_UPDATE_NOTIFIER` or `LITCLAUDE_NO_UPDATE_CHECK` disables the notice/check path, even when the variable is empty. CI, JSON, dry-run, and non-TTY management calls are gated. |
15
+ | Automatic update | An eligible cached newer version can trigger npm to fetch and run the installer, then doctor. This can occur on `SessionStart` or interactive management commands. | `LITCLAUDE_NO_AUTO_UPDATE` disables automatic installation and the SessionStart check that feeds it, but leaves the interactive update notice eligible. An install is skipped when Claude Code already lists that version or a newer one. `LITCLAUDE_NO_UPDATE_CHECK` or `NO_UPDATE_NOTIFIER` disables both paths. SessionStart is a piped host hook, so it does not use the management TTY requirement. |
16
16
  | Jev skill hint | Off by default. With `LITCLAUDE_JEV=1` and your own `TYPESAFE_API_KEY`, the `UserPromptSubmit` hook sends one HTTPS POST per eligible prompt to `api.typesafe.ai`. The body holds the prompt truncated to 2,000 characters with home paths, e-mail addresses, and token-shaped strings redacted, plus the list of LitClaude skill names and descriptions. Anything in the prompt without a token shape, such as a hostname, a customer name, or a password not written as `password=…`, is sent as written. No files, tool output, or history are sent. TypeSafe bills the request to your account (about $0.04 per million input tokens). | Unset `LITCLAUDE_JEV` to turn it off. Slash commands, routed prompts, and prompts that name a skill are never sent. Each request has a 1.5-second timeout, no retry, no redirect, and a per-session cap of 200. Because `TYPESAFE_API_KEY` is exported in the shell that starts Claude Code, the agent's own tools can read it; use a key dedicated to this feature, with a low spend limit. |
17
17
  | Public source reader | `litclaude public-read` resolves DNS and sends HTTP(S) GET requests to the supplied public URL and validated redirects. Plain search text becomes a query to `duckduckgo.com/html/`. Requests use a LitClaude user-agent and Accept header. The destination sees the requested path and query, plus normal connection metadata. | The reader rejects credential-bearing URLs and private-network targets, revalidates redirects, and stops at detected authentication/paywall barriers. It does not use a browser login or cookie jar. Redacting a URL in returned evidence does not remove query values from the actual request. |
18
18
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@litfamily/litclaude",
3
- "version": "1.0.21",
4
- "description": "Claude Code-native workflow distribution.",
3
+ "version": "1.0.22",
4
+ "description": "Claude Code plugin: add lit to a prompt and Claude plans, tests, checks and reviews the work, then leaves a handoff for the next session.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "litclaude-ai": "bin/litclaude-ai.js",
@@ -82,9 +82,19 @@
82
82
  },
83
83
  "keywords": [
84
84
  "claude-code",
85
+ "claude-code-plugin",
86
+ "claude",
85
87
  "plugin",
86
88
  "ai-agents",
87
- "orchestration"
89
+ "agent-workflow",
90
+ "orchestration",
91
+ "planning",
92
+ "code-review",
93
+ "research",
94
+ "tdd",
95
+ "context-handoff",
96
+ "korean",
97
+ "office-documents"
88
98
  ],
89
99
  "author": "LitClaude contributors",
90
100
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "litclaude",
3
3
  "description": "Claude Code-native workflow plugin with a bounded-authority start-work lifecycle.",
4
- "version": "1.0.21",
4
+ "version": "1.0.22",
5
5
  "author": {
6
6
  "name": "LitClaude contributors"
7
7
  },
@@ -13,5 +13,25 @@
13
13
  "mcpServers": "./.mcp.json",
14
14
  "lspServers": "./.lsp.json",
15
15
  "displayName": "LitClaude",
16
- "defaultEnabled": true
16
+ "defaultEnabled": true,
17
+ "userConfig": {
18
+ "motion": {
19
+ "type": "string",
20
+ "title": "LIT band motion",
21
+ "description": "How the band above the prompt moves: full, subtle (no overshoot, shimmer or embers) or off.",
22
+ "default": "full"
23
+ },
24
+ "glyphs": {
25
+ "type": "string",
26
+ "title": "LIT band glyphs",
27
+ "description": "auto uses the icon font when it is installed (litclaude-ai install --icon-font); nerd always; basic works with any font.",
28
+ "default": "auto"
29
+ },
30
+ "band": {
31
+ "type": "string",
32
+ "title": "LIT band",
33
+ "description": "When the band shows above the prompt: auto (only while LitClaude work is active), always or off.",
34
+ "default": "auto"
35
+ }
36
+ }
17
37
  }
@@ -744,11 +744,13 @@ const hasUiModeIntent = (normalized) => hasInterfaceModeWord(normalized) && uiSu
744
744
  // ("the report says", "deck of cards", "문서 참고"). Hangul terms are bare substrings for the
745
745
  // reason given above, except 덱, which would match inside 인덱스 and so needs a non-Hangul
746
746
  // neighbour. Code documentation and tool reports are excluded by name: a README, API docs,
747
- // a docstring, a bug, test, coverage or error report are not Word files.
747
+ // a docstring, a bug, test, coverage or error report are not Word files. A Markdown file is
748
+ // excluded only when it is a named project document or sits under docs/; any other `.md` is
749
+ // usually the source of the deck or report ("turn notes.md into slides").
748
750
  const officeVerb = /\b(?:make|making|made|create|creating|write|writing|draft|drafting|prepare|preparing|build|building|generate|generating|produce|producing|put\s+together|turn\b.*\binto|compile)\b|만들|만드|작성|써\s?줘|써\s?주|써서|써봐|쓰고|정리해|정리하|준비|초안|뽑아|짜\s?줘|구성해/u;
749
751
  const slideNoun = /\b(?:slides?|slide\s?deck|deck|presentation|pptx?|powerpoint|keynote)\b|발표|슬라이드|피피티|파워포인트|(?<![가-힣])덱(?![가-힣])|(?<![가-힣])덱을|(?<![가-힣])덱으로/u;
750
752
  const documentNoun = /\b(?:reports?|docx|doc|documents?|word\s+(?:doc(?:ument)?|file)|ms\s?word|white\s?papers?|proposals?|memos?)\b|보고서|리포트|기획서|제안서|계획서|문서|워드|백서/u;
751
- const codeDocumentation = /readme|changelog|contributing|api\s?(?:문서|docs?\b|reference)|docstrings?|jsdoc|javadoc|주석|\.md\b|\bdocs\/|\b(?:bug|test|coverage|error|crash|lint|issue|incident)\s+reports?\b|\breport\s+(?:a|the|this|that)\s+(?:bug|issue|error|problem)/u;
753
+ const codeDocumentation = /readme|changelog|contributing|api\s?(?:문서|docs?\b|reference)|docstrings?|jsdoc|javadoc|주석|\b(?:agents|claude|gemini|skill|security|code_of_conduct|license|authors|architecture|support|governance)\.md\b|\bdocs\/|\b(?:bug|test|coverage|error|crash|lint|issue|incident)\s+reports?\b|\breport\s+(?:a|the|this|that)\s+(?:bug|issue|error|problem)/u;
752
754
 
753
755
  // Returns the office skills a bare-lit prompt asks for, slides first when both are named.
754
756
  const officeIntent = (normalized) => {
@@ -1140,7 +1142,7 @@ const rulesBlockOrEmpty = (build) => {
1140
1142
  }
1141
1143
  };
1142
1144
 
1143
- const staticRulesContext = ({ cwd, sessionId, maxRuleChars, maxResultChars }) =>
1145
+ const staticRulesContext = ({ cwd, sessionId, maxRuleChars, maxResultChars, maxResultBytes }) =>
1144
1146
  rulesBlockOrEmpty(() =>
1145
1147
  staticRulesBlock({
1146
1148
  cwd,
@@ -1148,6 +1150,7 @@ const staticRulesContext = ({ cwd, sessionId, maxRuleChars, maxResultChars }) =>
1148
1150
  sessionId,
1149
1151
  ...(maxRuleChars === undefined ? {} : { maxRuleChars }),
1150
1152
  ...(maxResultChars === undefined ? {} : { maxResultChars }),
1153
+ ...(maxResultBytes === undefined ? {} : { maxResultBytes }),
1151
1154
  }),
1152
1155
  );
1153
1156
 
@@ -1254,11 +1257,12 @@ switch (eventName) {
1254
1257
  // Prompt-time static injection is the straggler lane: SessionStart already recorded
1255
1258
  // what it injected, so this is normally empty and only speaks when a rule file
1256
1259
  // appeared mid-session. Reduced budget, because it can fire on every turn.
1257
- const promptRulesFor = (maxResultChars = DEFAULT_PROMPT_MAX_RESULT_CHARS) => staticRulesContext({
1260
+ const promptRulesFor = (maxResultChars = DEFAULT_PROMPT_MAX_RESULT_CHARS, maxResultBytes) => staticRulesContext({
1258
1261
  cwd: typeof input.cwd === "string" ? input.cwd : process.cwd(),
1259
1262
  sessionId: input.session_id,
1260
1263
  maxRuleChars: Math.min(DEFAULT_PROMPT_MAX_RULE_CHARS, maxResultChars),
1261
1264
  maxResultChars,
1265
+ maxResultBytes,
1262
1266
  });
1263
1267
  // The exact-bare canonical routes and the trusted-resume authority envelope are
1264
1268
  // deliberately excluded: both are fixed payloads where an appended block would
@@ -1269,9 +1273,11 @@ switch (eventName) {
1269
1273
  ? undefined
1270
1274
  : maxBytes - Buffer.byteLength(context, "utf8") - separatorBytes;
1271
1275
  if (remainingBytes !== undefined && remainingBytes <= 0) return context;
1272
- const promptRules = promptRulesFor(remainingBytes === undefined
1273
- ? DEFAULT_PROMPT_MAX_RESULT_CHARS
1274
- : Math.floor(remainingBytes / 4));
1276
+ // Count the rules in real bytes: a worst-case bytes-per-char guess left a long install
1277
+ // path in the context with too little room for even a rule's first line.
1278
+ const promptRules = remainingBytes === undefined
1279
+ ? promptRulesFor()
1280
+ : promptRulesFor(Math.min(DEFAULT_PROMPT_MAX_RESULT_CHARS, remainingBytes), remainingBytes);
1275
1281
  if (!promptRules) return context;
1276
1282
  const combined = `${context}\n\n${promptRules}`;
1277
1283
  return maxBytes === undefined || Buffer.byteLength(combined, "utf8") <= maxBytes
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
- import { dirname, join } from "node:path";
5
+ import { basename, dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import {
8
8
  accentCodeForName,
@@ -13,8 +13,8 @@ import {
13
13
  rainbowText,
14
14
  toneText,
15
15
  } from "../lib/hud-accent-palette.mjs";
16
- import { latestUsageTokens } from "../lib/cache-measurement.mjs";
17
- import { readIgnitionState, renderIgnitionSegment } from "../lib/hud-ignition.mjs";
16
+ import { latestUsageTokensCached } from "../lib/cache-measurement.mjs";
17
+ import { ignitionStatePath, readIgnitionState, renderIgnitionSegment } from "../lib/hud-ignition.mjs";
18
18
  import { jevHudState } from "../lib/jev-skill-hint.mjs";
19
19
  import { resolveProjectStateRoot } from "../lib/project-state-root.mjs";
20
20
  import { recordContextUsage, resolveAutoHandoff } from "../lib/auto-handoff.mjs";
@@ -77,6 +77,19 @@ const shortModel = (raw) => {
77
77
  return compact.slice(0, 10) || "?";
78
78
  };
79
79
 
80
+ // A million-token window reads as 1m, and smaller ones keep their k count.
81
+ const formatWindow = (size) => {
82
+ if (size >= 1000000 && size % 100000 === 0) return `${size / 1000000}m`;
83
+ return `${Math.max(1, Math.round(size / 1000))}k`;
84
+ };
85
+
86
+ // Claude Code sends effort.level only for models that take the effort parameter.
87
+ const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
88
+ const effortLabel = (status) => {
89
+ const level = status.effort?.level;
90
+ return EFFORT_LEVELS.has(level) ? `[${level}]` : "";
91
+ };
92
+
80
93
  const clamp = (value, min = 0, max = 100) => Math.max(min, Math.min(max, Number.isFinite(value) ? value : min));
81
94
 
82
95
  const displayPercent = (value) => Math.round(clamp(value));
@@ -111,7 +124,8 @@ const latestUserMessage = (transcriptPath) => {
111
124
  for (let index = lines.length - 1; index >= 0; index -= 1) {
112
125
  try {
113
126
  const event = JSON.parse(lines[index]);
114
- if (event.type !== "user") continue;
127
+ // Loaded skill bodies and compaction summaries are host-written user entries, not prompts.
128
+ if (event.type !== "user" || event.isMeta === true || event.isCompactSummary === true) continue;
115
129
  const content = event?.message?.content;
116
130
  const text = Array.isArray(content)
117
131
  ? content.filter((part) => part?.type === "text").map((part) => part.text).join(" ")
@@ -227,9 +241,9 @@ const formatReset = (value) => {
227
241
  const minutes = Math.max(0, Math.floor((target - Date.now()) / 60000));
228
242
  const hours = Math.floor(minutes / 60);
229
243
  const days = Math.floor(hours / 24);
230
- if (days > 0) return `↻${days}d${hours % 24}h`;
231
- if (hours > 0) return `↻${hours}h${minutes % 60}m`;
232
- return `↻${minutes}m`;
244
+ if (days > 0) return `↻ ${days}d${hours % 24}h`;
245
+ if (hours > 0) return `↻ ${hours}h${minutes % 60}m`;
246
+ return `↻ ${minutes}m`;
233
247
  };
234
248
 
235
249
  const coloredText = appearance === "dark";
@@ -239,13 +253,13 @@ const formatUsageSegment = (label, value, reset) => {
239
253
  const labelText = text(label);
240
254
  if (value === null) {
241
255
  return coloredText
242
- ? `${labelText} ${accent("[")}${colors.accent}░░${colors.reset}${accent("]")} ${accent("--%")}`
243
- : `${labelText} [${makeBlockBar(0, 2)}] --%`;
256
+ ? `${labelText} ${accent("[")}${colors.accent}░░${colors.reset}${accent("]")}${accent("--%")}`
257
+ : `${labelText} [${makeBlockBar(0, 2)}]--%`;
244
258
  }
245
259
  const pct = displayPercent(value);
246
260
  const resetText = formatReset(reset);
247
261
  const pctText = coloredText ? `${barColor(pct)}${pct}%${colors.reset}` : `${pct}%`;
248
- return `${labelText} ${accent("[")}${makeBlockBar(pct, 2)}${accent("]")} ${pctText}${resetText ? ` ${text(resetText)}` : ""}`;
262
+ return `${labelText} ${accent("[")}${makeBlockBar(pct, 2)}${accent("]")}${pctText}${resetText ? ` ${text(resetText)}` : ""}`;
249
263
  };
250
264
 
251
265
  // Optional Jev skill hint badge, attached to the model label: nothing while LITCLAUDE_JEV is
@@ -312,13 +326,12 @@ const main = async () => {
312
326
  const version = readVersion();
313
327
  const model = shortModel(status.model?.display_name ?? status.model?.id);
314
328
  const maxContext = Number(status.context_window?.context_window_size ?? 200000);
315
- const maxK = Math.max(1, Math.round(maxContext / 1000));
316
- const tokens = latestUsageTokens(status.transcript_path);
317
- const estimatedTokens = tokens > 0 ? tokens : 20000;
329
+ const windowText = formatWindow(maxContext);
318
330
  const statusContextPct = firstNumber(status.context_window?.used_percentage, status.context_window?.usage_percentage);
319
- const rawContextPct = statusContextPct === null ? Math.floor((estimatedTokens * 100) / maxContext) : statusContextPct;
320
- const contextPct = displayPercent(rawContextPct);
321
- const knownContextPct = statusContextPct !== null ? statusContextPct : tokens > 0 ? rawContextPct : null;
331
+ const usageCachePath = join(dirname(ignitionStatePath(status.session_id)), `usage-${basename(ignitionStatePath(status.session_id))}`);
332
+ const tokens = statusContextPct === null ? latestUsageTokensCached(status.transcript_path, usageCachePath) : 0;
333
+ // Without a host percent or a transcript usage record the size is unknown, and the line says so.
334
+ const knownContextPct = statusContextPct !== null ? statusContextPct : tokens > 0 ? Math.floor((tokens * 100) / maxContext) : null;
322
335
  const usage = parseUsage(status);
323
336
  const usageText = ` ${accent(sep)} ${formatUsageSegment("5h", usage.fiveHour, usage.fiveHourReset)} ${accent(sep)} ${formatUsageSegment("1w", usage.weekly, usage.weeklyReset)}`;
324
337
  const git = gitStatus(status.cwd);
@@ -327,13 +340,17 @@ const main = async () => {
327
340
  depth: colorDepth,
328
341
  appearance,
329
342
  });
330
- const contextText = `${text("ctx")} ${accent("[")}${makeContextBar(contextPct)}${accent("]")} ${text(`${contextPct}%/${maxK}k`)}`;
343
+ const contextPct = knownContextPct === null ? null : displayPercent(knownContextPct);
344
+ const contextText = contextPct === null
345
+ ? `${text("ctx")} ${text(`?/${windowText}`)}`
346
+ : `${text("ctx")} ${accent("[")}${makeContextBar(contextPct)}${accent("]")} ${text(`${contextPct}%/${windowText}`)}`;
331
347
  const ignition = renderIgnitionSegment(readIgnitionState(status.session_id), {
332
348
  depth: plainOutput ? "plain" : colorDepth,
333
349
  });
350
+ const effort = effortLabel(status);
334
351
  const jev = jevBadge(status);
335
352
  const autoHandoff = autoHandoffSegment(status, knownContextPct);
336
- const line = `${prefix}${ignition ? ` ${ignition}` : ""} ${accent("|")} ${text(model)}${jev ? ` ${jev}` : ""} ${accent(sep)} ${contextText}${autoHandoff ? ` ${accent(sep)} ${autoHandoff}` : ""}${usageText}${git ? ` ${accent(sep)} ${text("git")} ${text(git)}` : ""}`;
353
+ const line = `${prefix}${ignition ? ` ${ignition}` : ""} ${accent("|")} ${text(model)}${effort ? ` ${text(effort)}` : ""}${jev ? ` ${jev}` : ""} ${accent(sep)} ${contextText}${autoHandoff ? ` ${accent(sep)} ${autoHandoff}` : ""}${usageText}${git ? ` ${accent(sep)} ${text("git")} ${text(git)}` : ""}`;
337
354
  process.stdout.write(`${line}\n`);
338
355
 
339
356
  const lastMessage = latestUserMessage(status.transcript_path);
@@ -344,6 +361,6 @@ const main = async () => {
344
361
  };
345
362
 
346
363
  main().catch((error) => {
347
- process.stdout.write(`[🔥LITCLAUDE v${readVersion()}] | HUD unavailable: ${error.message}\n`);
364
+ process.stdout.write(`[🔥LIT v${readVersion()}] | HUD unavailable: ${error.message}\n`);
348
365
  process.exit(0);
349
366
  });
@@ -9,7 +9,7 @@ import {
9
9
  } from "../lib/wikify-knowledge.mjs";
10
10
 
11
11
  const protocolVersion = "2024-11-05";
12
- const serverVersion = "1.0.21";
12
+ const serverVersion = "1.0.22";
13
13
 
14
14
  const publicSourceReadTool = {
15
15
  name: "public_source_read",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "modules": ["./lit-mod.mjs"],
2
3
  "hooks": {
3
4
  "SessionStart": [
4
5
  {
@@ -0,0 +1,44 @@
1
+ // The LIT band's surface module: the `Client` element lit-mod.mjs draws above the prompt. It runs on
2
+ // the drawing thread with no mods API, keeps the band's timeline in its local state, and animates on
3
+ // the surface's own frame clock only while something moves; once the timeline settles it stops its
4
+ // timer and the band stays still until the hooks module hands it a new model.
5
+
6
+ import { composeBand } from "../lib/mod/frame.mjs";
7
+ import { TIMINGS, motionProfile } from "../lib/mod/motion.mjs";
8
+ import { renderBand } from "../lib/mod/render.mjs";
9
+ import { advanceTimeline, createTimeline, sampleTimeline, timelineBusy } from "../lib/mod/timeline.mjs";
10
+
11
+ // The timeline lives in one holder object that the frame timer and every draw share. The timer
12
+ // never reads `surface.state` back, so a draw that folded in a new model is never overwritten by a
13
+ // tick that started from an older copy.
14
+ export default function LitBand(props, surface) {
15
+ const profile = motionProfile(props?.motion);
16
+ const model = props?.model;
17
+ const empty = () => surface.elements.Box({ children: [] });
18
+ if (!model) return empty();
19
+
20
+ const holder = surface.state?.live ?? { now: 0, model: null, timeline: null, stop: null };
21
+ if (holder.timeline === null) {
22
+ holder.timeline = createTimeline(model, 0, profile);
23
+ holder.model = model;
24
+ } else if (holder.model !== model) {
25
+ holder.timeline = advanceTimeline(holder.timeline, model, holder.now, profile);
26
+ holder.model = model;
27
+ }
28
+
29
+ if (holder.stop === null && timelineBusy(holder.timeline, holder.now, profile)) {
30
+ holder.stop = surface.every(TIMINGS.frame, () => {
31
+ holder.now += TIMINGS.frame;
32
+ if (!timelineBusy(holder.timeline, holder.now, profile)) {
33
+ holder.stop?.();
34
+ holder.stop = null;
35
+ }
36
+ surface.setState({ live: holder, now: holder.now });
37
+ });
38
+ }
39
+ if (surface.state?.live !== holder) surface.setState({ live: holder, now: holder.now });
40
+
41
+ if (!model.visible && !timelineBusy(holder.timeline, holder.now, profile)) return empty();
42
+ const frame = composeBand(model, sampleTimeline(holder.timeline, holder.now, profile), { columns: props.columns, glyphs: props.glyphs });
43
+ return renderBand(surface.elements, frame);
44
+ }