@litfamily/lithermes 1.0.31 → 1.0.33

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 (50) hide show
  1. package/README.md +7 -7
  2. package/README_Ko-KR.md +7 -7
  3. package/assets/lithermes-plugin/README.md +0 -12
  4. package/assets/lithermes-plugin/adhd_stack.py +43 -1
  5. package/assets/lithermes-plugin/auto_handoff.py +13 -2
  6. package/assets/lithermes-plugin/bounded_work.py +33 -3
  7. package/assets/lithermes-plugin/core.py +59 -18
  8. package/assets/lithermes-plugin/core_review.py +2 -2
  9. package/assets/lithermes-plugin/lit_mark.py +2 -7
  10. package/assets/lithermes-plugin/payload-version.json +43 -47
  11. package/assets/lithermes-plugin/plugin.yaml +1 -1
  12. package/assets/lithermes-plugin/rules/bundled-rules/baseline-discipline.md +7 -13
  13. package/assets/lithermes-plugin/rules/globmatch.py +1 -1
  14. package/assets/lithermes-plugin/skills/debugging/SKILL.md +1 -1
  15. package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +1 -1
  16. package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +2 -2
  17. package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +1 -1
  18. package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +1 -1
  19. package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +4 -4
  20. package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +1 -1
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxtjs.csv +1 -1
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/manifest.json +2 -2
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
  24. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +10 -10
  25. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +12 -7
  26. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +81 -27
  27. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +34 -6
  28. package/assets/lithermes-plugin/skills/lit-burnoff/SKILL.md +1 -1
  29. package/assets/lithermes-plugin/skills/lit-code/SKILL.md +4 -4
  30. package/assets/lithermes-plugin/skills/lit-code/references/rust/README.md +2 -2
  31. package/assets/lithermes-plugin/skills/lit-code/references/rust/async-tokio.md +4 -4
  32. package/assets/lithermes-plugin/skills/lit-code/references/rust/axum-stack.md +1 -1
  33. package/assets/lithermes-plugin/skills/lit-code/references/rust/clap-stack.md +1 -1
  34. package/assets/lithermes-plugin/skills/lit-code/references/rust/libraries.md +3 -3
  35. package/assets/lithermes-plugin/skills/lit-code/references/rust/proptest-insta.md +10 -10
  36. package/assets/lithermes-plugin/skills/lit-code/references/rust/zero-cost-safety.md +1 -1
  37. package/assets/lithermes-plugin/skills/lit-code/references/typescript/README.md +2 -2
  38. package/assets/lithermes-plugin/skills/lit-code/references/typescript/backend-hono.md +1 -1
  39. package/assets/lithermes-plugin/skills/lit-code/references/typescript/tsconfig-strict.md +1 -1
  40. package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/check-no-excuse-rules.ts +2 -2
  41. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  42. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package.json +2 -1
  43. package/assets/lithermes-plugin/skills/rules/SKILL.md +1 -1
  44. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
  45. package/assets/{lithermes-plugin → npm-channel}/auto_update.py +43 -9
  46. package/package.json +1 -1
  47. package/readme-assets/badge-version.svg +1 -1
  48. package/src/lib/canonicalCorpus.js +1 -1
  49. package/src/lib/install.js +22 -0
  50. package/src/lib/skillPayload.js +10 -5
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: seven armored robots power on one by one, the LitHermes robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: seven armored robots power on one by one, the LitHermes robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  <p align="center">
4
- <a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/badge-version.svg" alt="1.0.31" /></a>
5
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/badge-license.svg" alt="MIT license" /></a>
4
+ <a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-version.svg" alt="1.0.33" /></a>
5
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-license.svg" alt="MIT license" /></a>
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a> &nbsp;
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition film</a> &nbsp;
11
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
9
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a> &nbsp;
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition film</a> &nbsp;
11
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
12
12
  </p>
13
13
 
14
14
  # LitHermes
@@ -17,7 +17,7 @@
17
17
 
18
18
  A plugin for **Hermes Agent**. Put `lit` in front of a request, and Hermes plans the task, does it, checks the result and writes down where things stand, so the next session can pick up where this one stopped.
19
19
 
20
- **[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/README_Ko-KR.md)
20
+ **[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/README_Ko-KR.md)
21
21
 
22
22
  ## Install
23
23
 
package/README_Ko-KR.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 일곱 로봇 패널이 차례로 켜지고, LitHermes 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 일곱 로봇 패널이 차례로 켜지고, LitHermes 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  <p align="center">
4
- <a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/badge-version.svg" alt="1.0.31" /></a>
5
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/badge-license.svg" alt="MIT license" /></a>
4
+ <a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-version.svg" alt="1.0.33" /></a>
5
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-license.svg" alt="MIT license" /></a>
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a> &nbsp;
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition 영상</a> &nbsp;
11
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
9
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a> &nbsp;
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition 영상</a> &nbsp;
11
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
12
12
  </p>
13
13
 
14
14
  # LitHermes
@@ -17,7 +17,7 @@
17
17
 
18
18
  **Hermes Agent**용 플러그인입니다. 요청 앞에 `lit`을 붙이면 Hermes가 작업을 계획하고, 해내고, 결과를 확인한 뒤 어디까지 왔는지 적어 둡니다. 그래서 다음 세션이 그 기록을 읽고 멈춘 자리에서 이어 갈 수 있습니다.
19
19
 
20
- **[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.31/README.md)
20
+ **[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/README.md)
21
21
 
22
22
  ## 설치
23
23
 
@@ -111,18 +111,6 @@ CLI/plugin name, not the npm package name.
111
111
  `lit-brainstorm` (after `lit`: `brainstorm`, `brainstorming`, `ideation`, `ideate`, `idea`, `ideas`),
112
112
  `lit-burnoff`, `lit-burnoff-file`, `comment-checker`, `lsp`, `lsp-setup`, and
113
113
  `rules`.
114
- - On the first interactive top-level `pre_llm_call`, the payload runs one
115
- foreground update barrier by default. It resolves the host-owned
116
- `HERMES_HOME`, validates an exact stable registry version, and calls the
117
- package's transaction bridge with `npx --yes --package @litfamily/lithermes@<version> -- lithermes
118
- __auto-update --hermes-home PATH`. The bridge performs the real guarded
119
- `install --yes --no-hud --no-style --no-patch-installed-hermes` child, separate locking, backup,
120
- journal/receipt, rollback on interruption or timeout, and a truthful
121
- post-install doctor. `--no-auto-update`, `LITHERMES_NO_AUTO_UPDATE`,
122
- `NO_UPDATE_NOTIFIER`, and `LITHERMES_NO_UPDATE_CHECK` disable the relevant
123
- path. Noninteractive, CI, JSON, dry-run, HUD/help/version/uninstall, and
124
- delegate sessions remain no-op. The update bridge never receives npm
125
- credentials and never uses the unsupported `--home` option.
126
114
  - Optional automatic handoff (`auto_handoff.py`), off by default. The user
127
115
  switches it on with `/lit-handoff auto on <percent>` (plain text reply, no
128
116
  model call; `auto off` and `auto status` likewise) or with
@@ -13,7 +13,10 @@ step the last brief offered: the hook reads the next-step record the collector w
13
13
  session (``.hermes/lithermes/adhd/next-<session>.json``) and returns a short context note for
14
14
  the model. A plan saved under ``plans/`` during a lit-plan turn writes the same record
15
15
  (``start-plan``), so ``go`` right after planning shows that plan's ``/start-work`` command. A record older than 30 minutes, or no record at all, reads as no next step on
16
- record. Hermes has no typed ``go`` command; ``go`` is a word the hook consumes.
16
+ record. A ``continue-work`` record is re-checked against the run's state first: a run that is no
17
+ longer active or no longer bound to this session starts nothing, and a ``wait-lanes`` record
18
+ (other lanes were at work) starts nothing either. Hermes has no typed ``go`` command; ``go`` is
19
+ a word the hook consumes.
17
20
  """
18
21
  from __future__ import annotations
19
22
 
@@ -214,6 +217,31 @@ def read_next_step(workspace: Path | str, session_id: Any) -> dict[str, Any] | N
214
217
  return data if isinstance(data, dict) and isinstance(data.get("kind"), str) else None
215
218
 
216
219
 
220
+ _WORK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$")
221
+
222
+
223
+ def _run_still_owned(workspace: Path | str, target: dict[str, Any], session_id: Any) -> bool:
224
+ """True when the recorded bounded-work run is still active and bound to this session.
225
+
226
+ Reads the run's state file without taking its lock, so `go` never waits on a busy run.
227
+ A missing, unreadable or ownerless run does not hold. Never raises.
228
+ """
229
+ work_id = str(target.get("workId") or "")
230
+ if not _WORK_ID.match(work_id):
231
+ return False
232
+ try:
233
+ path = Path(workspace) / ".hermes" / "lithermes" / "work" / work_id / "state.json"
234
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_STATE_BYTES:
235
+ return False
236
+ state = json.loads(path.read_text(encoding="utf-8"))
237
+ except (OSError, ValueError):
238
+ return False
239
+ if not isinstance(state, dict):
240
+ return False
241
+ owner = state.get("active_session_id")
242
+ return state.get("status") == "active" and bool(owner) and owner == str(session_id or "")
243
+
244
+
217
245
  def go_note(workspace: Path | str, session_id: Any, *, now: float | None = None) -> str:
218
246
  """The context note a consumed `go` injects: what to do next, or that nothing is on record."""
219
247
  import time as _time
@@ -254,6 +282,20 @@ def go_note(workspace: Path | str, session_id: Any, *, now: float | None = None)
254
282
  f"`/start-work {detail}`. Present that command for the user to run; do not run it, "
255
283
  "and do not start anything else."
256
284
  )
285
+ if record.get("kind") == "wait-lanes":
286
+ lanes = target.get("lanes")
287
+ count = f"{lanes} " if isinstance(lanes, int) and lanes > 0 else ""
288
+ return (
289
+ f"The user typed `go` while {count}other lane(s) of this repo were still at work when "
290
+ "the brief ran: start nothing. Say that `go` waits until those lanes finish, and offer "
291
+ "`lit adhd` for a fresh brief once they have."
292
+ )
293
+ if record.get("kind") == "continue-work" and not _run_still_owned(workspace, target, session_id):
294
+ return (
295
+ f"The user typed `go`, but the brief's step no longer holds: run {detail or 'unknown'} "
296
+ "is no longer active or no longer owned by this session. Say so and start nothing; "
297
+ "offer `lit adhd` for a fresh brief."
298
+ )
257
299
  if record.get("kind") == "approve-boundary":
258
300
  return (
259
301
  "YOUR TURN\n"
@@ -350,14 +350,23 @@ def _host_comparison(setting: Setting) -> tuple[str, bool]:
350
350
 
351
351
  # ---- the host's own compaction point ---------------------------------------
352
352
 
353
+ def _yaml_module() -> Any:
354
+ try:
355
+ return importlib.import_module("yaml")
356
+ except ImportError:
357
+ return None
358
+
359
+
353
360
  def _host_config() -> dict[str, Any]:
361
+ yaml = _yaml_module()
362
+ if yaml is None:
363
+ return {"_lithermes_yaml": "missing"}
354
364
  try:
355
365
  path = get_hermes_home() / "config.yaml"
356
366
  if path.stat().st_size > 1024 * 1024:
357
367
  return {}
358
- yaml = importlib.import_module("yaml")
359
368
  data = yaml.safe_load(path.read_text(encoding="utf-8"))
360
- except Exception: # noqa: BLE001 - a config we cannot read only removes the comparison
369
+ except (OSError, ValueError, UnicodeDecodeError, yaml.YAMLError):
361
370
  return {}
362
371
  return data if isinstance(data, dict) else {}
363
372
 
@@ -459,6 +468,8 @@ def _context_window(model: str, base_url: str, provider: str, config: Mapping[st
459
468
  def host_estimate() -> tuple[float | None, str]:
460
469
  """Estimate Hermes' compaction point for the configured model, or say why not."""
461
470
  config = _host_config()
471
+ if config.get("_lithermes_yaml") == "missing":
472
+ return None, "PyYAML is not importable, so the compaction comparison is missing"
462
473
  section = config.get("model") if isinstance(config.get("model"), Mapping) else {}
463
474
  model = str(section.get("default") or "")
464
475
  window = _context_window(model, str(section.get("base_url") or ""), str(section.get("provider") or ""), config)
@@ -16,6 +16,7 @@ import re
16
16
  import secrets
17
17
  import shlex
18
18
  import tempfile
19
+ import time
19
20
  from contextlib import contextmanager
20
21
  from pathlib import Path
21
22
  from typing import Any, Iterator
@@ -38,6 +39,7 @@ MAX_HISTORY = 64
38
39
  MAX_LEDGER_EVENTS = 96
39
40
  MAX_IDENTIFIER = 96
40
41
  MAX_SESSION = 256
42
+ PROMPT_LOCK_WAIT_SECONDS = 2.0
41
43
  TERMINAL_STATUSES = {"cancelled", "completed"}
42
44
  PROGRESS_STATUSES = {"pending", "in_progress", "blocked", "pass", "fail"}
43
45
  _FORBIDDEN_ACTION_TOKENS = {
@@ -202,15 +204,32 @@ def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
202
204
  pass
203
205
 
204
206
 
207
+ class BoundedWorkBusy(OSError):
208
+ """The run lock stayed held past the caller's wait limit."""
209
+
210
+
205
211
  @contextmanager
206
- def _locked(run_dir: Path) -> Iterator[None]:
212
+ def _locked(run_dir: Path, wait_seconds: float | None = None) -> Iterator[None]:
207
213
  run_dir.mkdir(parents=True, exist_ok=True)
208
214
  lock_path = run_dir / ".lock"
209
215
  with lock_path.open("a+", encoding="utf-8") as handle:
210
216
  try:
211
217
  import fcntl
212
218
 
213
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
219
+ if wait_seconds is None:
220
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
221
+ else:
222
+ # Prompt-path callers retry a non-blocking lock until the limit,
223
+ # so a stuck holder cannot stall the user's turn.
224
+ deadline = time.monotonic() + wait_seconds
225
+ while True:
226
+ try:
227
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
228
+ break
229
+ except BlockingIOError:
230
+ if time.monotonic() >= deadline:
231
+ raise BoundedWorkBusy(f"bounded work lock is busy: {run_dir}") from None
232
+ time.sleep(0.05)
214
233
  yield
215
234
  finally:
216
235
  try:
@@ -430,12 +449,13 @@ def bind_bounded_work(
430
449
  activation_token: str,
431
450
  *,
432
451
  session_id: str,
452
+ wait_seconds: float | None = None,
433
453
  ) -> dict[str, Any] | None:
434
454
  root = _canonical_worktree(worktree)
435
455
  if not session_id or len(session_id) > MAX_SESSION or len(activation_token) > 256:
436
456
  return None
437
457
  run_dir = _work_dir(root, work_id)
438
- with _locked(run_dir):
458
+ with _locked(run_dir, wait_seconds):
439
459
  state = _reconcile_locked(run_dir)
440
460
  if state["status"] != "active":
441
461
  return None
@@ -1018,6 +1038,16 @@ def consume_activation_message(message: str, *, session_id: str) -> str | None:
1018
1038
  payload["work_id"],
1019
1039
  payload["token"],
1020
1040
  session_id=session_id,
1041
+ wait_seconds=PROMPT_LOCK_WAIT_SECONDS,
1042
+ )
1043
+ except BoundedWorkBusy:
1044
+ return "\n".join(
1045
+ [
1046
+ '<lithermes-bounded-work schema="3" status="busy">',
1047
+ "The bounded work run is busy: another LitHermes call holds its lock, so this activation was not bound.",
1048
+ "Tell the user the run is busy and to send it again in a moment. No authority is active for this turn.",
1049
+ "</lithermes-bounded-work>",
1050
+ ]
1021
1051
  )
1022
1052
  except (OSError, ValueError):
1023
1053
  return None
@@ -71,11 +71,6 @@ try:
71
71
  except (ImportError, ModuleNotFoundError):
72
72
  import rules as _rules
73
73
 
74
- try:
75
- from . import auto_update as _auto_update
76
- except (ImportError, ModuleNotFoundError):
77
- import auto_update as _auto_update
78
-
79
74
  try:
80
75
  from . import auto_handoff as _auto_handoff
81
76
  except (ImportError, ModuleNotFoundError):
@@ -1656,6 +1651,60 @@ def browser_drive_tool_guard(
1656
1651
  }
1657
1652
 
1658
1653
 
1654
+ NPM_CHANNEL_MARKER_NAME = ".lithermes-npm-channel"
1655
+ NPM_CHANNEL_MARKER_TEXT = "lithermes npm channel\n"
1656
+ _NPM_CHANNEL_MODULE: Any = False
1657
+
1658
+
1659
+ def _npm_channel_module() -> Any:
1660
+ """Load the npm updater once. Reloading it would clear its per-session guard."""
1661
+
1662
+ global _NPM_CHANNEL_MODULE
1663
+ if _NPM_CHANNEL_MODULE is not False:
1664
+ return _NPM_CHANNEL_MODULE
1665
+ import importlib.util
1666
+
1667
+ here = Path(__file__).resolve().parent
1668
+ module_path = here / "auto_update.py"
1669
+ marker_path = here / NPM_CHANNEL_MARKER_NAME
1670
+ loaded = None
1671
+ try:
1672
+ if (
1673
+ not module_path.is_symlink()
1674
+ and not marker_path.is_symlink()
1675
+ and module_path.is_file()
1676
+ and marker_path.is_file()
1677
+ and marker_path.read_text(encoding="utf-8") == NPM_CHANNEL_MARKER_TEXT
1678
+ ):
1679
+ spec = importlib.util.spec_from_file_location("_lithermes_npm_channel", module_path)
1680
+ if spec is not None and spec.loader is not None:
1681
+ module = importlib.util.module_from_spec(spec)
1682
+ sys.modules[spec.name] = module
1683
+ spec.loader.exec_module(module)
1684
+ loaded = module
1685
+ except Exception:
1686
+ loaded = None
1687
+ _NPM_CHANNEL_MODULE = loaded
1688
+ return loaded
1689
+
1690
+
1691
+ def _npm_channel_pre_llm(kwargs: dict[str, Any]) -> None:
1692
+ """Run the npm-only updater when this directory carries both channel files.
1693
+
1694
+ A catalog copy never has the sibling module and the marker together, so it
1695
+ cannot reach the updater. A symlink is not the installer's file.
1696
+ """
1697
+
1698
+ module = _npm_channel_module()
1699
+ if module is None:
1700
+ return
1701
+ try:
1702
+ module.pre_llm_call(**kwargs)
1703
+ except Exception as exc:
1704
+ if exc.__class__.__module__ == "_lithermes_npm_channel" and exc.__class__.__name__ == "UnknownStateError":
1705
+ raise
1706
+
1707
+
1659
1708
  def pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
1660
1709
  # Hermes identifies delegate_task children with platform="subagent".
1661
1710
  # Child goals may quote words such as "lit" or "lit-crucible" as inert task
@@ -1664,19 +1713,11 @@ def pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
1664
1713
  release_browser_drive_state(kwargs.get("session_id"))
1665
1714
  return None
1666
1715
  # A first interactive top-level turn is the only plugin lifecycle point
1667
- # where the Python payload can safely foreground an exact npm update. The
1668
- # bridge owns its own recursion guard, timeout, transaction, rollback, and
1669
- # receipt; it never contributes text to the model-facing context.
1670
- try:
1671
- _auto_update.pre_llm_call(**kwargs)
1672
- except _auto_update.UnknownStateError:
1673
- # A failed restore is not an advisory miss: continuing would let the
1674
- # model operate against a profile whose installed payload is unknown.
1675
- raise
1676
- except Exception:
1677
- # Ordinary registry/installer failures remain fail-open for this turn;
1678
- # the transaction receipt records the redacted reason.
1679
- pass
1716
+ # where an npm install can safely foreground an exact update. The channel
1717
+ # loader no-ops unless this directory is that install. The updater owns
1718
+ # its recursion guard, timeout, transaction, rollback, and receipt, and it
1719
+ # never contributes text to the model-facing context.
1720
+ _npm_channel_pre_llm(kwargs)
1680
1721
  user_message = str(kwargs.get("user_message") or "")
1681
1722
  requested_reader_mode = reader_facing_mode_from_user_request(user_message)
1682
1723
  reader_contract = reader_facing_contract_block(
@@ -61,7 +61,7 @@ def _decode_git_path(value: bytes) -> str:
61
61
  def _changed_paths(workspace: Path, base: str) -> list[str]:
62
62
  paths = {
63
63
  _decode_git_path(value)
64
- for value in _run_git_bytes(workspace, ["diff", "--name-only", "-z", base]).split(b"\0")
64
+ for value in _run_git_bytes(workspace, ["diff", "--name-only", "-z", "--", base]).split(b"\0")
65
65
  if value
66
66
  }
67
67
  records = _run_git_bytes(
@@ -255,7 +255,7 @@ def command_review_work(raw_args: str) -> dict[str, str]:
255
255
  if mode != "work":
256
256
  return _plan_or_ask_review(mode, mode_line, target, workspace, reader_args)
257
257
  changed_paths = _changed_paths(workspace, base)
258
- diff = _run_git(workspace, ["diff", base])
258
+ diff = _run_git(workspace, ["diff", "--", base])
259
259
  if len(diff) > 60000:
260
260
  diff = diff[:60000] + "\n... [diff truncated at 60k chars — lanes should read full files as needed]"
261
261
  run_cmd = detect_run_command(workspace)
@@ -260,10 +260,5 @@ def skin_logo(*, env=None, stream=None, json=False) -> str:
260
260
 
261
261
 
262
262
  def configure_welcome_skin() -> None:
263
- """Adjust the active managed skin in memory; the host draws it at welcome."""
264
- try:
265
- from hermes_cli.skin_engine import get_active_skin, get_active_skin_name
266
- except ImportError:
267
- return
268
- if get_active_skin_name().startswith("lithermes-"):
269
- get_active_skin().banner_logo = skin_logo(json="--json" in sys.argv)
263
+ """Leave the active skin as the host loaded it. The managed skin file carries its own logo."""
264
+ return None