@litfamily/lithermes 1.0.25 → 1.0.27

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.
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five 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.27/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five 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.25/readme-assets/badge-version.svg" alt="1.0.25" /></a>
5
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/readme-assets/badge-license.svg" alt="MIT license" /></a>
4
+ <a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/readme-assets/badge-version.svg" alt="1.0.27" /></a>
5
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/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.25/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a> &nbsp;
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/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.25/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.27/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a> &nbsp;
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/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.27/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
12
12
  </p>
13
13
 
14
14
  # LitHermes
15
15
 
16
16
  **Keep the work lit.**
17
17
 
18
- A plugin for **Hermes Agent**. Put `lit` in front of a request, and Hermes plans the task, does it, checks what it did and leaves a note that the next session can pick up.
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.25/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.27/README_Ko-KR.md)
21
21
 
22
22
  ## Install
23
23
 
@@ -27,7 +27,7 @@ You need Hermes Agent, Node.js 18 or later, and write access to your Hermes home
27
27
  npx --yes --package @litfamily/lithermes@latest -- lithermes install --yes --no-style
28
28
  ```
29
29
 
30
- It installs the plugin and updates your Hermes configuration without stopping to ask; `--no-style` skips the reply-style picker. To try it in a separate profile, set `HERMES_HOME` to an empty directory first. [More install options](https://github.com/wjgoarxiv/lithermes/blob/main/docs/install.md).
30
+ The command copies the plugin into your Hermes home and switches it on, without stopping to ask; `--no-style` skips the question about reply style. To try it in a separate profile first, point `HERMES_HOME` at an empty directory before you install. [More install options](https://github.com/wjgoarxiv/lithermes/blob/main/docs/install.md).
31
31
 
32
32
  ## Your first task
33
33
 
@@ -37,7 +37,7 @@ Restart the Hermes CLI or gateway, then type:
37
37
  lit Build a to-do list in one HTML file without external dependencies. Implement add, complete, and delete; record what you checked and what remains.
38
38
  ```
39
39
 
40
- Once the work has started, the reply opens with a `🔥 **LIT IGNITED · <discipline>** 🔥` line. When it finishes, check the file yourself, then run `/lit-handoff` so the next session in this project can continue. [Automatic handoff](https://github.com/wjgoarxiv/lithermes/blob/main/docs/automatic-handoff.md) is off by default; `/lit-handoff auto on 60` (a whole number from 1 to 99) asks for a handoff before you run `/compact`.
40
+ The reply opens with a `🔥 **LIT IGNITED · <discipline>** 🔥` line once the lit workflow has started. When the task is done, check the file yourself, then run `/lit-handoff` so the next session in this project can carry on from the note it writes. [Automatic handoff](https://github.com/wjgoarxiv/lithermes/blob/main/docs/automatic-handoff.md) is off by default; `/lit-handoff auto on 60` (a whole number from 1 to 99) asks for a handoff before you run `/compact`.
41
41
 
42
42
  ## What you get
43
43
 
package/README_Ko-KR.md CHANGED
@@ -1,23 +1,23 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/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.27/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/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.25/readme-assets/badge-version.svg" alt="1.0.25" /></a>
5
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/readme-assets/badge-license.svg" alt="MIT license" /></a>
4
+ <a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/readme-assets/badge-version.svg" alt="1.0.27" /></a>
5
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/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.25/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a> &nbsp;
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.25/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.25/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.27/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a> &nbsp;
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.27/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.27/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
12
12
  </p>
13
13
 
14
14
  # LitHermes
15
15
 
16
16
  **Keep the work lit.**
17
17
 
18
- **Hermes Agent**용 플러그인입니다. 요청 앞에 `lit`을 붙이면 Hermes가 작업을 계획하고, 해내고, 한 일을 확인한 뒤, 다음 세션이 이어받을 수 있는 기록을 남깁니다.
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.25/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.27/README.md)
21
21
 
22
22
  ## 설치
23
23
 
@@ -27,7 +27,7 @@ Hermes Agent와 Node.js 18 이상, 그리고 Hermes 홈(기본 `~/.hermes`)에
27
27
  npx --yes --package @litfamily/lithermes@latest -- lithermes install --yes --no-style
28
28
  ```
29
29
 
30
- 플러그인을 설치하고 Hermes 설정을 고치는 동안 승인을 묻느라 멈추지 않습니다. `--no-style`은 답변 문체 고르기를 건너뜁니다. 따로 떼어 둔 프로필에서 써 보려면 먼저 `HERMES_HOME`을 빈 디렉터리로 지정하세요. 다른 옵션은 [설치 문서](https://github.com/wjgoarxiv/lithermes/blob/main/docs/install.ko.md)에 있습니다.
30
+ 이 명령은 플러그인을 Hermes 홈에 복사하고 켜 두며, 중간에 승인을 묻지 않습니다. `--no-style`은 답변 문체를 묻는 단계를 건너뜁니다. 따로 떼어 둔 프로필에서 먼저 써 보려면 설치 전에 `HERMES_HOME`을 빈 디렉터리로 지정하세요. 다른 옵션은 [설치 문서](https://github.com/wjgoarxiv/lithermes/blob/main/docs/install.ko.md)에 있습니다.
31
31
 
32
32
  ## 첫 작업
33
33
 
@@ -37,7 +37,7 @@ Hermes CLI 또는 gateway를 다시 시작한 뒤 이렇게 입력하세요.
37
37
  lit 외부 의존성 없이 HTML 파일 하나로 할 일 목록을 만들어줘. 추가·완료·삭제를 구현하고 확인한 내용과 다음 행동을 남겨줘.
38
38
  ```
39
39
 
40
- 응답 첫 줄에 `🔥 **LIT IGNITED · <discipline>** 🔥`가 나오면 작업이 시작된 것입니다. 끝나면 파일을 직접 확인하고 `/lit-handoff`를 실행하세요. 같은 프로젝트의 다음 세션이 그 기록을 읽고 이어갑니다. [자동 핸드오프](https://github.com/wjgoarxiv/lithermes/blob/main/docs/automatic-handoff.ko.md)는 기본값이 꺼짐이고, `/lit-handoff auto on 60`(1에서 99 사이의 정수)을 켜 두면 `/compact` 전에 인계 기록을 먼저 요청합니다.
40
+ 응답 첫 줄에 `🔥 **LIT IGNITED · <discipline>** 🔥`가 나오면 lit 작업이 시작된 것입니다. 끝나면 파일을 직접 확인하고 `/lit-handoff`를 실행하세요. 같은 프로젝트의 다음 세션이 그 기록을 읽고 이어 갑니다. [자동 핸드오프](https://github.com/wjgoarxiv/lithermes/blob/main/docs/automatic-handoff.ko.md)는 기본값이 꺼짐이고, `/lit-handoff auto on 60`(1에서 99 사이의 정수)을 켜 두면 `/compact` 전에 인계 기록을 먼저 요청합니다.
41
41
 
42
42
  ## 할 수 있는 일
43
43
 
@@ -608,6 +608,7 @@ def _pre_tool_call(**kwargs: Any) -> dict[str, str] | None:
608
608
  args=kwargs.get("args"),
609
609
  session_id=kwargs.get("session_id"),
610
610
  workspace=kwargs.get("workspace"),
611
+ task_id=kwargs.get("task_id"),
611
612
  )
612
613
  if humanizer_denial is not None:
613
614
  return humanizer_denial
@@ -15,6 +15,7 @@ this instructs a rebuild; it cannot refuse the write.
15
15
  from __future__ import annotations
16
16
 
17
17
  import difflib
18
+ import errno
18
19
  import json
19
20
  import os
20
21
  import re
@@ -171,22 +172,50 @@ def _directory_flags() -> int | None:
171
172
  return os.O_RDONLY | directory | nofollow | getattr(os, "O_CLOEXEC", 0)
172
173
 
173
174
 
174
- def _relative_artifact_path(path_text: str, workspace: Path) -> Path | None:
175
+ def _project_root() -> Path | None:
176
+ """The Hermes launch directory (this process's cwd), widened to the git toplevel above it."""
177
+ try:
178
+ launch = Path(os.getcwd()).resolve(strict=True)
179
+ except (OSError, RuntimeError):
180
+ return None
181
+ return next((folder for folder in (launch, *launch.parents) if (folder / ".git").exists()), launch)
182
+
183
+
184
+ def _locate_artifact(path_text: str, workspace: Path) -> tuple[Path, Path] | None:
185
+ """(read root, path under it) for a reader copy, or None for internal and non-reader paths.
186
+
187
+ A path outside the working directory (absolute, or ``../`` after a terminal ``cd``) is
188
+ still a deliverable when it lies inside the launch project: it is judged by its path
189
+ under the project and read under its own parent. Anything outside both is skipped.
190
+ """
175
191
  candidate = Path(path_text).expanduser()
176
192
  target = candidate if candidate.is_absolute() else workspace / candidate
177
193
  absolute = Path(os.path.abspath(os.fspath(target)))
178
194
  try:
179
- relative = absolute.relative_to(workspace)
195
+ root, relative = workspace, absolute.relative_to(workspace)
196
+ judged = relative
180
197
  except ValueError:
181
- return None
198
+ project = _project_root()
199
+ root, relative = Path(os.path.realpath(absolute.parent)), Path(absolute.name)
200
+ try:
201
+ judged = (root / relative).relative_to(project) if project else None
202
+ except ValueError:
203
+ judged = None
204
+ if judged is None:
205
+ return None
182
206
  if (
183
207
  not relative.parts
184
208
  or any(part in {"", ".", ".."} for part in relative.parts)
185
209
  or relative.suffix.lower() not in READER_FACING_SUFFIXES
186
- or _INTERNAL_FILE.search(relative.as_posix())
210
+ or _INTERNAL_FILE.search(judged.as_posix())
187
211
  ):
188
212
  return None
189
- return relative
213
+ return root, relative
214
+
215
+
216
+ def _relative_artifact_path(path_text: str, workspace: Path) -> Path | None:
217
+ located = _locate_artifact(path_text, workspace)
218
+ return located[1] if located else None
190
219
 
191
220
 
192
221
  def _read_stable_file_result(
@@ -195,10 +224,11 @@ def _read_stable_file_result(
195
224
  byte_limit: int,
196
225
  max_file_bytes: int = MAX_FILE_BYTES,
197
226
  ) -> _StableFile | _ReadBudgetExceeded | None:
198
- relative = _relative_artifact_path(path_text, workspace)
227
+ located = _locate_artifact(path_text, workspace)
199
228
  directory_flags = _directory_flags()
200
- if relative is None or directory_flags is None or byte_limit < 0:
229
+ if located is None or directory_flags is None or byte_limit < 0:
201
230
  return None
231
+ workspace, relative = located
202
232
  read_limit = min(max_file_bytes, byte_limit)
203
233
  root_parts = tuple(part for part in workspace.parts if part != os.path.sep)
204
234
  directory_fds: list[int] = []
@@ -387,20 +417,31 @@ def _run_detector(text: str, path_text: str) -> list[dict] | None:
387
417
  or finding["line"] < 1
388
418
  or finding["line_end"] < finding["line"]
389
419
  or not isinstance(finding.get("match"), str)
420
+ or not isinstance(finding.get("lines", []), list)
421
+ or not all(isinstance(line, int) for line in finding.get("lines", []))
390
422
  ):
391
423
  return None
392
424
  return findings
393
425
 
394
426
 
427
+ def _touches_changed(finding: dict, changed: set[int]) -> bool:
428
+ """A document-scope finding lists its occurrence lines; any other finding counts its whole span."""
429
+ if "lines" in finding:
430
+ return any(line in changed for line in finding["lines"])
431
+ return any(finding["line"] <= line <= finding["line_end"] for line in changed)
432
+
433
+
395
434
  def _changed_findings(text: str, path_text: str, changed: set[int], session_id: str) -> list[dict] | None:
396
435
  findings = _run_detector(_mask_exact_user_quotes(text, session_id), path_text)
397
436
  if findings is None:
398
437
  return None
399
- return [
400
- finding
401
- for finding in findings
402
- if any(finding["line"] <= line <= finding["line_end"] for line in changed)
403
- ]
438
+ return [finding for finding in findings if _touches_changed(finding, changed)]
439
+
440
+
441
+ NEGATION_ADVICE = (
442
+ "negation-cluster: several negations sit in one place. Say what the thing is first, then add the caveat once. "
443
+ "Example: write \"18,420 visits in total, including repeat visits\" instead of \"18,420 is not the number of residents\"."
444
+ )
404
445
 
405
446
 
406
447
  def _block_message(findings: list[dict]) -> dict[str, str] | None:
@@ -409,9 +450,10 @@ def _block_message(findings: list[dict]) -> dict[str, str] | None:
409
450
  return None
410
451
  refs = ", ".join(f"{item['rule']}:{item['line']}" for item in blocks[:5])
411
452
  extra = f" (+{len(blocks) - 5})" if len(blocks) > 5 else ""
453
+ advice = f" {NEGATION_ADVICE}" if any(item["rule"] == "negation-cluster" for item in blocks) else ""
412
454
  return {
413
455
  "action": "block",
414
- "message": f"LitHumanizer blocked {len(blocks)} block-tier finding(s) in added prose ({refs}{extra}). Revise the added wording; warnings remain contextual review signals.",
456
+ "message": f"LitHumanizer blocked {len(blocks)} block-tier finding(s) in added prose ({refs}{extra}). Revise the added wording; warnings remain contextual review signals.{advice}",
415
457
  }
416
458
 
417
459
 
@@ -420,6 +462,8 @@ def _warn_if_needed(session_id: str, findings: list[dict]) -> None:
420
462
  if warnings:
421
463
  refs = ", ".join(f"{item['rule']}:{item['line']}" for item in warnings[:5])
422
464
  _queue_advisory(session_id, f"LitHumanizer warning-tier review signal(s) in added prose: {refs}. Consider them in context; they are not a ban or authorship judgment.")
465
+ if any(item["rule"] == "negation-cluster" for item in warnings):
466
+ _queue_advisory(session_id, NEGATION_ADVICE)
423
467
 
424
468
 
425
469
  def _fail_open(session_id: str) -> None:
@@ -435,8 +479,12 @@ def _read_previous(path_text: str, workspace: Path) -> str | None:
435
479
  return None
436
480
  target = Path(path_text).expanduser()
437
481
  target = target if target.is_absolute() else workspace / target
438
- if result is None and not target.exists() and not target.is_symlink():
439
- return ""
482
+ try:
483
+ if result is None and not target.exists() and not target.is_symlink():
484
+ return ""
485
+ except OSError as error:
486
+ # No file can sit at a path the system refuses to name, so the whole edit is new text.
487
+ return "" if error.errno == errno.ENAMETOOLONG else None
440
488
  return None
441
489
 
442
490
 
@@ -450,7 +498,11 @@ def _check_text_change(session_id: str, path_text: str, proposed: str, workspace
450
498
  if previous is None:
451
499
  _fail_open(session_id)
452
500
  return None
453
- if len(previous.encode("utf-8")) > MAX_DETECT_BYTES:
501
+ return _check_proposed(session_id, path_text, previous, proposed)
502
+
503
+
504
+ def _check_proposed(session_id: str, path_text: str, previous: str, proposed: str) -> dict[str, str] | None:
505
+ if len(proposed.encode("utf-8")) > MAX_DETECT_BYTES or len(previous.encode("utf-8")) > MAX_DETECT_BYTES:
454
506
  _fail_open(session_id)
455
507
  return None
456
508
  changed = _changed_lines(previous, proposed)
@@ -464,33 +516,106 @@ def _check_text_change(session_id: str, path_text: str, proposed: str, workspace
464
516
  return _block_message(findings)
465
517
 
466
518
 
467
- def _patch_added_chunks(patch_text: str) -> list[tuple[str, str]]:
468
- chunks: list[tuple[str, str]] = []
469
- path = ""
470
- kind = ""
471
- added: list[str] = []
472
-
473
- def flush() -> None:
474
- nonlocal added
475
- if path and kind in {"Add", "Update"} and added:
476
- chunks.append((path, "\n".join(added)))
477
- added = []
478
-
479
- for line in patch_text.splitlines():
519
+ def _patch_operations(patch_text: str) -> list[tuple[str, str, list[tuple[str, list[tuple[str, str]]]]]]:
520
+ """V4A Add/Update operations as (kind, path, hunks); a hunk is (context hint, [(prefix, line)])."""
521
+ operations: list[tuple[str, str, list[tuple[str, list[tuple[str, str]]]]]] = []
522
+ hunks: list[tuple[str, list[tuple[str, str]]]] | None = None
523
+ for raw in patch_text.split("\n"):
524
+ line = raw[:-1] if raw.endswith("\r") else raw
480
525
  header = _V4A_FILE.match(line)
481
526
  if header:
482
- flush()
483
- kind, path = header.group(1), header.group(2).strip()
484
- elif _V4A_MOVE.match(line):
485
- flush()
486
- elif line.startswith("+++") or line.startswith("---"):
487
- flush()
488
- elif line.startswith("+"):
489
- added.append(line[1:])
527
+ hunks = [] if header.group(1) in {"Add", "Update"} else None
528
+ if hunks is not None:
529
+ operations.append((header.group(1), header.group(2).strip(), hunks))
530
+ elif _V4A_MOVE.match(line) or line.startswith("*** "):
531
+ continue
532
+ elif hunks is None or not line or line.startswith(("+++", "---", "\\")):
533
+ continue
534
+ elif line.startswith("@@"):
535
+ hint = re.match(r"@@\s*(.+?)\s*@@", line)
536
+ hunks.append((hint.group(1) if hint else "", []))
490
537
  else:
491
- flush()
492
- flush()
493
- return chunks
538
+ if not hunks:
539
+ hunks.append(("", []))
540
+ hunks[-1][1].append((line[0], line[1:]) if line[0] in "+- " else (" ", line))
541
+ return [(kind, path, [hunk for hunk in found if hunk[1]]) for kind, path, found in operations]
542
+
543
+
544
+ def _apply_hunks(previous: str, hunks: list[tuple[str, list[tuple[str, str]]]]) -> str | None:
545
+ """Apply V4A hunks the way Hermes places them; None when a hunk does not match exactly once."""
546
+ text = previous
547
+ for hint, lines in hunks:
548
+ search = [content for prefix, content in lines if prefix != "+"]
549
+ replace = "\n".join(content for prefix, content in lines if prefix != "-")
550
+ if not search:
551
+ if hint and text.count(hint) > 1:
552
+ return None
553
+ if hint and hint in text:
554
+ eol = text.find("\n", text.find(hint))
555
+ text = text + "\n" + replace if eol == -1 else text[: eol + 1] + replace + "\n" + text[eol + 1 :]
556
+ else:
557
+ text = text.rstrip("\n") + "\n" + replace + "\n"
558
+ continue
559
+ pattern = "\n".join(search)
560
+ if not pattern.strip() or text.count(pattern) != 1:
561
+ return None
562
+ text = text.replace(pattern, replace, 1)
563
+ return text
564
+
565
+
566
+ def _hunk_text(hunks: list[tuple[str, list[tuple[str, str]]]]) -> tuple[str, set[int]]:
567
+ """Context and added lines with a blank line at every hunk break; the added lines count as changed."""
568
+ lines: list[str] = []
569
+ changed: set[int] = set()
570
+ for _hint, hunk in hunks:
571
+ if lines:
572
+ lines.append("")
573
+ for prefix, content in hunk:
574
+ if prefix == "-":
575
+ continue
576
+ lines.append(content)
577
+ if prefix == "+":
578
+ changed.add(len(lines))
579
+ return "\n".join(lines) + "\n", changed
580
+
581
+
582
+ def _check_patch_operation(
583
+ session_id: str, kind: str, path_text: str, hunks: list[tuple[str, list[tuple[str, str]]]], workspace: Path
584
+ ) -> dict[str, str] | None:
585
+ previous = _read_previous(path_text, workspace)
586
+ if previous is not None:
587
+ proposed = "\n".join(c for _h, hunk in hunks for p, c in hunk if p == "+") + "\n" if kind == "Add" else _apply_hunks(previous, hunks)
588
+ if proposed is not None:
589
+ return _check_proposed(session_id, path_text, previous, proposed)
590
+ text, changed = _hunk_text(hunks)
591
+ if not changed:
592
+ return None
593
+ if len(text.encode("utf-8")) > MAX_DETECT_BYTES:
594
+ _fail_open(session_id)
595
+ return None
596
+ findings = _changed_findings(text, path_text, changed, session_id)
597
+ if findings is None:
598
+ _fail_open(session_id)
599
+ return None
600
+ _warn_if_needed(session_id, findings)
601
+ return _block_message(findings)
602
+
603
+
604
+ def _file_tool_base(task_id: object) -> str:
605
+ """The directory Hermes resolves a relative file-tool path against: the session cwd a terminal ``cd`` recorded."""
606
+ terminal = sys.modules.get("tools.terminal_tool")
607
+ session_cwd = getattr(terminal, "get_session_cwd", None)
608
+ if callable(session_cwd):
609
+ try:
610
+ recorded = session_cwd(str(task_id or "") or None)
611
+ except Exception: # noqa: BLE001 - a host helper failure falls back to the next anchor
612
+ recorded = None
613
+ if isinstance(recorded, str) and os.path.isabs(recorded) and os.path.isdir(recorded):
614
+ return recorded
615
+ configured = os.environ.get("TERMINAL_CWD", "")
616
+ if os.path.isabs(configured) and os.path.isdir(configured):
617
+ return configured
618
+ return os.getcwd()
494
619
 
495
620
 
496
621
  def pre_tool_call(
@@ -499,6 +624,7 @@ def pre_tool_call(
499
624
  session_id: object,
500
625
  *,
501
626
  workspace: object = None,
627
+ task_id: object = None,
502
628
  ) -> dict[str, str] | None:
503
629
  """Block block-tier findings before Hermes persists a supported text edit."""
504
630
  name = str(tool_name or "")
@@ -507,7 +633,7 @@ def pre_tool_call(
507
633
  if not key or name not in {"write_file", "patch"}:
508
634
  return None
509
635
  try:
510
- root = Path(workspace or Path.cwd()).expanduser().resolve(strict=True)
636
+ root = Path(workspace or _file_tool_base(task_id)).expanduser().resolve(strict=True)
511
637
  except (OSError, RuntimeError):
512
638
  _fail_open(key)
513
639
  return None
@@ -525,21 +651,18 @@ def pre_tool_call(
525
651
  if not isinstance(old_string, str) or not isinstance(new_string, str):
526
652
  return None
527
653
  previous = _read_previous(path_text, root)
654
+ if previous == "" and old_string:
655
+ return _check_text_change(key, path_text, new_string, root)
528
656
  if previous is None or old_string not in previous:
529
657
  _fail_open(key)
530
658
  return None
531
659
  proposed = previous.replace(old_string, new_string) if payload.get("replace_all") else previous.replace(old_string, new_string, 1)
532
660
  return _check_text_change(key, path_text, proposed, root)
533
661
  if mode == "patch" and isinstance(payload.get("patch"), str):
534
- for path_text, added_text in _patch_added_chunks(payload["patch"]):
662
+ for kind, path_text, hunks in _patch_operations(payload["patch"]):
535
663
  if not _path_is_reader_copy(path_text, root):
536
664
  continue
537
- findings = _changed_findings(added_text, path_text, set(range(1, len(added_text.splitlines()) + 1)), key)
538
- if findings is None:
539
- _fail_open(key)
540
- continue
541
- _warn_if_needed(key, findings)
542
- denial = _block_message(findings)
665
+ denial = _check_patch_operation(key, kind, path_text, hunks, root)
543
666
  if denial:
544
667
  return denial
545
668
  return None
@@ -185,22 +185,6 @@ def _host_config_path() -> Path:
185
185
  return home / "config.yaml"
186
186
 
187
187
 
188
- def _retired_hint_data_line() -> str | None:
189
- # The Jev skill hint is gone. Files it wrote under Hermes home are left for the
190
- # user to delete; doctor only says where they are.
191
- state = _host_config_path().parent / "lithermes"
192
- try:
193
- found = any(
194
- entry.name == "jev-trace.jsonl" or entry.name.startswith("jev-last-")
195
- for entry in state.iterdir()
196
- )
197
- except OSError:
198
- return None
199
- if not found:
200
- return None
201
- return f"old Jev data can be deleted at {state}/jev-*"
202
-
203
-
204
188
  def _is_secret_looking_scalar(value: object) -> bool:
205
189
  if not isinstance(value, str):
206
190
  return False
@@ -583,9 +567,6 @@ def doctor_report(host_version: str | None = None) -> tuple[list[str], int]:
583
567
  )
584
568
  healthy = healthy and knowledge_ok
585
569
  lines.append(f"[NOTE] {_provider_metrics.capability_line()}")
586
- retired_hint = _retired_hint_data_line()
587
- if retired_hint:
588
- lines.append(f"[NOTE] {retired_hint}")
589
570
  handoff_tag, handoff_text = _auto_handoff.doctor_line()
590
571
  lines.append(f"[{handoff_tag}] {handoff_text}")
591
572
  if version != "unknown":
@@ -282,8 +282,98 @@ def _matches_language(rule: dict, text: str) -> bool:
282
282
  return True
283
283
 
284
284
 
285
+ NEGATION_CLUSTER_ID = "negation-cluster"
286
+ NEGATION_BLOCK_KO = 2
287
+ NEGATION_BLOCK_TOTAL = 3
288
+ NEGATION_WARN_TOTAL = 2
289
+ _NEGATION_KO = re.compile(r"아닙니다|아님|아니다|아니며|아니라|아닌")
290
+ _NEGATION_KO_REWORD = re.compile(
291
+ r"(?:로|으로) (?:읽을|볼|해석할|간주할) 수 없|(?:로|으로) 보지 않|(?:을|를) (?:뜻하지|의미하지|나타내지|가리키지) 않"
292
+ r"|(?:와|과)는 다(?:릅니다|르다|름|르며|르고|르므로)|(?:로|으로) (?:읽으면|보면|해석하면) 안 "
293
+ )
294
+ _NEGATION_EN = re.compile(r"\b(?:is not|are not|isn't|aren't|not a|rather than)\b", re.IGNORECASE)
295
+ _NEGATION_EN_REWORD = re.compile(
296
+ r"\b(?:should not|cannot|can't|must not) be (?:read|taken|interpreted|treated) as\b|\b(?:does|do) not (?:mean|represent|indicate)\b",
297
+ re.IGNORECASE,
298
+ )
299
+ _TABLE_RULE_ROW = re.compile(r"[\s|:-]+")
300
+ _SHORT_TABLE_CELL = 12
301
+ STDIN_WARNING_CAP = 256
302
+ STDIN_BLOCK_CAP = 1024
303
+ STDIN_OUTPUT_BYTES = 1024 * 1024
304
+
305
+
306
+ def _negations(text: str) -> list[tuple[re.Match, bool]]:
307
+ """Definitional negations in ``text`` as (match, is_korean), plain and reworded forms alike."""
308
+ found = [(match, True) for pattern in (_NEGATION_KO, _NEGATION_KO_REWORD) for match in pattern.finditer(text)]
309
+ found += [(match, False) for pattern in (_NEGATION_EN, _NEGATION_EN_REWORD) for match in pattern.finditer(text)]
310
+ return sorted(found, key=lambda item: item[0].start())
311
+
312
+
313
+ def _negation_finding(file: str, units: list[tuple[str, int]], excerpt: str) -> dict | None:
314
+ """Block on two Korean or three negations in total, warn on exactly two."""
315
+ hits = [(match, korean, line) for text, line in units for match, korean in _negations(text)]
316
+ korean = sum(1 for _match, is_korean, _line in hits if is_korean)
317
+ if korean >= NEGATION_BLOCK_KO or len(hits) >= NEGATION_BLOCK_TOTAL:
318
+ severity = "block"
319
+ elif len(hits) >= NEGATION_WARN_TOTAL:
320
+ severity = "warn"
321
+ else:
322
+ return None
323
+ return {
324
+ "file": str(file),
325
+ "rule": NEGATION_CLUSTER_ID,
326
+ "severity": severity,
327
+ "line": min(line for _match, _korean, line in hits),
328
+ "line_end": max(line for _match, _korean, line in hits),
329
+ "match": " … ".join(match.group(0) for match, _korean, _line in hits)[:240],
330
+ "excerpt": re.sub(r"\s+", " ", excerpt.strip())[:180],
331
+ }
332
+
333
+
334
+ def negation_cluster_findings(rows: list[dict], file: str = "<stdin>") -> list[dict]:
335
+ """Count definitional negations per paragraph, list block or table column.
336
+
337
+ Fenced code and blockquote rows are already skipped and inline code is
338
+ blanked by ``_source_rows``; a skipped or blank row ends a block. The line
339
+ engine has no block scope, so this check sits beside it.
340
+ """
341
+ blocks: list[list[dict]] = []
342
+ current: list[dict] = []
343
+ for row in rows:
344
+ if row["skipped"] or not row["candidate"].strip():
345
+ if current:
346
+ blocks.append(current)
347
+ current = []
348
+ else:
349
+ current.append(row)
350
+ if current:
351
+ blocks.append(current)
352
+ findings = []
353
+ for block in blocks:
354
+ excerpt = " ".join(row["candidate"] for row in block)
355
+ if all(row["candidate"].lstrip().startswith("|") for row in block):
356
+ columns: dict[int, list[tuple[str, int]]] = {}
357
+ for row in block:
358
+ if _TABLE_RULE_ROW.fullmatch(row["candidate"]):
359
+ continue
360
+ cells = [cell.strip() for cell in row["candidate"].strip().strip("|").split("|")]
361
+ for index, cell in enumerate(cells):
362
+ if len(cell) > _SHORT_TABLE_CELL:
363
+ columns.setdefault(index, []).append((cell, row["number"]))
364
+ candidates = [_negation_finding(file, cells, excerpt) for cells in columns.values()]
365
+ else:
366
+ candidates = [_negation_finding(file, [(row["candidate"], row["number"]) for row in block], excerpt)]
367
+ findings.extend(item for item in candidates if item is not None)
368
+ return findings
369
+
370
+
285
371
  def scan_text(text: str, rules: Iterable[dict] | None = None, file: str = "<stdin>") -> list[dict]:
286
- """Return at most one finding per rule segment with canonical line mapping."""
372
+ """Return at most one finding per rule segment with canonical line mapping.
373
+
374
+ With the bundled rule set (``rules`` omitted) the block-scoped
375
+ ``negation-cluster`` check is added; an explicit rule list scans only those rules.
376
+ """
287
377
  if _is_internal_path(file):
288
378
  return []
289
379
  selected = list(rules) if rules is not None else load_rules()
@@ -303,15 +393,24 @@ def scan_text(text: str, rules: Iterable[dict] | None = None, file: str = "<stdi
303
393
  if match is None:
304
394
  continue
305
395
  excerpt = re.sub(r"\s+", " ", segment["text"].strip())[:180]
306
- findings.append({
396
+ line = _line_at_offset(segment, match.start())
397
+ line_end = _line_at_offset(segment, max(match.start(), match.end() - 1))
398
+ finding = {
307
399
  "file": str(file),
308
400
  "rule": rule["id"],
309
401
  "severity": rule["severity"],
310
- "line": _line_at_offset(segment, match.start()),
311
- "line_end": _line_at_offset(segment, max(match.start(), match.end() - 1)),
402
+ "line": line,
403
+ "line_end": line_end,
312
404
  "match": match.group(0)[:240],
313
405
  "excerpt": excerpt,
314
- })
406
+ }
407
+ if rule["scope"] == "document":
408
+ # A document-scope match runs from its first occurrence to its last; the
409
+ # lines between belong to neither, so only the two ends are occurrence lines.
410
+ finding["lines"] = sorted({line, line_end})
411
+ findings.append(finding)
412
+ if rules is None:
413
+ findings.extend(negation_cluster_findings(rows, file))
315
414
  return findings
316
415
 
317
416
 
@@ -354,6 +453,19 @@ def main(argv: list[str] | None = None) -> int:
354
453
  except (OSError, UnicodeError, ValueError, json.JSONDecodeError, RuntimeError) as error:
355
454
  print(f"LIT_HUMANIZER_SCAN_ERROR: {error}", file=sys.stderr)
356
455
  return 3
456
+ if args.stdin_json:
457
+ # The pre-write guard rejects oversized output; warnings must never crowd out a block,
458
+ # and a flood of blocks is trimmed so the guard still sees one instead of failing open.
459
+ # Each finding repeats the path, so the output is also capped by bytes, blocks first.
460
+ warnings = [finding for finding in findings if finding["severity"] != "block"]
461
+ blocks = [finding for finding in findings if finding["severity"] == "block"]
462
+ kept, size = [], 2
463
+ for finding in blocks[:STDIN_BLOCK_CAP] + warnings[:STDIN_WARNING_CAP]:
464
+ size += len(json.dumps(finding, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + 1
465
+ if size > STDIN_OUTPUT_BYTES and kept:
466
+ break
467
+ kept.append(finding)
468
+ findings = kept
357
469
  if args.stdin_json or args.json:
358
470
  print(json.dumps(findings, ensure_ascii=False, separators=(",", ":")))
359
471
  else:
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-10-18T09:42:50.174Z",
2
+ "syncedAt": "2026-10-19T12:29:49.086Z",
3
3
  "source": "bundled-payload",
4
- "sourceHash": "91ef78ac52df745352ac069cd0872ee6a17253c4fa55b5577e52482b9b329971",
4
+ "sourceHash": "2bb93dc4c7d46d48dcc893b33faebb7065d343a2f636fadb1d25f1695c1ef23d",
5
5
  "files": [
6
6
  {
7
7
  "path": "NOTICE.md",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "path": "__init__.py",
16
- "sha256": "057f8d630323ac427a48749c05cbd23076810fb66fa78821c6ab16076574127c"
16
+ "sha256": "3ccd142c77ef0dc3039d0c61f8e6ee7a8e39ea02c3889ba7908b044467ac7286"
17
17
  },
18
18
  {
19
19
  "path": "auto_handoff.py",
@@ -85,11 +85,11 @@
85
85
  },
86
86
  {
87
87
  "path": "deliverable_hedge_guard.py",
88
- "sha256": "084eb4381d8be3055eada034bf0c3c31339b9c766cbf3b19fd85042e2e254039"
88
+ "sha256": "f65110568952f197e93452e8a9d01c21cd75479b987110d36e9b05a78f34a302"
89
89
  },
90
90
  {
91
91
  "path": "diagnostics.py",
92
- "sha256": "1fd173995dd1abc1d58cc719ecd351f91bd1c65c9056fdffa064fda684f18318"
92
+ "sha256": "13fdef893d10cecdffdbb61daf64629e5ccbf4777964e75930ca34017472828e"
93
93
  },
94
94
  {
95
95
  "path": "handoff.py",
@@ -97,7 +97,7 @@
97
97
  },
98
98
  {
99
99
  "path": "humanizer_detector.py",
100
- "sha256": "48e4f3a02326521769ac193b858eecbb90773cb765aa8d5794ffaa3c1dee2d62"
100
+ "sha256": "675bd49ffb0c581c6f47a52945ddc9ef205071a722fd3f708c76252c1fa11804"
101
101
  },
102
102
  {
103
103
  "path": "humanizer_ko_metrics.py",
@@ -177,7 +177,7 @@
177
177
  },
178
178
  {
179
179
  "path": "plugin.yaml",
180
- "sha256": "d9eae351c038cfaff91980666d4d3ebe8f2ad82f31bf8ef9539fb4c4ad088e84"
180
+ "sha256": "7af0cc7db4c684a51fa874d1aff88dac3bba80c18346820b30b3afc78631b5a8"
181
181
  },
182
182
  {
183
183
  "path": "provider_metrics.py",
@@ -201,7 +201,7 @@
201
201
  },
202
202
  {
203
203
  "path": "rules/bundled-rules/lit-humanizer.md",
204
- "sha256": "aad3a931da64fa2dd67eb80503a976d7f61a3f885e5aca4178f562789e19266e"
204
+ "sha256": "f926c9f456daf7cd5dc081d0e9f2e15d3a7aa73cb37c75d292076a00810b6d34"
205
205
  },
206
206
  {
207
207
  "path": "rules/bundled-rules/windows-git-bash.md",
@@ -1281,7 +1281,7 @@
1281
1281
  },
1282
1282
  {
1283
1283
  "path": "skills/frontend-ui-ux/references/complete-contract.md",
1284
- "sha256": "df99f8f532c06232220537041128d8a5480bb60aedbbe3af8909444aaa8944ac"
1284
+ "sha256": "f3d9ba63058388aba9ecd7684b15ae7d544ac488d7be1f78ce06886f18fbc623"
1285
1285
  },
1286
1286
  {
1287
1287
  "path": "skills/frontend-ui-ux/references/composition.md",
@@ -3273,7 +3273,7 @@
3273
3273
  },
3274
3274
  {
3275
3275
  "path": "skills/lit-humanizer/SKILL.md",
3276
- "sha256": "47c562a344c89d16be5271f86874af556991dd5c9a26f45e0800eda5a08f5381"
3276
+ "sha256": "1fb63c16cb88adee827b2aa9bdce4edb0fcd88adf9c331d29597556f996d4fb9"
3277
3277
  },
3278
3278
  {
3279
3279
  "path": "skills/lit-humanizer/assets/report-skeleton.md",
@@ -4401,7 +4401,7 @@
4401
4401
  },
4402
4402
  {
4403
4403
  "path": "skills/visual-qa/references/complete-contract.md",
4404
- "sha256": "16ccc6a685865e00a226fbdbbdf1d4e170ca9c37731a94974ea135f281032e51"
4404
+ "sha256": "0ed07370f299c81cfdd7fc6c26c8d3e4af784286df25234a425ee8ec3cd7a3b3"
4405
4405
  },
4406
4406
  {
4407
4407
  "path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
@@ -1,5 +1,5 @@
1
1
  name: lithermes
2
- version: 1.0.25
2
+ version: 1.0.27
3
3
  description: "Hermes-native workflow toolkit: litgoal durable runtime, 5-lane review orchestrator, Litwork commands, skills, and prompt steering."
4
4
  author: "LitHermes contributors"
5
5
  kind: standalone
@@ -9,6 +9,8 @@ Apply to new chat prose and file content. Read only text you add or change. Pres
9
9
 
10
10
  Block only clear drafting residue such as label-only evidence/source lines, stacked generic caveats, model disclaimers, or chatbot sign-offs. Treat vocabulary, contrast, triads, and rhythm as warnings to inspect; keep accurate, natural language. Preserve facts, numbers, names, citations, modality, scope, register, and useful structure. Never infer authorship or optimize a detector score.
11
11
 
12
+ Say what a number or thing is first, then add a needed caveat once: write "18,420회는 같은 사람의 반복 방문을 포함한 방문 횟수 합계입니다" in place of `18,420회는 주민 수가 아님`. The `write_file`/`patch` guard denies a paragraph, list or table column holding two Korean definitional negations or three in any language; rewordings such as `~로 읽을 수 없음` and `does not mean` count too. Two that are not both Korean draw a `negation-cluster` warning.
13
+
12
14
  Use citations in the requested format. State a material risk once, plainly, in the chat reply; keep a reader deliverable focused on its purpose. Keep internal plans, evidence, ledgers, status, and machine-readable records detailed.
13
15
 
14
16
  The `write_file`/`patch` pre-tool guard can deny block-tier text before save. Office and PDF extraction runs after a completed write, so a block means fix the source and rebuild. If asked to revise prose, return the requested content without an audit preamble; do not change files or fetch sources unless the user asked.
@@ -445,7 +445,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
445
445
 
446
446
  ```text
447
447
  $ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
448
- Installed LitHermes 1.0.25
448
+ Installed LitHermes 1.0.27
449
449
  plugin: $ISOLATED/plugins/lithermes
450
450
  model config: updated
451
451
 
@@ -462,7 +462,7 @@ installed payload: PASS
462
462
  enabled config: PASS
463
463
 
464
464
  $ hermes lithermes doctor
465
- [OK] plugin.yaml readable (version 1.0.25)
465
+ [OK] plugin.yaml readable (version 1.0.27)
466
466
  [OK] skills bundled: 36
467
467
  [OK] litgoal durable runtime importable
468
468
  ```
@@ -60,6 +60,8 @@ after_payload_edit: "npm --prefix packages/lithermes-installer run sync-plugin -
60
60
 
61
61
  For a file edit, the Hermes `pre_tool_call` guard blocks block-tier findings in added text passed to `write_file` or `patch` for Markdown, HTML, text, TeX, CSV, SVG, reStructuredText, and AsciiDoc files. Warn-tier findings arrive as advisory context. Fenced code, inline code, exact quoted user spans, and internal `plans/`, `evidence/`, `HANDOFF*`, ledger, `.lit*/`, and `.hermes/` paths are excluded. The guard never scans unchanged file text. Use `write_file` or `patch` for prose edits when pre-save checking is required.
62
62
 
63
+ Lead with what a number or thing is. A run of definitions by negation leaves the reader to assemble the meaning from what was ruled out. Write "18,420회는 같은 사람의 반복 방문을 포함한 방문 횟수 합계입니다" in place of `18,420회는 주민 수가 아님. 고유 방문자 수도 아님.`, and give a needed caveat once; an honest limit such as "고유 이용자 지표는 자료에 없습니다" stays. The detector's `negation-cluster` check counts, in each paragraph, list block or Markdown table column (cells of 12 characters or fewer are ignored), after removing fenced code, inline code and `>` quote lines: the Korean forms `아닙니다`, `아님`, `아니다`, `아니며`, `아니라`, `아닌` and their rewordings `~로 읽을 수 없음`, `~로 볼 수 없음`, `~로 보지 않음`, `~을 뜻하지 않음`, `~와는 다름`, `~로 읽으면 안 됨`; the English forms `is not`, `are not`, `isn't`, `aren't`, `not a`, `rather than` and the rewordings `should not be read as`, `does not mean`. Two Korean hits, or three in any language, make it block-tier: the `pre_tool_call` guard denies the `write_file` or `patch`, and the denial text Hermes returns as the tool result carries the rewrite advice and this example. Exactly two hits that are not both Korean stay a warn-tier advisory and the write goes through. Rewording `아님` into `~로 읽을 수 없음` counts the same. Put a bad example you need to show in inline code or a `>` quote so the guard skips it.
64
+
63
65
  DOCX/PPTX/PDF checks happen after a supported file-write event or when a script-like tool reports an explicit output path in its result. Hermes' `post_tool_call` is observer-only, so a finding cannot undo a completed export: revise the source and rebuild the file. DOCX/PPTX extraction uses Python's standard-library ZIP/XML support. PDF extraction uses host `pdftotext` when present; if it is unavailable or fails, the scan fails open and reports that boundary. A DOCX, PPTX or Markdown file that a `terminal` or `execute_code` call builds is checked when that call returns: Hermes' `transform_tool_result` hook finds files modified while the call ran (the reported or session working directory, `workdir`, and folders of paths named in the command, code or output; bounded depth, file count and size, internal paths skipped) and, on a block-tier finding, puts `LIT_HUMANIZER_SCRIPT_DELIVERABLE_BLOCK` with the rule ids in that tool result's `error` field. The command's exit code stays as it ran, and the file stays on disk: edit the build source and rebuild before presenting it. A file written outside those folders, deeper than the scan, or by a background process after the call returned is not seen; run the detector on it explicitly.
64
66
 
65
67
  ## #contract.language_and_scope
@@ -485,7 +485,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
485
485
 
486
486
  ```text
487
487
  $ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
488
- Installed LitHermes 1.0.25
488
+ Installed LitHermes 1.0.27
489
489
  plugin: $ISOLATED/plugins/lithermes
490
490
  model config: updated
491
491
 
@@ -502,7 +502,7 @@ installed payload: PASS
502
502
  enabled config: PASS
503
503
 
504
504
  $ hermes lithermes doctor
505
- [OK] plugin.yaml readable (version 1.0.25)
505
+ [OK] plugin.yaml readable (version 1.0.27)
506
506
  [OK] skills bundled: 36
507
507
  [OK] litgoal durable runtime importable
508
508
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/lithermes",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Hermes Agent plugin that plans, builds, checks and hands off your work when you add one word, lit, to a request.",
5
5
  "keywords": [
6
6
  "hermes-agent",
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.25"><title>release: 1.0.25</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.25</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.27"><title>release: 1.0.27</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.27</text></g></svg>
package/src/lib/files.js CHANGED
@@ -106,6 +106,29 @@ function writeFileAtomic(filePath, data, encoding) {
106
106
  }
107
107
  }
108
108
 
109
+ // The payload-relative source a host-written bytecode path belongs to, or null.
110
+ // Only CPython's own layout counts: <dir>/__pycache__/<module>.<tag>[.opt-N].pyc.
111
+ function hostBytecodeSource(relative) {
112
+ const segments = relative.split("/");
113
+ if (segments.length < 2 || segments[segments.length - 2] !== "__pycache__") return null;
114
+ const match = /^([^.]+)\.[a-zA-Z][a-zA-Z0-9_-]*(?:\.opt-[0-9]+)?\.pyc$/.exec(segments[segments.length - 1]);
115
+ return match ? [...segments.slice(0, -2), `${match[1]}.py`].join("/") : null;
116
+ }
117
+
118
+ // Removes the host bytecode for one removed source module, then its cache
119
+ // folder once empty. Anything else in that folder stays where it is.
120
+ function removeHostBytecode(sourceFile) {
121
+ const cacheDir = path.join(path.dirname(sourceFile), "__pycache__");
122
+ if (!fs.lstatSync(cacheDir, { throwIfNoEntry: false })?.isDirectory()) return;
123
+ const stem = path.basename(sourceFile, ".py");
124
+ for (const entry of fs.readdirSync(cacheDir, { withFileTypes: true })) {
125
+ if (entry.isFile() && hostBytecodeSource(`__pycache__/${entry.name}`) === `${stem}.py`) {
126
+ fs.unlinkSync(path.join(cacheDir, entry.name));
127
+ }
128
+ }
129
+ if (fs.readdirSync(cacheDir).length === 0) fs.rmdirSync(cacheDir);
130
+ }
131
+
109
132
  function removeEmptyDirs(dir, stopAt) {
110
133
  let current = dir;
111
134
  while (current && current !== stopAt && current.startsWith(stopAt) && fs.existsSync(current)) {
@@ -118,9 +141,11 @@ function removeEmptyDirs(dir, stopAt) {
118
141
  module.exports = {
119
142
  cleanPythonCache,
120
143
  copyTree,
144
+ hostBytecodeSource,
121
145
  inspectPythonCache,
122
146
  listFiles,
123
147
  removeEmptyDirs,
148
+ removeHostBytecode,
124
149
  sha256,
125
150
  writeFileAtomic,
126
151
  };
@@ -15,7 +15,7 @@ const {
15
15
  revalidateModelConfigPlan,
16
16
  writeConfig,
17
17
  } = require("./config");
18
- const { copyTree, removeEmptyDirs, sha256, writeFileAtomic } = require("./files");
18
+ const { copyTree, removeEmptyDirs, removeHostBytecode, sha256, writeFileAtomic } = require("./files");
19
19
  const { assertStateParent, assertPluginParent, defaultHermesHome, defaultHermesRepo, detectHermesRuntimeRepo, detectHermesVersion, ensureHermesHome, inspectHermesHostCapabilities, LitHermesError } = require("./hermesDiscovery");
20
20
  const { NOT_PROBED_HOST_VERSION } = require("./modelConfigBoundary");
21
21
  const { patchInstalledHermes, patchManifestPath, rollbackPatches } = require("./patch");
@@ -802,6 +802,7 @@ function uninstallLitHermes(flags = {}) {
802
802
  const file = path.join(dest, entry.path);
803
803
  if (isInstalledRegularFile(dest, entry.path) && sha256(file) === entry.sha256) {
804
804
  fs.unlinkSync(file);
805
+ if (entry.path.endsWith(".py")) removeHostBytecode(file);
805
806
  removeEmptyDirs(path.dirname(file), dest);
806
807
  }
807
808
  }
@@ -1,7 +1,7 @@
1
1
  const crypto = require("node:crypto");
2
2
  const fs = require("node:fs");
3
3
  const path = require("node:path");
4
- const { sha256 } = require("./files");
4
+ const { hostBytecodeSource, sha256 } = require("./files");
5
5
  const {
6
6
  captureRegularFile,
7
7
  parseJsonRejectingDuplicateKeys,
@@ -229,12 +229,31 @@ function inspectSkillPayload(pluginPath, manifest, verifySourceHash = false, opt
229
229
  allowedDirectories.add(`${segments.slice(0, index).join("/")}/`);
230
230
  }
231
231
  }
232
+ // Hermes imports the plugin with bytecode writing on, so CPython caches for
233
+ // shipped modules are expected host output; any other cache content is not.
234
+ const hostCacheDirectories = new Set();
235
+ for (const entryPath of inspected.entries.keys()) {
236
+ if (!entryPath.endsWith(".py")) continue;
237
+ const directory = path.posix.dirname(entryPath);
238
+ hostCacheDirectories.add(directory === "." ? "__pycache__/" : `${directory}/__pycache__/`);
239
+ }
240
+ const isHostBytecode = (entryPath) => {
241
+ const source = hostBytecodeSource(entryPath);
242
+ if (!source || !inspected.entries.has(source)) return false;
243
+ try {
244
+ return fs.lstatSync(path.join(pluginPath, ...entryPath.split("/"))).isFile();
245
+ } catch {
246
+ return false;
247
+ }
248
+ };
232
249
  const traversal = listPayloadEntries(pluginPath, options.readDirectory);
233
250
  for (const entryPath of traversal.entries) {
234
251
  if (
235
252
  inspected.entries.has(entryPath)
236
253
  || allowedUnlisted.has(entryPath)
237
254
  || allowedDirectories.has(entryPath)
255
+ || hostCacheDirectories.has(entryPath)
256
+ || isHostBytecode(entryPath)
238
257
  ) continue;
239
258
  failures.push({ ...entryLabel(entryPath), reason: "unexpected" });
240
259
  }