lithermes-ai 0.8.4 → 0.8.6
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 +33 -1
- package/README_Ko-KR.md +20 -1
- package/assets/lithermes-plugin/README.md +29 -4
- package/assets/lithermes-plugin/__init__.py +28 -3
- package/assets/lithermes-plugin/core.py +317 -48
- package/assets/lithermes-plugin/litgoal/runtime.py +21 -11
- package/assets/lithermes-plugin/litgoal/store.py +15 -2
- package/assets/lithermes-plugin/payload-version.json +16 -12
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/redaction.py +72 -0
- package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +11 -12
- package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -4
- package/assets/lithermes-plugin/skills/review-work/SKILL.md +4 -2
- package/assets/lithermes-plugin/skills/start-work/SKILL.md +13 -20
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -51,11 +51,16 @@ Restart any running Hermes CLI or Hermes gateway process. Then open Hermes and t
|
|
|
51
51
|
- `/lit`: start an Litwork loop immediately.
|
|
52
52
|
- `/lit-loop`: explicit alias for the same execution loop.
|
|
53
53
|
- `/lit-plan`: create a durable implementation plan.
|
|
54
|
+
- Natural routing: a standalone `lit` or `litwork` in normal prose activates Litwork;
|
|
55
|
+
`lit plan`, `lit review`, `lit research`, and `lit goal` route to the matching
|
|
56
|
+
Lithermes mode contract. Tokens inside code spans, fenced code, substrings,
|
|
57
|
+
compounds, paths, and real slash-command mentions are ignored.
|
|
54
58
|
- `/litwork-loop` and `/litwork-plan`: longer aliases.
|
|
55
59
|
- `/lit_loop` and `/lit_plan`: gateway-friendly aliases for Telegram dispatch.
|
|
56
60
|
- Native `/goal` binding: Hermes has no model-facing goal tools, so `/lit`, `/lit-loop`, and `/lit-plan` bind the native standing `/goal` via the session goal manager (persists across turns; native evidence-judge decides completion). Criteria + evidence use the durable `goal_*` tools.
|
|
57
61
|
- Interactive install spinner keeps terminal installs lively while redirected or scripted installs stay plain; use `npx lithermes-ai install --yes --no-spinner` for quiet terminal installs.
|
|
58
|
-
-
|
|
62
|
+
- `/start-work`: execution-only for an approved plan. Natural-language `lit start work`
|
|
63
|
+
is `BLOCKED` because a hook cannot switch Hermes commands; invoke `/start-work <plan>` explicitly.
|
|
59
64
|
- LitHermes workflow skill set: `ai-slop-remover`, `comment-checker`,
|
|
60
65
|
`debugging`, `deep-interview`, `frontend-ui-ux`, `git-master`, `init-deep`,
|
|
61
66
|
`lsp`, `programming`, `refactor`,
|
|
@@ -66,6 +71,33 @@ Restart any running Hermes CLI or Hermes gateway process. Then open Hermes and t
|
|
|
66
71
|
so each release is reproducible and auditable. Repo-rule loading is handled by
|
|
67
72
|
Hermes' native context-files feature, not a LitHermes hook.
|
|
68
73
|
|
|
74
|
+
## Mode Contract
|
|
75
|
+
|
|
76
|
+
- `lit` / `litwork`: execution discipline. Direct `lit <task>` creates run state
|
|
77
|
+
under `.hermes/lithermes/runs/`; indirect mentions only inject the Litwork loop.
|
|
78
|
+
- `lit plan`: planning-only. It must inspect, interview if needed, and produce a
|
|
79
|
+
plan; it must not implement or call start-work.
|
|
80
|
+
- `lit review`: review-work mode. It verifies goal/constraints, real-surface QA,
|
|
81
|
+
code quality, security/safety, and context/docs/package readiness. Missing
|
|
82
|
+
evidence, timeouts, or cleanup gaps block approval.
|
|
83
|
+
- `lit research`: litresearch mode. Separate verified facts, hypotheses, sources,
|
|
84
|
+
and uncertainty; keep any journal under `.hermes/lithermes/litresearch/`.
|
|
85
|
+
- `lit goal`: litgoal mode. Bind one objective plus checkable criteria through
|
|
86
|
+
`goal_set` / `goal_*` tools; state lives in `.hermes/lithermes/litgoal/`.
|
|
87
|
+
- `lit start work`: `BLOCKED` unless the user invokes the native `/start-work`
|
|
88
|
+
command. `/start-work` is execution-only for approved plans.
|
|
89
|
+
|
|
90
|
+
Natural routing ignores inline code spans like `` `lit plan` ``, fenced code, real
|
|
91
|
+
slash-command mentions such as `/lit`, substrings like `split`, and compounds like
|
|
92
|
+
`lit-review` or `lit_loop`. Path-like arguments such as `/tmp/repo` and
|
|
93
|
+
`/api/v1/users` remain ordinary task text and do not suppress valid activation.
|
|
94
|
+
|
|
95
|
+
Secret-bearing prompts are redacted before persistence or model-facing handoff
|
|
96
|
+
(for example `Authorization: Bearer ...`, `api_key=...`, `token=...`, and common
|
|
97
|
+
provider key shapes). Malformed input fails closed without partial run-state.
|
|
98
|
+
Local state under `.hermes/lithermes/`, `plans/`, `runs/`, `evidence/`,
|
|
99
|
+
`state.json`, `ledger.jsonl`, and `notepad.md` is not packaged into npm payloads.
|
|
100
|
+
|
|
69
101
|
## Requirements
|
|
70
102
|
|
|
71
103
|
- Hermes Agent already installed on the machine.
|
package/README_Ko-KR.md
CHANGED
|
@@ -51,11 +51,14 @@ npx lithermes-ai install --yes
|
|
|
51
51
|
- `/lit`: Litwork loop를 바로 시작합니다.
|
|
52
52
|
- `/lit-loop`: 같은 실행 loop를 명시적으로 호출합니다.
|
|
53
53
|
- `/lit-plan`: 구현 계획을 먼저 세웁니다.
|
|
54
|
+
- natural routing: 일반 문장 속 standalone lit / litwork / lit plan / lit review /
|
|
55
|
+
lit research / lit goal을 Hermes-native mode로 라우팅합니다. code spans,
|
|
56
|
+
fenced code, substring, compound token, path 안의 lit, 실제 slash-command 언급은 무시합니다.
|
|
54
57
|
- `/litwork-loop`, `/litwork-plan`: 긴 이름의 alias입니다.
|
|
55
58
|
- `/lit_loop`, `/lit_plan`: Telegram dispatch에 맞춘 gateway alias입니다.
|
|
56
59
|
- 네이티브 `/goal` 바인딩: Hermes에는 model-facing goal tool이 없으므로 `/lit`, `/lit-loop`, `/lit-plan`은 세션 goal manager를 통해 네이티브 standing `/goal`을 대신 설정합니다(턴을 넘어 유지되고 네이티브 evidence-judge가 완료를 판정). success criteria와 증거는 durable `goal_*` 도구로 추적합니다.
|
|
57
60
|
- interactive install spinner가 terminal 설치는 더 생동감 있게 보여주고, redirect/script 설치는 기존처럼 plain output을 유지합니다. 조용한 terminal 설치가 필요하면 `npx lithermes-ai install --yes --no-spinner`를 사용합니다.
|
|
58
|
-
-
|
|
61
|
+
- `/start-work`: 승인된 plan만 실행하는 execution-only 명령입니다. 자연어 `lit start work`는 hook이 Hermes command 전환을 할 수 없으므로 `BLOCKED`되고, 사용자가 `/start-work <plan>`을 직접 호출해야 합니다.
|
|
59
62
|
- LitHermes workflow skill set: `ai-slop-remover`, `comment-checker`,
|
|
60
63
|
`debugging`, `deep-interview`, `frontend-ui-ux`, `git-master`, `init-deep`,
|
|
61
64
|
`lsp`, `programming`, `refactor`,
|
|
@@ -65,6 +68,22 @@ npx lithermes-ai install --yes
|
|
|
65
68
|
hook, 모든 skill, durable goal tooling — 이 설치 상태 그대로 번들에 들어가므로,
|
|
66
69
|
각 릴리스는 재현 가능하고 감사할 수 있습니다.
|
|
67
70
|
|
|
71
|
+
## Mode Contract
|
|
72
|
+
|
|
73
|
+
- `lit` / `litwork`: 실행 discipline입니다. 직접 `lit <task>`는 `.hermes/lithermes/runs/`에 run state를 씁니다.
|
|
74
|
+
- `lit plan`: planning-only입니다. 구현하거나 start-work를 호출하지 않고 plan을 만들고 승인 대기합니다.
|
|
75
|
+
- `lit review`: review-work mode입니다. behavior, tests, docs/package readiness, security/safety, cleanup evidence를 5-lane으로 검증합니다.
|
|
76
|
+
- `lit research`: verified facts, hypotheses, sources, uncertainty를 분리하고 journal은 `.hermes/lithermes/litresearch/`에 둡니다.
|
|
77
|
+
- `lit goal`: one objective plus checkable criteria를 `.hermes/lithermes/litgoal/`에 `goal_*` 도구로 기록합니다.
|
|
78
|
+
- `lit start work`: `BLOCKED`; 승인된 plan에 대해 native `/start-work <approved-plan>`을 직접 호출해야 합니다.
|
|
79
|
+
|
|
80
|
+
Natural routing은 `code spans`, fenced code, `/lit` 같은 실제 slash-command mention,
|
|
81
|
+
`split` 같은 substring, `lit-review` / `lit_loop` 같은 compound token을 무시합니다.
|
|
82
|
+
반면 `/tmp/repo`, `/api/v1/users` 같은 path-like argument는 유효한 task text입니다.
|
|
83
|
+
secret-bearing prompt는 durable persistence / model handoff 전에 redact되며,
|
|
84
|
+
malformed input은 partial run-state 없이 실패합니다. `.hermes/lithermes`, `plans/`,
|
|
85
|
+
`runs/`, `evidence/`, `state.json`, `ledger.jsonl`, `notepad.md` 같은 local state는 npm에 not packaged 됩니다.
|
|
86
|
+
|
|
68
87
|
## 요구 사항
|
|
69
88
|
|
|
70
89
|
- Hermes Agent가 이미 설치되어 있어야 합니다.
|
|
@@ -7,11 +7,14 @@ first-class Hermes skills:
|
|
|
7
7
|
goal bootstrap to the Hermes agent instead of stopping at plan creation.
|
|
8
8
|
- `/lit-loop` and `/litwork-loop` create run state under
|
|
9
9
|
`.hermes/lithermes/runs/` and dispatch the task back to the Hermes agent.
|
|
10
|
-
- `/start-work` opens or dry-runs
|
|
10
|
+
- `/start-work` opens or dry-runs an approved plan against a workspace; it is
|
|
11
|
+
execution-only and never bootstraps a plan from a brief.
|
|
11
12
|
- The `pre_llm_call` hook injects an Litwork directive when the user says
|
|
12
|
-
`lit` or `litwork`.
|
|
13
|
-
`lit
|
|
14
|
-
|
|
13
|
+
a standalone `lit` or `litwork`. It also routes `lit plan`, `lit review`,
|
|
14
|
+
`lit research`, and `lit goal` to the matching `lithermes:*` skill contract.
|
|
15
|
+
Hermes has no model-facing goal tools, so a direct `lit <task>` (and the /lit
|
|
16
|
+
command) binds the native standing `/goal` via the session goal manager;
|
|
17
|
+
criteria + evidence use the durable `goal_*` tools.
|
|
15
18
|
- Explicit skills are available as:
|
|
16
19
|
`lithermes:ai-slop-remover`, `lithermes:comment-checker`,
|
|
17
20
|
`lithermes:debugging`, `lithermes:deep-interview`,
|
|
@@ -25,6 +28,28 @@ first-class Hermes skills:
|
|
|
25
28
|
run in parallel, the parent blocks for all); there is no named-agent registry
|
|
26
29
|
and no per-child model selection.
|
|
27
30
|
|
|
31
|
+
## Mode Contract
|
|
32
|
+
|
|
33
|
+
- `lit` / `litwork`: Litwork execution discipline; direct `lit <task>` writes
|
|
34
|
+
`.hermes/lithermes/runs/<run>/` and forwards the task.
|
|
35
|
+
- `lit plan`: planning-only. Do not implement or start work; create/refine an
|
|
36
|
+
approved plan first.
|
|
37
|
+
- `lit review`: review-work verifies behavior, tests, docs/package readiness,
|
|
38
|
+
security/safety, and cleanup evidence through a 5-lane all-or-nothing gate.
|
|
39
|
+
- `lit research`: separate verified facts, hypotheses, sources, and uncertainty;
|
|
40
|
+
journals live under `.hermes/lithermes/litresearch/<slug>/`.
|
|
41
|
+
- `lit goal`: bind one objective plus checkable criteria in
|
|
42
|
+
`.hermes/lithermes/litgoal/`.
|
|
43
|
+
- `lit start work`: `BLOCKED` in natural routing because `pre_llm_call` cannot
|
|
44
|
+
switch Hermes commands. The user must invoke `/start-work <approved-plan>`.
|
|
45
|
+
|
|
46
|
+
Natural routing ignores code spans, fenced code, substrings, compounds, path-
|
|
47
|
+
embedded tokens, and real slash-command mentions. `/tmp/repo` and
|
|
48
|
+
`/api/v1/users` are path-like arguments, not suppressors. Secret-bearing prompt
|
|
49
|
+
text is redacted before persistence or model-facing handoff; malformed input
|
|
50
|
+
fails closed without partial run state. Local `.hermes/lithermes` state, plans,
|
|
51
|
+
runs, evidence, `state.json`, `ledger.jsonl`, and `notepad.md` are not packaged.
|
|
52
|
+
|
|
28
53
|
Enable with:
|
|
29
54
|
|
|
30
55
|
```yaml
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
import functools
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
@@ -116,16 +117,40 @@ def _pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
|
|
|
116
117
|
|
|
117
118
|
|
|
118
119
|
def _setup_lithermes_cli(parser) -> None:
|
|
120
|
+
try:
|
|
121
|
+
parser.formatter_class = argparse.RawDescriptionHelpFormatter
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
parser.description = "LitHermes — Hermes-native Litwork toolkit (litgoal runtime, skills, hooks)."
|
|
125
|
+
parser.epilog = (
|
|
126
|
+
"slash commands: /lit /lit-loop /lit-plan /litgoal /review-work /start-work /deep-interview\n"
|
|
127
|
+
"skills: 20 lithermes:* skills — `hermes lithermes status` lists them\n"
|
|
128
|
+
"hooks: pre_llm_call, subagent_stop, transform_llm_output\n"
|
|
129
|
+
"run `hermes lithermes status` for versions + full surface, or `doctor` for health checks"
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument("--version", action="store_true", help="print the LitHermes plugin version")
|
|
119
132
|
sub = parser.add_subparsers(dest="lh_cmd")
|
|
133
|
+
sub.add_parser("version", help="print the LitHermes plugin version")
|
|
134
|
+
sub.add_parser("status", help="show LitHermes + Hermes versions, hooks, commands, skills")
|
|
135
|
+
sub.add_parser("doctor", help="run LitHermes health checks")
|
|
120
136
|
goal_parser = sub.add_parser("goal", help="LitHermes litgoal durable runtime")
|
|
121
137
|
litgoal_cli.setup(goal_parser)
|
|
122
138
|
|
|
123
139
|
|
|
124
140
|
def _handle_lithermes_cli(args) -> int:
|
|
125
|
-
|
|
141
|
+
cmd = getattr(args, "lh_cmd", None)
|
|
142
|
+
if getattr(args, "version", False) or cmd == "version":
|
|
143
|
+
print(core.version_line())
|
|
144
|
+
return 0
|
|
145
|
+
if cmd == "doctor":
|
|
146
|
+
lines, code = core.doctor_report()
|
|
147
|
+
print("\n".join(lines))
|
|
148
|
+
return code
|
|
149
|
+
if cmd == "goal":
|
|
126
150
|
return litgoal_cli.handle(args)
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
# status, or bare `hermes lithermes` → the most useful "who am I" answer
|
|
152
|
+
print(core.status_report())
|
|
153
|
+
return 0
|
|
129
154
|
|
|
130
155
|
|
|
131
156
|
def _ignited(handler):
|
|
@@ -10,6 +10,11 @@ from datetime import datetime, timezone
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Iterable
|
|
12
12
|
|
|
13
|
+
try:
|
|
14
|
+
from .redaction import redact_obj, redact_text
|
|
15
|
+
except (ImportError, ModuleNotFoundError): # standalone test import via PYTHONPATH
|
|
16
|
+
from redaction import redact_obj, redact_text # type: ignore
|
|
17
|
+
|
|
13
18
|
try:
|
|
14
19
|
get_hermes_home = importlib.import_module("hermes_constants").get_hermes_home
|
|
15
20
|
except Exception:
|
|
@@ -25,11 +30,12 @@ except Exception:
|
|
|
25
30
|
LITGOAL_STATE_DIRNAME = "litgoal"
|
|
26
31
|
|
|
27
32
|
# Fire on a standalone `lit`/`litwork` token delimited by whitespace, string
|
|
28
|
-
# edge, or punctuation — but NOT inside a larger word ("split", "literally")
|
|
29
|
-
# NOT as a hyphen/underscore compound ("lit-review", "lit_loop")
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
# edge, or punctuation — but NOT inside a larger word ("split", "literally"),
|
|
34
|
+
# NOT as a hyphen/underscore compound ("lit-review", "lit_loop"), and NOT when
|
|
35
|
+
# the token is path/slash-command embedded (`/lit`, `/tmp/lit.sock`). Path-like
|
|
36
|
+
# slash tokens elsewhere in the prompt do not suppress a valid standalone token.
|
|
37
|
+
LIT_PATTERN = re.compile(r"(?<![\w/-])(?:litwork|lit)(?![\w/-])", re.IGNORECASE)
|
|
38
|
+
DIRECT_LIT_PATTERN = re.compile(r"^\s*(?:lit|litwork)(?![\w-])\s+(?P<task>.+?)\s*$", re.IGNORECASE | re.DOTALL)
|
|
33
39
|
MAX_TASK_LEN = 4000
|
|
34
40
|
_SLUG_PATTERN = re.compile(r"[^a-z0-9]+")
|
|
35
41
|
|
|
@@ -44,6 +50,12 @@ LITBURN_BANNER = "🔥 LITBURN IGNITED 🔥"
|
|
|
44
50
|
# the banner onto that turn's response before the user sees it. Consumed once.
|
|
45
51
|
_PENDING_IGNITE: set[str] = set()
|
|
46
52
|
|
|
53
|
+
# Session ids whose current turn must be user-visible BLOCKED. This is used for
|
|
54
|
+
# natural-language requests such as "lit start work": a pre-LLM hook can steer the
|
|
55
|
+
# model, but it cannot switch Hermes modes/agents. The transform hook makes the
|
|
56
|
+
# safe block visible instead of pretending a mode switch occurred.
|
|
57
|
+
_PENDING_BLOCK: dict[str, str] = {}
|
|
58
|
+
|
|
47
59
|
|
|
48
60
|
LIT_CONTEXT = "\n".join(
|
|
49
61
|
[
|
|
@@ -90,6 +102,15 @@ class CommandArgs:
|
|
|
90
102
|
options: dict[str, str | bool]
|
|
91
103
|
|
|
92
104
|
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class NaturalLitRoute:
|
|
107
|
+
mode: str
|
|
108
|
+
objective: str = ""
|
|
109
|
+
visible_message: str = ""
|
|
110
|
+
blocked: bool = False
|
|
111
|
+
block_message: str = ""
|
|
112
|
+
|
|
113
|
+
|
|
93
114
|
def slugify(text: str, fallback: str = "lithermes-plan") -> str:
|
|
94
115
|
lowered = text.strip().lower()
|
|
95
116
|
slug = _SLUG_PATTERN.sub("-", lowered).strip("-")
|
|
@@ -159,6 +180,7 @@ def event_log_path() -> Path:
|
|
|
159
180
|
|
|
160
181
|
|
|
161
182
|
def append_jsonl(path: Path, payload: dict[str, Any]) -> None:
|
|
183
|
+
payload = redact_obj(payload)
|
|
162
184
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
185
|
with path.open("a", encoding="utf-8") as handle:
|
|
164
186
|
handle.write(json.dumps(payload, sort_keys=True) + "\n")
|
|
@@ -212,12 +234,142 @@ def _clamp_task(task: str) -> str:
|
|
|
212
234
|
A pasted multi-thousand-char prompt would otherwise inflate the injected
|
|
213
235
|
LIT_CONTEXT and the persisted run-state. Clamp to MAX_TASK_LEN chars.
|
|
214
236
|
"""
|
|
215
|
-
task = task.strip()
|
|
237
|
+
task = redact_text(task).strip()
|
|
216
238
|
if len(task) > MAX_TASK_LEN:
|
|
217
239
|
return task[:MAX_TASK_LEN].rstrip() + " […]"
|
|
218
240
|
return task
|
|
219
241
|
|
|
220
242
|
|
|
243
|
+
_FENCED_CODE_RE = re.compile(r"(^|\n)(`{3,}|~{3,})[^\n]*\n.*?(?:\n\2(?=\n|$)|$)", re.DOTALL)
|
|
244
|
+
_INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
|
|
245
|
+
_INDENTED_CODE_LINE_RE = re.compile(r"(?m)^(?: {4,}|\t).*$")
|
|
246
|
+
_MODE_LEAD_RE = re.compile(r"^[\s:;,\-—–]+")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def strip_markdown_code(text: str) -> str:
|
|
250
|
+
"""Remove markdown code spans/fences before natural trigger parsing."""
|
|
251
|
+
without_fences = _FENCED_CODE_RE.sub("\n", str(text or ""))
|
|
252
|
+
without_indented = _INDENTED_CODE_LINE_RE.sub("", without_fences)
|
|
253
|
+
return _INLINE_CODE_RE.sub(" ", without_indented)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _after_mode_word(text: str, word: str) -> str | None:
|
|
257
|
+
m = re.match(rf"^\s*{re.escape(word)}(?![\w-])(?P<rest>.*)$", text, re.IGNORECASE | re.DOTALL)
|
|
258
|
+
if not m:
|
|
259
|
+
return None
|
|
260
|
+
return m.group("rest").strip()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _after_start_work(text: str) -> str | None:
|
|
264
|
+
m = re.match(r"^\s*start\s+work(?![\w-])(?P<rest>.*)$", text, re.IGNORECASE | re.DOTALL)
|
|
265
|
+
if not m:
|
|
266
|
+
return None
|
|
267
|
+
return m.group("rest").strip()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def detect_lit_mode(message: str) -> NaturalLitRoute | None:
|
|
271
|
+
"""Route a natural-language LitHermes activation to a native mode.
|
|
272
|
+
|
|
273
|
+
The parser intentionally ignores code spans/fences and only treats standalone
|
|
274
|
+
`lit`/`litwork` tokens as activations. A token embedded in `/lit`,
|
|
275
|
+
`/tmp/lit.sock`, `split`, `lit-review`, or `lit_loop` is ignored; path-like
|
|
276
|
+
slash tokens elsewhere remain ordinary task text.
|
|
277
|
+
"""
|
|
278
|
+
visible = strip_markdown_code(message)
|
|
279
|
+
for match in LIT_PATTERN.finditer(visible):
|
|
280
|
+
token = match.group(0).lower()
|
|
281
|
+
after = _MODE_LEAD_RE.sub(" ", visible[match.end():]).strip()
|
|
282
|
+
if token == "litwork":
|
|
283
|
+
return NaturalLitRoute(mode="litwork", objective=_clamp_task(after), visible_message=visible)
|
|
284
|
+
|
|
285
|
+
start_rest = _after_start_work(after)
|
|
286
|
+
if start_rest is not None:
|
|
287
|
+
block = (
|
|
288
|
+
"BLOCKED: natural-language `lit start work` cannot switch Hermes into the "
|
|
289
|
+
"native execution command. Invoke `/start-work <approved-plan>` explicitly "
|
|
290
|
+
"after `/lit-plan` has produced an approved plan. No run state was created."
|
|
291
|
+
)
|
|
292
|
+
return NaturalLitRoute(
|
|
293
|
+
mode="start-work",
|
|
294
|
+
objective=_clamp_task(start_rest),
|
|
295
|
+
visible_message=visible,
|
|
296
|
+
blocked=True,
|
|
297
|
+
block_message=block,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
for word, mode in (
|
|
301
|
+
("plan", "lit-plan"),
|
|
302
|
+
("review", "review-work"),
|
|
303
|
+
("research", "litresearch"),
|
|
304
|
+
("goal", "litgoal"),
|
|
305
|
+
):
|
|
306
|
+
rest = _after_mode_word(after, word)
|
|
307
|
+
if rest is not None:
|
|
308
|
+
return NaturalLitRoute(mode=mode, objective=_clamp_task(rest), visible_message=visible)
|
|
309
|
+
return NaturalLitRoute(mode="litwork", objective=_clamp_task(after), visible_message=visible)
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def build_natural_mode_context(route: NaturalLitRoute) -> str:
|
|
314
|
+
objective = route.objective or "(no objective text supplied — ask for the missing objective if needed)"
|
|
315
|
+
if route.blocked:
|
|
316
|
+
return "\n".join(
|
|
317
|
+
[
|
|
318
|
+
"<lithermes-natural-route mode=\"start-work\">",
|
|
319
|
+
route.block_message,
|
|
320
|
+
"Do not execute, edit, or create run state from this natural phrase.",
|
|
321
|
+
"Safe fallback: ask the user to invoke the native command `/start-work <approved-plan>`.",
|
|
322
|
+
"</lithermes-natural-route>",
|
|
323
|
+
]
|
|
324
|
+
)
|
|
325
|
+
if route.mode == "lit-plan":
|
|
326
|
+
return "\n".join(
|
|
327
|
+
[
|
|
328
|
+
"<lithermes-natural-route mode=\"lit-plan\">",
|
|
329
|
+
"Natural routing: standalone lit plan -> lithermes:lit-plan.",
|
|
330
|
+
f"Objective: {objective}",
|
|
331
|
+
"Mode Contract: planning-only. Do not implement, edit production code, run start-work, or claim execution is done.",
|
|
332
|
+
"Load lithermes:lit-plan, inspect first, create/fill a plan under plans/, and wait for explicit approval.",
|
|
333
|
+
"Durable state: use plans/ and .hermes/lithermes goal tools only; never foreign state roots.",
|
|
334
|
+
"</lithermes-natural-route>",
|
|
335
|
+
]
|
|
336
|
+
)
|
|
337
|
+
if route.mode == "review-work":
|
|
338
|
+
return "\n".join(
|
|
339
|
+
[
|
|
340
|
+
"<lithermes-natural-route mode=\"review-work\">",
|
|
341
|
+
"Natural routing: standalone lit review -> lithermes:review-work.",
|
|
342
|
+
f"Review target: {objective}",
|
|
343
|
+
"Mode Contract: verify only. Run the 5-lane review: goal/constraints, real-surface QA, code quality, security/safety, and context/docs/package readiness.",
|
|
344
|
+
"All lanes must pass; timeout, missing evidence, inconclusive output, or cleanup gaps block approval.",
|
|
345
|
+
"</lithermes-natural-route>",
|
|
346
|
+
]
|
|
347
|
+
)
|
|
348
|
+
if route.mode == "litresearch":
|
|
349
|
+
return "\n".join(
|
|
350
|
+
[
|
|
351
|
+
"<lithermes-natural-route mode=\"litresearch\">",
|
|
352
|
+
"Natural routing: standalone lit research -> lithermes:litresearch.",
|
|
353
|
+
f"Research demand: {objective}",
|
|
354
|
+
"Mode Contract: separate verified facts, hypotheses, sources, and uncertainty. Do not present uncited claims as facts.",
|
|
355
|
+
"Use Hermes-native delegate_task swarms when justified and keep any research journal under .hermes/lithermes/litresearch/<slug>/.",
|
|
356
|
+
"</lithermes-natural-route>",
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
if route.mode == "litgoal":
|
|
360
|
+
return "\n".join(
|
|
361
|
+
[
|
|
362
|
+
"<lithermes-natural-route mode=\"litgoal\">",
|
|
363
|
+
"Natural routing: standalone lit goal -> lithermes:litgoal.",
|
|
364
|
+
f"Objective: {objective}",
|
|
365
|
+
"Mode Contract: bind one objective plus checkable criteria. Use goal_set with happy/edge/regression criteria before work proceeds.",
|
|
366
|
+
"Durable state: .hermes/lithermes/litgoal/ via goal_* tools or `hermes lithermes goal status`.",
|
|
367
|
+
"</lithermes-natural-route>",
|
|
368
|
+
]
|
|
369
|
+
)
|
|
370
|
+
return LIT_CONTEXT
|
|
371
|
+
|
|
372
|
+
|
|
221
373
|
def _extract_run_context_task(message: str) -> str:
|
|
222
374
|
m = _RUN_CONTEXT_TASK_PATTERN.search(message)
|
|
223
375
|
return _clamp_task(m.group("task")) if m else ""
|
|
@@ -240,7 +392,7 @@ def _extract_bind_goal(message: str) -> str:
|
|
|
240
392
|
|
|
241
393
|
def bind_goal_marker(objective: str) -> str:
|
|
242
394
|
"""Render the bind-goal marker a command embeds so pre_llm_call binds /goal."""
|
|
243
|
-
objective = (objective
|
|
395
|
+
objective = _clamp_task(objective)
|
|
244
396
|
return f"<lithermes-bind-goal>{objective}</lithermes-bind-goal>" if objective else ""
|
|
245
397
|
|
|
246
398
|
|
|
@@ -268,6 +420,7 @@ def pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
|
|
|
268
420
|
# leak the banner onto this turn's response. Re-added below only if THIS turn
|
|
269
421
|
# is a keyword-lit turn — keeping the flag scoped to the current turn.
|
|
270
422
|
_PENDING_IGNITE.discard(session_id)
|
|
423
|
+
_PENDING_BLOCK.pop(session_id, None)
|
|
271
424
|
# /litgoal & /lit-plan declare an objective via the bind-goal marker. Bind it
|
|
272
425
|
# (we have session_id here) and stop — those messages are self-contained.
|
|
273
426
|
bind_obj = _extract_bind_goal(user_message)
|
|
@@ -281,12 +434,33 @@ def pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
|
|
|
281
434
|
if task:
|
|
282
435
|
bind_native_goal(session_id, task)
|
|
283
436
|
return None
|
|
284
|
-
|
|
437
|
+
route = detect_lit_mode(user_message)
|
|
438
|
+
if route is None:
|
|
285
439
|
return None
|
|
286
|
-
|
|
440
|
+
|
|
441
|
+
record_event(
|
|
442
|
+
"litwork_trigger",
|
|
443
|
+
session_id=session_id,
|
|
444
|
+
platform=str(kwargs.get("platform") or ""),
|
|
445
|
+
mode=route.mode,
|
|
446
|
+
)
|
|
447
|
+
if session_id:
|
|
448
|
+
_PENDING_IGNITE.add(session_id)
|
|
449
|
+
|
|
450
|
+
if route.blocked:
|
|
451
|
+
if session_id:
|
|
452
|
+
_PENDING_BLOCK[session_id] = route.block_message
|
|
453
|
+
return {"context": build_natural_mode_context(route)}
|
|
454
|
+
|
|
455
|
+
if route.mode != "litwork":
|
|
456
|
+
if route.mode in {"lit-plan", "litgoal"} and route.objective:
|
|
457
|
+
bind_native_goal(session_id, route.objective)
|
|
458
|
+
return {"context": build_natural_mode_context(route)}
|
|
459
|
+
|
|
460
|
+
direct = DIRECT_LIT_PATTERN.match(route.visible_message)
|
|
287
461
|
run_context = ""
|
|
288
462
|
if direct:
|
|
289
|
-
task = _clamp_task(direct.group("task"))
|
|
463
|
+
task = route.objective or _clamp_task(direct.group("task"))
|
|
290
464
|
if task:
|
|
291
465
|
bind_native_goal(session_id, task)
|
|
292
466
|
workspace = Path.cwd().resolve()
|
|
@@ -296,15 +470,6 @@ def pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
|
|
|
296
470
|
command="lit",
|
|
297
471
|
)
|
|
298
472
|
run_context = "\n\n" + build_run_agent_message(load_run_state(run_dir))
|
|
299
|
-
record_event(
|
|
300
|
-
"litwork_trigger",
|
|
301
|
-
session_id=session_id,
|
|
302
|
-
platform=str(kwargs.get("platform") or ""),
|
|
303
|
-
)
|
|
304
|
-
# Flag this turn so transform_llm_output forces the banner onto the response
|
|
305
|
-
# (the keyword path has no deterministic display channel like slash commands).
|
|
306
|
-
if session_id:
|
|
307
|
-
_PENDING_IGNITE.add(session_id)
|
|
308
473
|
return {"context": LIT_CONTEXT + run_context}
|
|
309
474
|
|
|
310
475
|
|
|
@@ -317,6 +482,10 @@ def transform_llm_output(**kwargs: Any) -> str | None:
|
|
|
317
482
|
a model that already opened with the banner is not double-bannered.
|
|
318
483
|
"""
|
|
319
484
|
session_id = str(kwargs.get("session_id") or "")
|
|
485
|
+
block = _PENDING_BLOCK.pop(session_id, "")
|
|
486
|
+
if block:
|
|
487
|
+
_PENDING_IGNITE.discard(session_id)
|
|
488
|
+
return f"{LITBURN_BANNER}\n\n{block}" if not block.startswith(LITBURN_BANNER) else block
|
|
320
489
|
if session_id not in _PENDING_IGNITE:
|
|
321
490
|
return None
|
|
322
491
|
_PENDING_IGNITE.discard(session_id)
|
|
@@ -348,7 +517,7 @@ def build_goal_instruction(
|
|
|
348
517
|
plan: Path | None = None,
|
|
349
518
|
workspace: Path | None = None,
|
|
350
519
|
) -> str:
|
|
351
|
-
objective = objective
|
|
520
|
+
objective = _clamp_task(objective) or "Complete the requested LitHermes task with evidence."
|
|
352
521
|
plan_line = f"Plan: {plan}" if plan else "Plan: none"
|
|
353
522
|
workspace_line = f"Workspace: {workspace}" if workspace else "Workspace: current"
|
|
354
523
|
return "\n".join(
|
|
@@ -372,7 +541,7 @@ def build_goal_instruction(
|
|
|
372
541
|
"- goal_steer to redirect, goal_checkpoint to snapshot; inspect via `hermes lithermes goal status`;",
|
|
373
542
|
"- goal_complete is REFUSED until every criterion has green + scenario evidence and no blocker is open.",
|
|
374
543
|
"",
|
|
375
|
-
"Isolation: for risky/parallel edits use a worktree
|
|
544
|
+
"Isolation: for risky/parallel edits use a git worktree or Hermes-native workspace isolation if available.",
|
|
376
545
|
"",
|
|
377
546
|
"Delegation model (you conduct, workers play):",
|
|
378
547
|
"- Use delegate_task(tasks:[{goal, context}]) to fan out INDEPENDENT work in parallel;",
|
|
@@ -392,7 +561,7 @@ def build_goal_instruction(
|
|
|
392
561
|
|
|
393
562
|
|
|
394
563
|
def create_plan(brief: str, workspace: Path | None = None) -> Path:
|
|
395
|
-
brief = brief
|
|
564
|
+
brief = _clamp_task(brief)
|
|
396
565
|
if not brief:
|
|
397
566
|
raise ValueError('usage: /lit-plan "what to build"')
|
|
398
567
|
|
|
@@ -483,7 +652,7 @@ def unchecked_items(markdown: str) -> list[str]:
|
|
|
483
652
|
for line in markdown.splitlines():
|
|
484
653
|
stripped = line.strip()
|
|
485
654
|
if stripped.startswith("- [ ] "):
|
|
486
|
-
items.append(stripped[6:].strip())
|
|
655
|
+
items.append(_clamp_task(stripped[6:].strip()))
|
|
487
656
|
return items
|
|
488
657
|
|
|
489
658
|
|
|
@@ -507,7 +676,7 @@ def extract_success_criteria(markdown: str) -> list[dict[str, str]]:
|
|
|
507
676
|
continue
|
|
508
677
|
key, _, value = field.partition(":")
|
|
509
678
|
key = key.strip().lower()
|
|
510
|
-
value = value
|
|
679
|
+
value = _clamp_task(value)
|
|
511
680
|
if key == "channel":
|
|
512
681
|
crit["qa_channel"] = value
|
|
513
682
|
elif key == "test":
|
|
@@ -520,7 +689,7 @@ def extract_success_criteria(markdown: str) -> list[dict[str, str]]:
|
|
|
520
689
|
|
|
521
690
|
def build_notepad(task: str, criteria: list[dict[str, str]]) -> str:
|
|
522
691
|
crit_lines = [
|
|
523
|
-
f"- {c['id']} [{c.get('qa_channel') or '?'}] {c.get('scenario') or ''} (test: {c.get('test_ref') or '?'})"
|
|
692
|
+
f"- {c['id']} [{_clamp_task(c.get('qa_channel') or '?')}] {_clamp_task(c.get('scenario') or '')} (test: {_clamp_task(c.get('test_ref') or '?')})"
|
|
524
693
|
for c in criteria
|
|
525
694
|
] or ["- (define success criteria before claiming progress)"]
|
|
526
695
|
return "\n".join(
|
|
@@ -598,6 +767,8 @@ def write_run_state(
|
|
|
598
767
|
completion_promise: str = "",
|
|
599
768
|
strategy: str = "continue",
|
|
600
769
|
) -> Path:
|
|
770
|
+
task = _clamp_task(task)
|
|
771
|
+
completion_promise = _clamp_task(completion_promise)
|
|
601
772
|
rid = run_id("lithermes")
|
|
602
773
|
run_dir = lithermes_dir(workspace) / "runs" / rid
|
|
603
774
|
evidence_dir = run_dir / "evidence"
|
|
@@ -606,7 +777,7 @@ def write_run_state(
|
|
|
606
777
|
criteria: list[dict[str, str]] = []
|
|
607
778
|
if plan and plan.exists():
|
|
608
779
|
try:
|
|
609
|
-
criteria = extract_success_criteria(plan.read_text(encoding="utf-8"))
|
|
780
|
+
criteria = redact_obj(extract_success_criteria(plan.read_text(encoding="utf-8")))
|
|
610
781
|
except OSError:
|
|
611
782
|
criteria = []
|
|
612
783
|
|
|
@@ -720,7 +891,7 @@ def build_plan_agent_message(brief: str, plan: Path, workspace: Path) -> str:
|
|
|
720
891
|
def command_lit_plan(raw_args: str) -> dict[str, str]:
|
|
721
892
|
args = parse_args(raw_args)
|
|
722
893
|
workspace = workspace_from_option(args.options.get("worktree"))
|
|
723
|
-
brief = _join_positional(args.positional)
|
|
894
|
+
brief = _clamp_task(_join_positional(args.positional))
|
|
724
895
|
path = create_plan(brief, workspace)
|
|
725
896
|
return {
|
|
726
897
|
"display": f"Created LitHermes plan: {path}\nForwarding goal bootstrap to Hermes agent now.",
|
|
@@ -732,10 +903,10 @@ def command_lit_plan(raw_args: str) -> dict[str, str]:
|
|
|
732
903
|
def _command_lit_dispatch(raw_args: str, *, command: str) -> dict[str, str]:
|
|
733
904
|
args = parse_args(raw_args)
|
|
734
905
|
workspace = workspace_from_option(args.options.get("worktree"))
|
|
735
|
-
task = _join_positional(args.positional)
|
|
906
|
+
task = _clamp_task(_join_positional(args.positional))
|
|
736
907
|
if not task:
|
|
737
908
|
raise ValueError('usage: /lit-loop "task" [--completion-promise TEXT] [--strategy reset|continue]')
|
|
738
|
-
completion = str(args.options.get("completion-promise") or "")
|
|
909
|
+
completion = _clamp_task(str(args.options.get("completion-promise") or ""))
|
|
739
910
|
strategy = str(args.options.get("strategy") or "continue")
|
|
740
911
|
if strategy not in {"continue", "reset"}:
|
|
741
912
|
raise ValueError("--strategy must be either 'continue' or 'reset'")
|
|
@@ -798,10 +969,10 @@ def detect_run_command(workspace: Path) -> str:
|
|
|
798
969
|
|
|
799
970
|
REVIEW_LANES = [
|
|
800
971
|
("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."),
|
|
801
|
-
("qa", "QA by execution — brainstorm 15+ scenarios (happy/boundary/error/regression), then actually run the app/surface and capture evidence. Verdict PASS/FAIL with per-scenario results."),
|
|
972
|
+
("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."),
|
|
802
973
|
("code-quality", "Code quality — staff-engineer review across correctness, patterns, naming, error handling, types, perf, tests, API design. Severity CRITICAL/MAJOR/MINOR/NITPICK. Verdict PASS/FAIL."),
|
|
803
|
-
("security", "Security (supplementary) — input validation, authz, secrets, data exposure, deps/CVEs, path/file ops. Severity CRITICAL/HIGH/MEDIUM/LOW. Verdict PASS/FAIL."),
|
|
804
|
-
("context", "Context
|
|
974
|
+
("security", "Security/safety (supplementary) — input validation, authz, secrets, data exposure, deps/CVEs, path/file ops, destructive actions. Severity CRITICAL/HIGH/MEDIUM/LOW. Verdict PASS/FAIL."),
|
|
975
|
+
("context", "Context/docs/package readiness — git history, issues/PRs, docs, changelog/release checklist, package dry-run/payload guard, cleanup receipts, TODO/warnings the diff may have missed. Verdict PASS/FAIL + discovered context."),
|
|
805
976
|
]
|
|
806
977
|
|
|
807
978
|
|
|
@@ -861,7 +1032,7 @@ def command_review_work(raw_args: str) -> dict[str, str]:
|
|
|
861
1032
|
def command_litgoal(raw_args: str) -> dict[str, str]:
|
|
862
1033
|
args = parse_args(raw_args)
|
|
863
1034
|
workspace = workspace_from_option(args.options.get("worktree"))
|
|
864
|
-
objective = _join_positional(args.positional)
|
|
1035
|
+
objective = _clamp_task(_join_positional(args.positional))
|
|
865
1036
|
intro = (
|
|
866
1037
|
"Opened the LitHermes litgoal durable runtime."
|
|
867
1038
|
if objective
|
|
@@ -942,21 +1113,16 @@ def command_start_work(raw_args: str) -> str | dict[str, str]:
|
|
|
942
1113
|
dry_run = bool(args.options.get("dry-run"))
|
|
943
1114
|
plan = find_plan(plan_name, workspace)
|
|
944
1115
|
|
|
945
|
-
# No-plan bootstrap: /start-work with a brief but no matching plan creates the
|
|
946
|
-
# plan first (treating start-work as approval to bootstrap), then proceeds.
|
|
947
|
-
bootstrapped = False
|
|
948
1116
|
if plan is None:
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
)
|
|
1117
|
+
target = plan_name or "(latest plan)"
|
|
1118
|
+
msg = (
|
|
1119
|
+
f"BLOCKED: /start-work is execution-only for approved plans. No plan named "
|
|
1120
|
+
f"'{target}' was found in {plan_dir(workspace)}. Run /lit-plan first, approve "
|
|
1121
|
+
"the plan, then invoke /start-work <plan-name>."
|
|
1122
|
+
)
|
|
953
1123
|
if dry_run:
|
|
954
|
-
return
|
|
955
|
-
|
|
956
|
-
f"would bootstrap a new plan from it in {plan_dir(workspace)}."
|
|
957
|
-
)
|
|
958
|
-
plan = create_plan(plan_name, workspace)
|
|
959
|
-
bootstrapped = True
|
|
1124
|
+
return msg
|
|
1125
|
+
raise ValueError(msg)
|
|
960
1126
|
|
|
961
1127
|
text = plan.read_text(encoding="utf-8")
|
|
962
1128
|
open_items = unchecked_items(text)
|
|
@@ -972,8 +1138,7 @@ def command_start_work(raw_args: str) -> str | dict[str, str]:
|
|
|
972
1138
|
)
|
|
973
1139
|
first_items = "\n".join(f"- {item}" for item in open_items[:5]) or "- no unchecked items found"
|
|
974
1140
|
display = (
|
|
975
|
-
f"
|
|
976
|
-
f"LitHermes work run: {run_dir}\n"
|
|
1141
|
+
f"Started LitHermes work run from approved plan: {run_dir}\n"
|
|
977
1142
|
f"Plan: {plan}\n"
|
|
978
1143
|
f"Open items:\n{first_items}"
|
|
979
1144
|
)
|
|
@@ -981,3 +1146,107 @@ def command_start_work(raw_args: str) -> str | dict[str, str]:
|
|
|
981
1146
|
"display": display,
|
|
982
1147
|
"agent_message": build_run_agent_message(load_run_state(run_dir)),
|
|
983
1148
|
}
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
# ---------------------------------------------------------------------------
|
|
1152
|
+
# Management / diagnostics surface (hermes lithermes version|status|doctor).
|
|
1153
|
+
# Hermes plugins otherwise can't answer "who am I and what version" — these
|
|
1154
|
+
# helpers back the CLI subcommands wired in __init__.py.
|
|
1155
|
+
# ---------------------------------------------------------------------------
|
|
1156
|
+
|
|
1157
|
+
PLUGIN_ROOT = Path(__file__).resolve().parent
|
|
1158
|
+
# These mirror what register()/register_hook()/litgoal tools wire up in __init__.py.
|
|
1159
|
+
# They are hardcoded (not introspected) so `status` works without a live ctx —
|
|
1160
|
+
# keep them in sync when adding a hook / slash command / goal tool.
|
|
1161
|
+
HOOKS = ("pre_llm_call", "subagent_stop", "transform_llm_output")
|
|
1162
|
+
SLASH_COMMANDS = (
|
|
1163
|
+
"lit", "lit-loop", "lit-plan", "litgoal", "review-work",
|
|
1164
|
+
"start-work", "deep-interview", "litwork-loop", "litwork-plan",
|
|
1165
|
+
)
|
|
1166
|
+
GOAL_TOOLS = (
|
|
1167
|
+
"goal_set", "goal_status", "goal_add_criterion", "goal_evidence",
|
|
1168
|
+
"goal_criterion_status", "goal_steer", "goal_checkpoint", "goal_complete",
|
|
1169
|
+
)
|
|
1170
|
+
_VERSION_RE = re.compile(r"^version:\s*(.+)$", re.MULTILINE)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def plugin_version() -> str:
|
|
1174
|
+
"""Read the LitHermes plugin version from the bundled plugin.yaml."""
|
|
1175
|
+
try:
|
|
1176
|
+
m = _VERSION_RE.search((PLUGIN_ROOT / "plugin.yaml").read_text(encoding="utf-8"))
|
|
1177
|
+
return m.group(1).strip().strip("\"'") if m else "unknown"
|
|
1178
|
+
except (OSError, ValueError):
|
|
1179
|
+
return "unknown"
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def hermes_host_version() -> str:
|
|
1183
|
+
"""Best-effort Hermes host runtime version (unknown outside Hermes)."""
|
|
1184
|
+
try:
|
|
1185
|
+
import hermes_cli
|
|
1186
|
+
return str(getattr(hermes_cli, "__version__", "") or "unknown")
|
|
1187
|
+
except Exception:
|
|
1188
|
+
return "unknown"
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def version_line() -> str:
|
|
1192
|
+
return f"lithermes {plugin_version()}"
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _evidence_kinds() -> tuple[str, ...]:
|
|
1196
|
+
try:
|
|
1197
|
+
from .litgoal import model
|
|
1198
|
+
return tuple(model.EVIDENCE_KINDS)
|
|
1199
|
+
except Exception:
|
|
1200
|
+
return ("red", "green", "scenario", "cleanup", "note")
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _skill_names() -> list[str]:
|
|
1204
|
+
skills_dir = PLUGIN_ROOT / "skills"
|
|
1205
|
+
if not skills_dir.is_dir():
|
|
1206
|
+
return []
|
|
1207
|
+
return sorted(d.name for d in skills_dir.iterdir() if (d / "SKILL.md").is_file())
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def status_report() -> str:
|
|
1211
|
+
"""One-glance health/identity line for `hermes lithermes status`."""
|
|
1212
|
+
skills = _skill_names()
|
|
1213
|
+
return "\n".join([
|
|
1214
|
+
f"LitHermes plugin {plugin_version()} (Hermes host {hermes_host_version()})",
|
|
1215
|
+
f"plugin dir: {PLUGIN_ROOT}",
|
|
1216
|
+
f"hooks ({len(HOOKS)}): {', '.join(HOOKS)}",
|
|
1217
|
+
f"slash commands ({len(SLASH_COMMANDS)}): {', '.join('/' + c for c in SLASH_COMMANDS)}",
|
|
1218
|
+
f"skills ({len(skills)}): {', '.join(skills)}",
|
|
1219
|
+
f"goal tools ({len(GOAL_TOOLS)}): {', '.join(GOAL_TOOLS)}",
|
|
1220
|
+
"litgoal state: .hermes/lithermes/litgoal/ (drive via: hermes lithermes goal status)",
|
|
1221
|
+
f"litgoal evidence kinds: {', '.join(_evidence_kinds())}",
|
|
1222
|
+
])
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def doctor_report() -> tuple[list[str], int]:
|
|
1226
|
+
"""Health checks for `hermes lithermes doctor`. Returns (lines, exit_code)."""
|
|
1227
|
+
lines: list[str] = []
|
|
1228
|
+
ok = True
|
|
1229
|
+
ver = plugin_version()
|
|
1230
|
+
if ver != "unknown":
|
|
1231
|
+
lines.append(f"[OK] plugin.yaml readable (version {ver})")
|
|
1232
|
+
else:
|
|
1233
|
+
lines.append("[WARN] plugin.yaml unreadable or missing a version field")
|
|
1234
|
+
ok = False
|
|
1235
|
+
skills = _skill_names()
|
|
1236
|
+
if skills:
|
|
1237
|
+
lines.append(f"[OK] skills bundled: {len(skills)}")
|
|
1238
|
+
else:
|
|
1239
|
+
lines.append("[WARN] no skills found under skills/")
|
|
1240
|
+
ok = False
|
|
1241
|
+
try:
|
|
1242
|
+
from .litgoal import runtime as _rt # noqa: F401
|
|
1243
|
+
lines.append("[OK] litgoal durable runtime importable")
|
|
1244
|
+
except Exception as exc:
|
|
1245
|
+
lines.append(f"[WARN] litgoal runtime import failed: {exc}")
|
|
1246
|
+
ok = False
|
|
1247
|
+
hv = hermes_host_version()
|
|
1248
|
+
if hv != "unknown":
|
|
1249
|
+
lines.append(f"[OK] Hermes host detected (v{hv})")
|
|
1250
|
+
else:
|
|
1251
|
+
lines.append("[NOTE] Hermes host version unknown (running outside Hermes?)")
|
|
1252
|
+
return lines, (0 if ok else 1)
|
|
@@ -15,6 +15,15 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
from . import model, store
|
|
17
17
|
|
|
18
|
+
try:
|
|
19
|
+
from ..redaction import redact_text
|
|
20
|
+
except (ImportError, ModuleNotFoundError): # standalone import fallback
|
|
21
|
+
try:
|
|
22
|
+
from redaction import redact_text # type: ignore
|
|
23
|
+
except (ImportError, ModuleNotFoundError):
|
|
24
|
+
def redact_text(value: str) -> str: # type: ignore
|
|
25
|
+
return str(value or "")
|
|
26
|
+
|
|
18
27
|
|
|
19
28
|
def _utc_now() -> str:
|
|
20
29
|
return datetime.now(timezone.utc).isoformat()
|
|
@@ -37,7 +46,7 @@ def create_goal(
|
|
|
37
46
|
title: str = "",
|
|
38
47
|
criteria: list[dict[str, Any]] | None = None,
|
|
39
48
|
) -> model.Goal:
|
|
40
|
-
objective = (objective
|
|
49
|
+
objective = redact_text(objective).strip()
|
|
41
50
|
if not objective:
|
|
42
51
|
raise ValueError("objective must be non-empty")
|
|
43
52
|
state = store.load_or_create(workspace)
|
|
@@ -47,9 +56,9 @@ def create_goal(
|
|
|
47
56
|
goal.criteria.append(
|
|
48
57
|
model.Criterion(
|
|
49
58
|
id=_next_id("C", [c.id for c in goal.criteria]),
|
|
50
|
-
scenario=str(spec.get("scenario", "")).strip(),
|
|
51
|
-
qa_channel=str(spec.get("qa_channel", "")).strip(),
|
|
52
|
-
test_ref=str(spec.get("test_ref", "")).strip(),
|
|
59
|
+
scenario=redact_text(str(spec.get("scenario", ""))).strip(),
|
|
60
|
+
qa_channel=redact_text(str(spec.get("qa_channel", ""))).strip(),
|
|
61
|
+
test_ref=redact_text(str(spec.get("test_ref", ""))).strip(),
|
|
53
62
|
)
|
|
54
63
|
)
|
|
55
64
|
state.goals.append(goal)
|
|
@@ -83,9 +92,9 @@ def add_criterion(
|
|
|
83
92
|
goal = _require_active(state)
|
|
84
93
|
crit = model.Criterion(
|
|
85
94
|
id=_next_id("C", [c.id for c in goal.criteria]),
|
|
86
|
-
scenario=scenario.strip(),
|
|
87
|
-
qa_channel=qa_channel.strip(),
|
|
88
|
-
test_ref=test_ref.strip(),
|
|
95
|
+
scenario=redact_text(scenario).strip(),
|
|
96
|
+
qa_channel=redact_text(qa_channel).strip(),
|
|
97
|
+
test_ref=redact_text(test_ref).strip(),
|
|
89
98
|
)
|
|
90
99
|
goal.criteria.append(crit)
|
|
91
100
|
store.save(workspace, state)
|
|
@@ -123,12 +132,12 @@ def add_evidence(
|
|
|
123
132
|
goal = _require_active(state)
|
|
124
133
|
for crit in goal.criteria:
|
|
125
134
|
if crit.id == criterion_id:
|
|
126
|
-
ev = model.Evidence(kind=kind, ref=ref, detail=detail, at=_utc_now())
|
|
135
|
+
ev = model.Evidence(kind=kind, ref=redact_text(ref), detail=redact_text(detail), at=_utc_now())
|
|
127
136
|
crit.evidence.append(ev)
|
|
128
137
|
store.save(workspace, state)
|
|
129
138
|
store.append_ledger(
|
|
130
139
|
workspace,
|
|
131
|
-
{"kind": "evidence_added", "criterion_id": criterion_id, "evidence_kind": kind, "ref": ref},
|
|
140
|
+
{"kind": "evidence_added", "criterion_id": criterion_id, "evidence_kind": kind, "ref": redact_text(ref)},
|
|
132
141
|
)
|
|
133
142
|
return ev
|
|
134
143
|
raise ValueError(f"criterion '{criterion_id}' not found")
|
|
@@ -142,7 +151,7 @@ def record_checkpoint(workspace: Path, summary: str, *, active_criterion: str =
|
|
|
142
151
|
cp = model.Checkpoint(
|
|
143
152
|
id=_next_id("K", [c.id for c in goal.checkpoints]),
|
|
144
153
|
at=_utc_now(),
|
|
145
|
-
summary=summary.strip(),
|
|
154
|
+
summary=redact_text(summary).strip(),
|
|
146
155
|
active_criterion=active_criterion.strip(),
|
|
147
156
|
)
|
|
148
157
|
goal.checkpoints.append(cp)
|
|
@@ -179,6 +188,7 @@ def _weakening_reason(directive: str) -> str | None:
|
|
|
179
188
|
def record_steering(workspace: Path, directive: str, *, kind: str = "redirect") -> model.Steering:
|
|
180
189
|
if kind not in model.STEERING_KINDS:
|
|
181
190
|
raise ValueError(f"invalid steering kind '{kind}' (valid: {model.STEERING_KINDS})")
|
|
191
|
+
directive = redact_text(directive)
|
|
182
192
|
reason = _weakening_reason(directive)
|
|
183
193
|
if reason is not None:
|
|
184
194
|
raise ValueError(
|
|
@@ -209,7 +219,7 @@ def add_review_blocker(workspace: Path, detail: str) -> model.ReviewBlocker:
|
|
|
209
219
|
goal = _require_active(state)
|
|
210
220
|
blocker = model.ReviewBlocker(
|
|
211
221
|
id=_next_id("B", [b.id for b in goal.review_blockers]),
|
|
212
|
-
detail=detail.strip(),
|
|
222
|
+
detail=redact_text(detail).strip(),
|
|
213
223
|
)
|
|
214
224
|
goal.review_blockers.append(blocker)
|
|
215
225
|
store.save(workspace, state)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import tempfile
|
|
8
|
+
from json import JSONDecodeError
|
|
8
9
|
from datetime import datetime, timezone
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any
|
|
@@ -20,6 +21,15 @@ except (ImportError, ModuleNotFoundError): # pragma: no cover - standalone impo
|
|
|
20
21
|
except (ImportError, ModuleNotFoundError):
|
|
21
22
|
LITGOAL_STATE_DIRNAME = "litgoal"
|
|
22
23
|
|
|
24
|
+
try:
|
|
25
|
+
from ..redaction import redact_obj
|
|
26
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover - standalone import fallback
|
|
27
|
+
try:
|
|
28
|
+
from redaction import redact_obj # type: ignore
|
|
29
|
+
except (ImportError, ModuleNotFoundError):
|
|
30
|
+
def redact_obj(value): # type: ignore
|
|
31
|
+
return value
|
|
32
|
+
|
|
23
33
|
|
|
24
34
|
def _utc_now() -> str:
|
|
25
35
|
return datetime.now(timezone.utc).isoformat()
|
|
@@ -48,7 +58,10 @@ def brief_path(workspace: Path) -> Path:
|
|
|
48
58
|
def load_or_create(workspace: Path) -> model.LitgoalState:
|
|
49
59
|
path = goals_path(workspace)
|
|
50
60
|
if path.exists():
|
|
51
|
-
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except JSONDecodeError as exc:
|
|
64
|
+
raise ValueError(f"malformed litgoal state at {path}: {exc}") from exc
|
|
52
65
|
return model.LitgoalState.from_dict(data)
|
|
53
66
|
return model.LitgoalState(created_at=_utc_now(), updated_at=_utc_now())
|
|
54
67
|
|
|
@@ -85,7 +98,7 @@ def save(workspace: Path, state: model.LitgoalState) -> None:
|
|
|
85
98
|
def append_ledger(workspace: Path, event: dict[str, Any]) -> None:
|
|
86
99
|
path = ledger_path(workspace)
|
|
87
100
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
-
entry = {"at": _utc_now(), **event}
|
|
101
|
+
entry = redact_obj({"at": _utc_now(), **event})
|
|
89
102
|
# NOTE: ledger appends are best-effort append-durable; no fsync here to keep
|
|
90
103
|
# high-frequency event writes cheap. Data loss on crash is limited to the
|
|
91
104
|
# last unflushed entry; goals.json (the source of truth) is fsync-durable.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"syncedAt": "2026-06-
|
|
2
|
+
"syncedAt": "2026-06-19T02:30:00.000Z",
|
|
3
3
|
"source": "source-reference",
|
|
4
|
-
"sourceHash": "
|
|
4
|
+
"sourceHash": "0a2b29742e4410128d26429945f694c77d47dc5a0d3f80a6bda8b1cbbb21200f",
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "NOTICE.md",
|
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "README.md",
|
|
12
|
-
"sha256": "
|
|
12
|
+
"sha256": "29a32fca9db9fd12a2a9e307e93f44ba2a8274fde19946011944958c1a1ebc6d"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "__init__.py",
|
|
16
|
-
"sha256": "
|
|
16
|
+
"sha256": "96b816aee730ea9a5e1fedbc283829240baffddecc37872e4f60bb05f420366c"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "core.py",
|
|
20
|
-
"sha256": "
|
|
20
|
+
"sha256": "d0689b196a2721c99c3c83a5e82482869da0d80e57a52c07f54d9268989c31b0"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "litgoal/__init__.py",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "litgoal/runtime.py",
|
|
40
|
-
"sha256": "
|
|
40
|
+
"sha256": "6876fa8fd59bb5da0378023a374fb7cdc0d68bf9814f87f47aa7415fc5437bb7"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "litgoal/store.py",
|
|
44
|
-
"sha256": "
|
|
44
|
+
"sha256": "bae3f5ab083a57ed433cbe27fa807e1ea703910836760a0ce0ed94563b656ca1"
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"path": "litgoal/tools.py",
|
|
@@ -49,7 +49,11 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "plugin.yaml",
|
|
52
|
-
"sha256": "
|
|
52
|
+
"sha256": "9d49a09370193484755d21941af9f6d977dfef780c7a8d6657c115ff643b0bbd"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "redaction.py",
|
|
56
|
+
"sha256": "eae670460e8006d04c06bc4e4ad9127dfcaf303de3a00b7e5453e2589ea55531"
|
|
53
57
|
},
|
|
54
58
|
{
|
|
55
59
|
"path": "skills/ai-slop-remover/SKILL.md",
|
|
@@ -153,7 +157,7 @@
|
|
|
153
157
|
},
|
|
154
158
|
{
|
|
155
159
|
"path": "skills/lit-plan/SKILL.md",
|
|
156
|
-
"sha256": "
|
|
160
|
+
"sha256": "5f00302bff604357c4448d43991af2daf800aa19b50ccbe74e46684e797b8fc3"
|
|
157
161
|
},
|
|
158
162
|
{
|
|
159
163
|
"path": "skills/litgoal/.gitkeep",
|
|
@@ -165,7 +169,7 @@
|
|
|
165
169
|
},
|
|
166
170
|
{
|
|
167
171
|
"path": "skills/litresearch/SKILL.md",
|
|
168
|
-
"sha256": "
|
|
172
|
+
"sha256": "363468a509f7743037b2f132171b7b1351d11c10680985a49cab1693855a0a20"
|
|
169
173
|
},
|
|
170
174
|
{
|
|
171
175
|
"path": "skills/litwork/SKILL.md",
|
|
@@ -573,7 +577,7 @@
|
|
|
573
577
|
},
|
|
574
578
|
{
|
|
575
579
|
"path": "skills/review-work/SKILL.md",
|
|
576
|
-
"sha256": "
|
|
580
|
+
"sha256": "4af425ec7924f1cd3d5fac633351f84d587cbaa68498d46daad9faf066c937ec"
|
|
577
581
|
},
|
|
578
582
|
{
|
|
579
583
|
"path": "skills/rules/SKILL.md",
|
|
@@ -581,7 +585,7 @@
|
|
|
581
585
|
},
|
|
582
586
|
{
|
|
583
587
|
"path": "skills/start-work/SKILL.md",
|
|
584
|
-
"sha256": "
|
|
588
|
+
"sha256": "9a5243b68236866943b59191fb378d848d6b4f480ace1457291db15c4c57e772"
|
|
585
589
|
},
|
|
586
590
|
{
|
|
587
591
|
"path": "skills/visual-qa/SKILL.md",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_KEY_VALUE_RE = re.compile(
|
|
8
|
+
r"(?i)(?<![A-Z0-9_.-])("
|
|
9
|
+
r"[\"']?[A-Z0-9_.-]*(?:api[_-]?key|access[_-]?token|secret[_-]?access[_-]?key|private[_-]?key|password|token|secret)[A-Z0-9_.-]*[\"']?"
|
|
10
|
+
r"\s*[=:]\s*[\"']?)([^\s,;\"'}]+)([\"']?)"
|
|
11
|
+
)
|
|
12
|
+
_SENSITIVE_KEY_FRAGMENTS = (
|
|
13
|
+
"apikey",
|
|
14
|
+
"accesstoken",
|
|
15
|
+
"secretaccesskey",
|
|
16
|
+
"privatekey",
|
|
17
|
+
"password",
|
|
18
|
+
"token",
|
|
19
|
+
"secret",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
23
|
+
(
|
|
24
|
+
re.compile(r"(?i)\b(authorization\s*:\s*bearer\s+)([^\s,;]+)"),
|
|
25
|
+
r"\1[REDACTED_SECRET]",
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
re.compile(r"\b(sk-[A-Za-z0-9_-]{8,})\b"),
|
|
29
|
+
"[REDACTED_SECRET]",
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
re.compile(r"\b((?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{8,})\b"),
|
|
33
|
+
"[REDACTED_SECRET]",
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
re.compile(r"\b(AKIA[0-9A-Z]{12,})\b"),
|
|
37
|
+
"[REDACTED_SECRET]",
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _redact_key_value(match: re.Match[str]) -> str:
|
|
43
|
+
key = match.group(1)
|
|
44
|
+
normalized = re.sub(r"[^a-z0-9]", "", key.lower())
|
|
45
|
+
if any(fragment in normalized for fragment in _SENSITIVE_KEY_FRAGMENTS):
|
|
46
|
+
return f"{key}[REDACTED_SECRET]{match.group(3)}"
|
|
47
|
+
return match.group(0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_text(value: str) -> str:
|
|
51
|
+
"""Best-effort redaction before user text is persisted or re-injected.
|
|
52
|
+
|
|
53
|
+
This is intentionally conservative and local: it catches common bearer-token,
|
|
54
|
+
key/value, OpenAI-style, GitHub-style, and AWS-style secrets without trying to
|
|
55
|
+
classify every high-entropy string as a secret.
|
|
56
|
+
"""
|
|
57
|
+
text = _KEY_VALUE_RE.sub(_redact_key_value, str(value or ""))
|
|
58
|
+
for pattern, replacement in _SECRET_PATTERNS:
|
|
59
|
+
text = pattern.sub(replacement, text)
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def redact_obj(value: Any) -> Any:
|
|
64
|
+
if isinstance(value, str):
|
|
65
|
+
return redact_text(value)
|
|
66
|
+
if isinstance(value, list):
|
|
67
|
+
return [redact_obj(item) for item in value]
|
|
68
|
+
if isinstance(value, tuple):
|
|
69
|
+
return tuple(redact_obj(item) for item in value)
|
|
70
|
+
if isinstance(value, dict):
|
|
71
|
+
return {key: redact_obj(item) for key, item in value.items()}
|
|
72
|
+
return value
|
|
@@ -18,14 +18,14 @@ description: Hermes-native planning consultant for /lit-plan — explore-first g
|
|
|
18
18
|
> `plans/<slug>.md` — this skill injects the consultant discipline that turns
|
|
19
19
|
> that template into a genuinely reasoned artifact.
|
|
20
20
|
|
|
21
|
-
This skill governs how Hermes behaves when `/lit-plan`
|
|
22
|
-
the **durable artifact** that
|
|
23
|
-
it with the same rigour
|
|
21
|
+
This skill governs how Hermes behaves when `/lit-plan` or natural-language
|
|
22
|
+
`lit plan` is invoked. The plan is the **durable artifact** that a later
|
|
23
|
+
`/start-work` execution run consumes — treat producing it with the same rigour
|
|
24
|
+
you would bring to execution.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
gets handed off is grounded, complete, and approved.
|
|
26
|
+
**Mode contract: planning-only.** Do not implement, edit production code, run
|
|
27
|
+
`/start-work`, or claim execution is done in this mode. Stop after the grounded,
|
|
28
|
+
reviewed plan is ready and wait for explicit approval to execute it.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -168,11 +168,10 @@ Then close with a literal gate line:
|
|
|
168
168
|
Ready to generate the plan. Please confirm (or steer) before I finalise.
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
conflicts. Log the skip as `[APPROVAL_GATE_SKIPPED: bootstrap flag + Trivial + no conflicts]`.
|
|
171
|
+
There is no bootstrap shortcut in planning mode. Even Trivial-tier plans must
|
|
172
|
+
clear this gate before Phase 4. `/start-work` is a separate execution-only mode
|
|
173
|
+
that consumes an already-approved plan; it must never be used to bypass planning
|
|
174
|
+
approval.
|
|
176
175
|
|
|
177
176
|
---
|
|
178
177
|
|
|
@@ -5,7 +5,7 @@ description: "Maximum-saturation LitHermes research orchestrator: decompose a re
|
|
|
5
5
|
|
|
6
6
|
# litresearch — maximum-saturation research orchestrator
|
|
7
7
|
|
|
8
|
-
The LitHermes maximum-saturation research orchestrator, built only on Hermes native surfaces. Decompose a research demand, fan out parallel retrieval swarms, recursively chase every lead until convergence, verify contested claims by running code or adversarial review, and synthesize a fully cited answer — journaling every wave to disk so the work survives compaction. Every mechanism maps to a real Hermes surface: the native `delegate_task` tool (a `tasks:[{goal, context, toolsets?, role?}]` batch for parallel fan-out, parent blocks until all children stop), web retrieval tools, a plain-text live lead tracker, and an on-disk `.lithermes/litresearch/<slug>/` session directory for the durable journal and cited synthesis.
|
|
8
|
+
The LitHermes maximum-saturation research orchestrator, built only on Hermes native surfaces. Decompose a research demand, fan out parallel retrieval swarms, recursively chase every lead until convergence, verify contested claims by running code or adversarial review, and synthesize a fully cited answer — journaling every wave to disk so the work survives compaction. Every mechanism maps to a real Hermes surface: the native `delegate_task` tool (a `tasks:[{goal, context, toolsets?, role?}]` batch for parallel fan-out, parent blocks until all children stop), web retrieval tools, a plain-text live lead tracker, and an on-disk `.hermes/lithermes/litresearch/<slug>/` session directory for the durable journal and cited synthesis.
|
|
9
9
|
|
|
10
10
|
## Role
|
|
11
11
|
|
|
@@ -51,10 +51,10 @@ Pick the tier before Phase 1 and record it in the research journal. Never hardco
|
|
|
51
51
|
4. Open a **durable on-disk session directory** alongside the plain-text tracker. The tracker is your fast live view; the on-disk files are your recovery point after compaction and the user's audit trail. Create a slug from the demand and make the directory:
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
mkdir -p .lithermes/litresearch/<slug>
|
|
54
|
+
mkdir -p .hermes/lithermes/litresearch/<slug>
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
`.lithermes/litresearch/<slug>/` is your `SESSION_DIR`. It is gitignore-friendly — keep it under `.lithermes/` so it stays out of commits. The parent (you) owns every file in it; research children are read-only and never write here. Maintain three kinds of file:
|
|
57
|
+
`.hermes/lithermes/litresearch/<slug>/` is your `SESSION_DIR`. It is repo-native and gitignore-friendly — keep it under `.hermes/lithermes/` so it stays out of commits and package payloads. The parent (you) owns every file in it; research children are read-only and never write here. Maintain three kinds of file:
|
|
58
58
|
|
|
59
59
|
- `wave-<N>-<kind>-<axis>.md` — your digest of each child return: key findings, sources with file:line or URL+version, and the child's `## EXPAND` markers copied verbatim.
|
|
60
60
|
- `expansion-log.md` — the lead ledger: per wave, the children spawned, the markers gained, and the leads opened and closed. This is the dedup memory so a closed lead never resurfaces.
|
|
@@ -211,7 +211,7 @@ Produce a standalone report only when the user requests one ("report", "document
|
|
|
211
211
|
| Open-ended web breadth (Exhaustive) | extra librarian + web-search/web-fetch `delegate_task` lanes |
|
|
212
212
|
| Adversarial verification | `delegate_task` child whose `goal` is to refute the claim |
|
|
213
213
|
| Live lead tracker | plain-text tracker in-session, mirrored to `expansion-log.md` |
|
|
214
|
-
| Durable journal / lead ledger / synthesis | on-disk `SESSION_DIR` = `.lithermes/litresearch/<slug>/` (`wave-*.md`, `expansion-log.md`, `verify-*.md`, `SYNTHESIS.md`) |
|
|
214
|
+
| Durable journal / lead ledger / synthesis | on-disk `SESSION_DIR` = `.hermes/lithermes/litresearch/<slug>/` (`wave-*.md`, `expansion-log.md`, `verify-*.md`, `SYNTHESIS.md`) |
|
|
215
215
|
|
|
216
216
|
## Stop Rules
|
|
217
217
|
|
|
@@ -20,9 +20,11 @@ the five lane briefs plus the gate contract. Then:
|
|
|
20
20
|
| `load_skills=[...]` | name the skills to load inside the child's `message` |
|
|
21
21
|
|
|
22
22
|
Lane → child mapping (dispatch all five in the single batch):
|
|
23
|
-
`goal` · `qa` · `code-quality` · `security` (supplementary) · `context`.
|
|
23
|
+
`goal` · `qa` · `code-quality` · `security` (supplementary) · `context/docs/package`.
|
|
24
24
|
|
|
25
25
|
Each child returns: `verdict` (PASS|FAIL), `confidence`, and findings with `file:line`.
|
|
26
|
+
The review must cover behavior, tests, docs/package readiness, security/safety,
|
|
27
|
+
and cleanup evidence; green tests without a real-surface probe are insufficient.
|
|
26
28
|
Aggregate and dedupe across lanes, then apply the **all-or-nothing gate**: any lane FAIL
|
|
27
29
|
⇒ **REVIEW FAILED** (list blocking issues by severity); all five PASS ⇒ **REVIEW PASSED**
|
|
28
30
|
(non-blocking suggestions only). Record the per-lane verdicts; the plugin's `subagent_stop`
|
|
@@ -559,4 +561,4 @@ Compile the final report in this format:
|
|
|
559
561
|
|
|
560
562
|
If FAILED - be specific. The user should know exactly what to fix and in what order. No vague "consider improving X" - state the problem, the file, and the fix.
|
|
561
563
|
|
|
562
|
-
If PASSED - keep it short. Highlight any non-blocking suggestions, but don't turn a passing review into a lecture.
|
|
564
|
+
If PASSED - keep it short. Highlight any non-blocking suggestions, but don't turn a passing review into a lecture.
|
|
@@ -18,10 +18,11 @@ description: Hermes-native plan executor for /start-work — resume from durable
|
|
|
18
18
|
> for a parallel batch, parent blocks until all children stop. No spawn_agent,
|
|
19
19
|
> no named-agent registry, no per-child model selection.
|
|
20
20
|
|
|
21
|
-
This skill governs all `/start-work` invocations in Hermes. It resolves
|
|
22
|
-
file (`plans/<slug>.md`), opens or resumes a durable run, and drives
|
|
23
|
-
top-level checkbox to completion through strict gates. The skill never
|
|
24
|
-
from scratch
|
|
21
|
+
This skill governs all `/start-work` invocations in Hermes. It resolves an
|
|
22
|
+
approved plan file (`plans/<slug>.md`), opens or resumes a durable run, and drives
|
|
23
|
+
every top-level checkbox to completion through strict gates. The skill never
|
|
24
|
+
plans from scratch, bootstraps a plan from a brief, or weakens the approval gate;
|
|
25
|
+
all recovery is from durable artifacts.
|
|
25
26
|
|
|
26
27
|
---
|
|
27
28
|
|
|
@@ -29,28 +30,20 @@ from scratch mid-run; all recovery is from durable artifacts.
|
|
|
29
30
|
|
|
30
31
|
The trigger is any of:
|
|
31
32
|
|
|
32
|
-
- `/start-work <slug>` — resolve `plans/<slug>.md`, open a new run.
|
|
33
|
+
- `/start-work <slug>` — resolve an approved `plans/<slug>.md`, open a new run.
|
|
33
34
|
- `/start-work <slug> --resume` — locate the most recent run for `<slug>` under
|
|
34
35
|
`.hermes/lithermes/runs/` and resume from where `state.json` says.
|
|
35
|
-
- `/start-work` with no
|
|
36
|
-
|
|
36
|
+
- `/start-work` with no matching plan — **BLOCKED**. Run `/lit-plan` first, get
|
|
37
|
+
approval, then invoke `/start-work <plan>`.
|
|
37
38
|
|
|
38
39
|
---
|
|
39
40
|
|
|
40
|
-
## §0 —
|
|
41
|
+
## §0 — Approved plan required
|
|
41
42
|
|
|
42
|
-
If `/start-work` was given a brief
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
- A one-line **Goal** heading.
|
|
47
|
-
- A **Tasks** section: one `- [ ] T-NNN | <imperative verb phrase>` line per
|
|
48
|
-
deliverable, ordered by dependency.
|
|
49
|
-
- A **Success Criteria** section: one machine-parseable row per criterion:
|
|
50
|
-
`- [ ] C-NNN | channel: <http|tmux|browser|computer> | test: <file::id> | scenario: <one-line>`
|
|
51
|
-
3. Then proceed to §1 as if the plan existed from the start.
|
|
52
|
-
|
|
53
|
-
The brief is the contract. Do not expand scope beyond it.
|
|
43
|
+
If `/start-work` was given a brief or a slug that does not resolve to an existing
|
|
44
|
+
plan, stop with `BLOCKED`. Do not create a plan here. Planning belongs to
|
|
45
|
+
`/lit-plan` / natural `lit plan`; execution begins only after the user approves a
|
|
46
|
+
real plan artifact.
|
|
54
47
|
|
|
55
48
|
---
|
|
56
49
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lithermes-ai",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"description": "npx/bunx installer for the LitHermes Hermes plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"assets",
|
|
23
23
|
"!assets/**/__pycache__/**",
|
|
24
24
|
"!assets/**/*.pyc",
|
|
25
|
+
"!assets/**/.lit[o]pencode/**",
|
|
25
26
|
"!assets/**/upstream/**",
|
|
26
27
|
"README.md",
|
|
27
28
|
"README_Ko-KR.md",
|