@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 +10 -10
- package/README_Ko-KR.md +10 -10
- package/assets/lithermes-plugin/__init__.py +1 -0
- package/assets/lithermes-plugin/deliverable_hedge_guard.py +171 -48
- package/assets/lithermes-plugin/diagnostics.py +0 -19
- package/assets/lithermes-plugin/humanizer_detector.py +117 -5
- package/assets/lithermes-plugin/payload-version.json +11 -11
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/rules/bundled-rules/lit-humanizer.md +2 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
- package/assets/lithermes-plugin/skills/lit-humanizer/SKILL.md +2 -0
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
- package/package.json +1 -1
- package/readme-assets/badge-version.svg +1 -1
- package/src/lib/files.js +25 -0
- package/src/lib/install.js +2 -1
- package/src/lib/skillPayload.js +20 -1
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.
|
|
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.
|
|
5
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
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.
|
|
10
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
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>
|
|
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>
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
5
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
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.
|
|
10
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
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>
|
|
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>
|
|
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.
|
|
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
|
-
플러그인을
|
|
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`를 실행하세요. 같은 프로젝트의 다음 세션이 그 기록을 읽고
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
227
|
+
located = _locate_artifact(path_text, workspace)
|
|
199
228
|
directory_flags = _directory_flags()
|
|
200
|
-
if
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
311
|
-
"line_end":
|
|
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-
|
|
2
|
+
"syncedAt": "2026-10-19T12:29:49.086Z",
|
|
3
3
|
"source": "bundled-payload",
|
|
4
|
-
"sourceHash": "
|
|
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": "
|
|
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": "
|
|
88
|
+
"sha256": "f65110568952f197e93452e8a9d01c21cd75479b987110d36e9b05a78f34a302"
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
"path": "diagnostics.py",
|
|
92
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
4404
|
+
"sha256": "0ed07370f299c81cfdd7fc6c26c8d3e4af784286df25234a425ee8ec3cd7a3b3"
|
|
4405
4405
|
},
|
|
4406
4406
|
{
|
|
4407
4407
|
"path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.
|
|
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
|
};
|
package/src/lib/install.js
CHANGED
|
@@ -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
|
}
|
package/src/lib/skillPayload.js
CHANGED
|
@@ -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
|
}
|