@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.
- package/README.md +10 -9
- package/README_Ko-KR.md +10 -9
- package/assets/lithermes-plugin/README.md +28 -9
- package/assets/lithermes-plugin/__init__.py +8 -3
- package/assets/lithermes-plugin/auto_update.py +17 -3
- package/assets/lithermes-plugin/core.py +41 -2
- package/assets/lithermes-plugin/core_contexts.py +27 -9
- package/assets/lithermes-plugin/core_contract.py +7 -2
- package/assets/lithermes-plugin/core_review.py +144 -3
- package/assets/lithermes-plugin/core_routing.py +10 -0
- package/assets/lithermes-plugin/core_workflow_commands.py +3 -2
- package/assets/lithermes-plugin/deliverable_hedge_guard.py +171 -48
- package/assets/lithermes-plugin/diagnostics.py +5 -3
- package/assets/lithermes-plugin/humanizer_detector.py +117 -5
- package/assets/lithermes-plugin/lit_reentry.py +233 -0
- package/assets/lithermes-plugin/payload-version.json +184 -52
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/rules/__init__.py +2 -0
- package/assets/lithermes-plugin/rules/bundled-rules/lit-humanizer.md +2 -0
- package/assets/lithermes-plugin/rules/engine.py +26 -11
- package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
- package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-humanizer/SKILL.md +2 -0
- package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
- package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
- package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
- package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
- package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
- package/package.json +2 -1
- package/readme-assets/badge-version.svg +1 -1
- package/readme-assets/cover-motion-still.webp +0 -0
- package/readme-assets/cover-motion.webp +0 -0
- package/src/cli.js +2 -2
- package/src/lib/check.js +9 -6
- package/src/lib/hermesDiscovery.js +2 -2
- package/src/lib/hostExec.js +81 -0
- package/src/lib/skillPayload.js +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
@@ -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()) ==
|
|
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:
|