@litfamily/lithermes 1.0.26 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +10 -9
  2. package/README_Ko-KR.md +10 -9
  3. package/assets/lithermes-plugin/README.md +28 -9
  4. package/assets/lithermes-plugin/__init__.py +8 -3
  5. package/assets/lithermes-plugin/auto_update.py +17 -3
  6. package/assets/lithermes-plugin/core.py +41 -2
  7. package/assets/lithermes-plugin/core_contexts.py +27 -9
  8. package/assets/lithermes-plugin/core_contract.py +7 -2
  9. package/assets/lithermes-plugin/core_review.py +144 -3
  10. package/assets/lithermes-plugin/core_routing.py +10 -0
  11. package/assets/lithermes-plugin/core_workflow_commands.py +3 -2
  12. package/assets/lithermes-plugin/deliverable_hedge_guard.py +171 -48
  13. package/assets/lithermes-plugin/diagnostics.py +5 -3
  14. package/assets/lithermes-plugin/humanizer_detector.py +117 -5
  15. package/assets/lithermes-plugin/lit_reentry.py +233 -0
  16. package/assets/lithermes-plugin/payload-version.json +184 -52
  17. package/assets/lithermes-plugin/plugin.yaml +1 -1
  18. package/assets/lithermes-plugin/rules/__init__.py +2 -0
  19. package/assets/lithermes-plugin/rules/bundled-rules/lit-humanizer.md +2 -0
  20. package/assets/lithermes-plugin/rules/engine.py +26 -11
  21. package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
  25. package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
  26. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
  27. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
  28. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
  29. package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
  30. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
  31. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
  32. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
  33. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
  34. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
  35. package/assets/lithermes-plugin/skills/lit-humanizer/SKILL.md +2 -0
  36. package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
  37. package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
  38. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
  39. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
  40. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
  41. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
  42. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
  43. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
  44. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
  45. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
  46. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
  47. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
  48. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
  49. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
  50. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
  51. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
  52. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
  53. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
  54. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
  55. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  56. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
  57. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
  58. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
  59. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  60. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
  61. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  62. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
  63. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
  64. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
  65. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
  66. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
  67. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  68. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
  69. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
  70. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
  71. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
  72. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
  73. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
  74. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
  75. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  76. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
  77. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
  78. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
  79. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
  80. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
  81. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
  82. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
  83. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
  84. package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
  85. package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
  86. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  87. package/package.json +2 -1
  88. package/readme-assets/badge-version.svg +1 -1
  89. package/readme-assets/cover-motion-still.webp +0 -0
  90. package/readme-assets/cover-motion.webp +0 -0
  91. package/src/cli.js +2 -2
  92. package/src/lib/check.js +9 -6
  93. package/src/lib/hermesDiscovery.js +2 -2
  94. package/src/lib/hostExec.js +81 -0
  95. package/src/lib/skillPayload.js +1 -1
  96. package/src/lib/updateNotifier.js +14 -4
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
4
5
  from pathlib import Path
5
6
 
6
7
  try:
@@ -11,7 +12,7 @@ try:
11
12
  reader_facing_contract_block,
12
13
  )
13
14
  from .core_reader_args import parse_reader_facing_command_args
14
- from .core_runtime import parse_args, record_delegate_batch_intent, workspace_from_option
15
+ from .core_runtime import parse_args, plan_dir, record_delegate_batch_intent, workspace_from_option
15
16
  except (ImportError, ModuleNotFoundError):
16
17
  from core_contract import (
17
18
  _skill_body_block,
@@ -20,7 +21,7 @@ except (ImportError, ModuleNotFoundError):
20
21
  reader_facing_contract_block,
21
22
  )
22
23
  from core_reader_args import parse_reader_facing_command_args
23
- from core_runtime import parse_args, record_delegate_batch_intent, workspace_from_option
24
+ from core_runtime import parse_args, plan_dir, record_delegate_batch_intent, workspace_from_option
24
25
 
25
26
  def _run_git(workspace: Path, args: list[str]) -> str:
26
27
  import subprocess
@@ -100,6 +101,72 @@ def detect_run_command(workspace: Path) -> str:
100
101
  return "(detect manually — no dev/start script found)"
101
102
 
102
103
 
104
+ PLAN_REVIEW_LANES = [
105
+ ("scope-objective", "Scope and objective achievability — exactly one bounded objective, explicit non-goals, a scope small enough to reach one outcome. Verdict PASS/ITERATE/NEEDS-CONTEXT."),
106
+ ("dependencies-gates", "Dependencies and unknown gates — real prerequisites, acyclic ordering, resolved or gated unknowns, startable first-wave items. Verdict PASS/ITERATE/NEEDS-CONTEXT."),
107
+ ("atomic-checklist", "Atomic checklist — every item has one action, one output, one verification, references, dependencies, and a binary acceptance result. Verdict PASS/ITERATE/NEEDS-CONTEXT."),
108
+ ("acceptance-evidence", "Acceptance and evidence — success criteria map to exact artifacts and commands; negative and boundary evidence fits the risk. Verdict PASS/ITERATE/NEEDS-CONTEXT."),
109
+ ("failure-decision-cleanup", "Failure, decision and cleanup — alternative outcomes do not assume the preferred path; stop, rollback, cleanup and DoneClaim conditions are explicit. Verdict PASS/ITERATE/NEEDS-CONTEXT."),
110
+ ]
111
+
112
+ # `lit review plan ...` / `/review-work plan ...` force plan review; `work` forces work review.
113
+ _FORCED_MODE = re.compile(r"^\s*(?P<mode>plan|work)(?![\w-])\s*(?P<rest>.*)$", re.IGNORECASE | re.DOTALL)
114
+ AUTO_PICK_RULE = (
115
+ "Review mode: auto-pick — work review when a named PR or diff, changed files outside plans/, or ticked plan rows "
116
+ "exist; plan review when a plan has no ticked rows and nothing else changed; ask the user when neither exists. "
117
+ "Name the picked mode on the first line of the report."
118
+ )
119
+ _PLAN_ROW = re.compile(r"^- \[(?P<mark>[ xX])\] ", re.MULTILINE)
120
+ _STATE_PREFIXES = ("plans/", ".hermes/")
121
+
122
+
123
+ def forced_review_mode(text: str) -> tuple[str | None, str]:
124
+ """Split a leading `plan`/`work` word off a review request."""
125
+ match = _FORCED_MODE.match(str(text or ""))
126
+ if match is None:
127
+ return None, str(text or "").strip()
128
+ return match.group("mode").lower(), match.group("rest").strip()
129
+
130
+
131
+ def review_mode_line(objective: str) -> str:
132
+ mode, _ = forced_review_mode(objective)
133
+ return f"Review mode: {mode} (forced by the user)" if mode else AUTO_PICK_RULE
134
+
135
+
136
+ def review_target(objective: str) -> str:
137
+ return forced_review_mode(objective)[1]
138
+
139
+
140
+ def _plan_rows(workspace: Path) -> list[tuple[int, int]]:
141
+ """(ticked, total) checklist rows for each plan under plans/."""
142
+ rows: list[tuple[int, int]] = []
143
+ try:
144
+ files = sorted(plan_dir(workspace).glob("*.md"))[:20]
145
+ except OSError:
146
+ return rows
147
+ for file in files:
148
+ try:
149
+ marks = [m.group("mark") for m in _PLAN_ROW.finditer(file.read_text(encoding="utf-8")[:200_000])]
150
+ except OSError:
151
+ continue
152
+ rows.append((sum(mark != " " for mark in marks), len(marks)))
153
+ return rows
154
+
155
+
156
+ def pick_review_mode(
157
+ forced: str | None, *, changed: list[str], plans: list[tuple[int, int]]
158
+ ) -> tuple[str, str]:
159
+ if forced:
160
+ return forced, "forced by the user"
161
+ if changed:
162
+ return "work", "auto-picked: changed files outside plans/"
163
+ if any(ticked for ticked, _ in plans):
164
+ return "work", "auto-picked: ticked plan rows"
165
+ if any(total for _, total in plans):
166
+ return "plan", "auto-picked: an unticked plan and no changed files"
167
+ return "ask", "no plan and no changed files found"
168
+
169
+
103
170
  REVIEW_LANES = [
104
171
  ("goal", "Goal & constraint verification — does the diff achieve the stated goal within every constraint; flag missed requirements, over-engineering, edge cases. Verdict PASS/FAIL + confidence."),
105
172
  ("qa", "QA by execution — brainstorm 15+ scenarios (happy/boundary/error/regression), then actually run the app/surface and capture evidence; tests alone are insufficient. Verdict PASS/FAIL with per-scenario results."),
@@ -109,11 +176,84 @@ REVIEW_LANES = [
109
176
  ]
110
177
 
111
178
 
179
+ def _plan_or_ask_review(mode, mode_line, target, workspace, reader_args) -> dict[str, str]:
180
+ """Plan review dispatches the plan-audit lanes; ask dispatches nothing."""
181
+ parent_synthesis_contract = reader_facing_contract_block(
182
+ reader_args.requested_mode,
183
+ authority=reader_args.authority,
184
+ boundary="parent_synthesis",
185
+ )
186
+ if mode == "ask":
187
+ return {
188
+ "display": "LitHermes review-work: no plan and no changed files found. Asking what to review.",
189
+ "agent_message": "\n".join([
190
+ "<lithermes-review-work>",
191
+ contract_route_block("review-work", surface="slash-command"),
192
+ f"workspace: {workspace}",
193
+ mode_line,
194
+ "Ask the user one short question: review a plan (give its path) or review work (give a diff, branch or PR)?",
195
+ "Do not dispatch any lane and do not guess a target.",
196
+ "</lithermes-review-work>",
197
+ parent_synthesis_contract,
198
+ ]),
199
+ }
200
+ batch_event = record_delegate_batch_intent(
201
+ workspace=workspace,
202
+ mode="review-work",
203
+ lanes=[key for key, _ in PLAN_REVIEW_LANES],
204
+ context={"review_mode": "plan", "target": target},
205
+ )
206
+ child_return_contract = reader_facing_contract_block(
207
+ "audit",
208
+ authority="explicit_parent_to_child_return_mode",
209
+ boundary="subagent_to_parent_return",
210
+ )
211
+ lines = [
212
+ "Run the LitHermes plan review on the draft plan.",
213
+ "",
214
+ "<lithermes-review-work>",
215
+ contract_route_block("review-work", surface="slash-command"),
216
+ f"workspace: {workspace}",
217
+ mode_line,
218
+ f"plan under review: {target or '(newest file under plans/)'}",
219
+ f"delegate batch evidence: {batch_event.get('artifact_dir', '')}",
220
+ "",
221
+ "Plan review is read-only: audit the draft, never implement. Dispatch the five audit lanes in ONE",
222
+ "delegate_task call (a `tasks` array of 5 entries, each {goal: <lane brief>, context: <plan text>});",
223
+ "per-child re-entry receipts arrive as separate messages and there is no combined wait:",
224
+ *(f"- lane[{key}]: {brief}" for key, brief in PLAN_REVIEW_LANES),
225
+ "",
226
+ "Any NEEDS-CONTEXT controls the aggregate, then any ITERATE; all lanes must PASS for PLAN REVIEW PASS.",
227
+ "Name the mode on the first line of the report. Load the lithermes:review-work skill for the plan verdict contract.",
228
+ _skill_body_block("review-work"),
229
+ "The audit child-return packet stays detailed for review; it cannot elevate the parent response:",
230
+ child_return_contract,
231
+ "</lithermes-review-work>",
232
+ parent_synthesis_contract,
233
+ ]
234
+ return {
235
+ "display": "LitHermes review-work: plan review, 5 audit lanes. Dispatching parallel review now.",
236
+ "agent_message": "\n".join(lines),
237
+ }
238
+
239
+
112
240
  def command_review_work(raw_args: str) -> dict[str, str]:
113
241
  reader_args = parse_reader_facing_command_args(raw_args)
114
242
  args = parse_args(reader_args.command_args)
115
243
  workspace = workspace_from_option(args.options.get("worktree"))
116
- base = str(args.options.get("base") or "HEAD~1")
244
+ explicit_base = args.options.get("base")
245
+ base = str(explicit_base or "HEAD~1")
246
+ forced, target = forced_review_mode(" ".join(args.positional))
247
+ # Older commits are not evidence of work to review: without --base only uncommitted changes count.
248
+ signal_paths = [] if forced else _changed_paths(workspace, str(explicit_base or "HEAD"))
249
+ mode, reason = pick_review_mode(
250
+ forced,
251
+ changed=[path for path in signal_paths if not path.startswith(_STATE_PREFIXES)],
252
+ plans=_plan_rows(workspace),
253
+ )
254
+ mode_line = f"Review mode: {mode} ({reason})"
255
+ if mode != "work":
256
+ return _plan_or_ask_review(mode, mode_line, target, workspace, reader_args)
117
257
  changed_paths = _changed_paths(workspace, base)
118
258
  diff = _run_git(workspace, ["diff", base])
119
259
  if len(diff) > 60000:
@@ -157,6 +297,7 @@ def command_review_work(raw_args: str) -> dict[str, str]:
157
297
  "<lithermes-review-work>",
158
298
  contract_route_block("review-work", surface="slash-command"),
159
299
  f"workspace: {workspace}",
300
+ mode_line,
160
301
  f"base: {base}",
161
302
  f"run command (for QA lane): {run_cmd}",
162
303
  f"delegate batch evidence: {batch_event.get('artifact_dir', '')}",
@@ -87,6 +87,7 @@ _BARE_SKILL_PREFIXES = (
87
87
  ("wikify", "wikify"),
88
88
  ("litresearch", "litresearch"),
89
89
  ("lit-comprehend", "lit-comprehend"),
90
+ ("lit-brainstorm", "lit-brainstorm"),
90
91
  ("humanizer", "lit-humanizer"),
91
92
  ("lit-humanizer", "lit-humanizer"),
92
93
  ("comprehend", "lit-comprehend"),
@@ -136,6 +137,15 @@ _LIT_SKILL_SUFFIXES = (
136
137
  ("wikify", "wikify"),
137
138
  ("lit-comprehend", "lit-comprehend"),
138
139
  ("comprehend", "lit-comprehend"),
140
+ # Open-ask words. Safe HERE: the user already typed `lit`. They are not bare
141
+ # routes, because "idea" and "brainstorming" open ordinary sentences.
142
+ ("lit-brainstorm", "lit-brainstorm"),
143
+ ("brainstorm", "lit-brainstorm"),
144
+ ("brainstorming", "lit-brainstorm"),
145
+ ("ideation", "lit-brainstorm"),
146
+ ("ideate", "lit-brainstorm"),
147
+ ("idea", "lit-brainstorm"),
148
+ ("ideas", "lit-brainstorm"),
139
149
  ("comment-checker", "comment-checker"),
140
150
  ("lsp-setup", "lsp-setup"),
141
151
  ("lsp", "lsp"),
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  try:
4
4
  from .core_commands import _join_positional
5
5
  from .core_contract import (
6
- _skill_body_block, conditional_uiux_skill_blocks, contract_route_block,
6
+ LITGOAL_MEANING, _skill_body_block, conditional_uiux_skill_blocks, contract_route_block,
7
7
  reader_facing_contract_block,
8
8
  )
9
9
  from .core_plans import plan_structure_issues, unchecked_items
@@ -14,7 +14,7 @@ try:
14
14
  except (ImportError, ModuleNotFoundError):
15
15
  from core_commands import _join_positional
16
16
  from core_contract import (
17
- _skill_body_block, conditional_uiux_skill_blocks, contract_route_block,
17
+ LITGOAL_MEANING, _skill_body_block, conditional_uiux_skill_blocks, contract_route_block,
18
18
  reader_facing_contract_block,
19
19
  )
20
20
  from core_plans import plan_structure_issues, unchecked_items
@@ -39,6 +39,7 @@ def command_litgoal(raw_args: str) -> dict[str, str]:
39
39
  "<lithermes-litgoal-command>",
40
40
  contract_route_block("litgoal", surface="slash-command"),
41
41
  f"workspace: {workspace}",
42
+ LITGOAL_MEANING,
42
43
  "Durable goal state lives under .hermes/lithermes/litgoal/ (goals.json + ledger.jsonl + evidence/).",
43
44
  "Drive it through the model-facing goal tools, not prose:",
44
45
  "- goal_status to read the active objective, criteria, evidence, and quality gate;",
@@ -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
@@ -498,15 +498,17 @@ def _motion_runtime_lines() -> list[str]:
498
498
  command = PLUGIN_ROOT / "skills" / "lit-typographic-motion" / "bin" / "runtime.mjs"
499
499
  if not command.is_file():
500
500
  return ["motion Chrome: unavailable", "motion ffmpeg: unavailable", "motion WebGL2 renderer: unavailable",
501
- "motion software GL: unknown", "motion pre-warm: missing; run lithermes motion-runtime install"]
501
+ "motion software GL: unknown", "motion pre-warm: missing; run lithermes motion-runtime install",
502
+ "motion cover encoder: unavailable"]
502
503
  try:
503
504
  result = subprocess.run(["node", str(command), "status"], capture_output=True, text=True, timeout=35)
504
- if result.returncode == 0 and len(result.stdout.splitlines()) == 5:
505
+ if result.returncode == 0 and len(result.stdout.splitlines()) == 6:
505
506
  return ["motion " + line for line in result.stdout.splitlines()]
506
507
  except (OSError, subprocess.TimeoutExpired):
507
508
  pass
508
509
  return ["motion Chrome: unavailable", "motion ffmpeg: unavailable", "motion WebGL2 renderer: unavailable",
509
- "motion software GL: unknown", "motion pre-warm: status failed; run lithermes motion-runtime status"]
510
+ "motion software GL: unknown", "motion pre-warm: status failed; run lithermes motion-runtime status",
511
+ "motion cover encoder: unavailable"]
510
512
 
511
513
 
512
514
  def _motion_line_needs_attention(line: str) -> bool: