lithermes-ai 0.8.5 → 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 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
- - `start-work`: open or dry-run a LitHermes plan inside Hermes.
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
- - `start-work`: LitHermes plan Hermes 작업으로 엽니다.
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 a plan against a workspace.
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`. Hermes has no model-facing goal tools, so a direct
13
- `lit <task>` (and the /lit command) binds the native standing `/goal` via the
14
- session goal manager; criteria + evidence use the durable `goal_*` tools.
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
@@ -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") and
29
- # NOT as a hyphen/underscore compound ("lit-review", "lit_loop"). This keeps
30
- # "…진행해줘 lit" (trailing token) firing while excluding command-like compounds.
31
- LIT_PATTERN = re.compile(r"(?<![\w-])(?:litwork|lit)(?![\w-])", re.IGNORECASE)
32
- DIRECT_LIT_PATTERN = re.compile(r"^\s*(?:lit|litwork)\b\s+(?P<task>.+?)\s*$", re.IGNORECASE | re.DOTALL)
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 or "").strip()
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
- if not LIT_PATTERN.search(user_message):
437
+ route = detect_lit_mode(user_message)
438
+ if route is None:
285
439
  return None
286
- direct = DIRECT_LIT_PATTERN.match(user_message)
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.strip() or "Complete the requested LitHermes task with evidence."
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 (EnterWorktree, or `claude --worktree <name> --tmux`).",
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.strip()
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.strip()
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 mining — git history, issues/PRs, related systems, developer TODO/warnings the diff may have missed. Verdict PASS/FAIL + discovered 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
- if not plan_name:
950
- raise ValueError(
951
- f"no plan found in {plan_dir(workspace)} and no brief given to bootstrap one"
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
- f"LitHermes dry-run: no plan named '{plan_name}' found; "
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"{'Bootstrapped a new plan and started' if bootstrapped else 'Started'} "
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
  )
@@ -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 or "").strip()
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
- data = json.loads(path.read_text(encoding="utf-8"))
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-15T16:35:55.443Z",
2
+ "syncedAt": "2026-06-19T02:30:00.000Z",
3
3
  "source": "source-reference",
4
- "sourceHash": "c93c37881e1f6f5730a1adc6c0e62e4dfab1ca44146eed900b8468448438fe8a",
4
+ "sourceHash": "0a2b29742e4410128d26429945f694c77d47dc5a0d3f80a6bda8b1cbbb21200f",
5
5
  "files": [
6
6
  {
7
7
  "path": "NOTICE.md",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  {
11
11
  "path": "README.md",
12
- "sha256": "29f9157e4aa5a667c0d4c2df30d803c4eaa8cc4b30937c84ac1a08b8257e1eca"
12
+ "sha256": "29a32fca9db9fd12a2a9e307e93f44ba2a8274fde19946011944958c1a1ebc6d"
13
13
  },
14
14
  {
15
15
  "path": "__init__.py",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  {
19
19
  "path": "core.py",
20
- "sha256": "70ddccfb4cc2fe1a923a5a61244e5cb36ef8ac0f94d34a76703fa6d82dbabf3f"
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": "65738e1ab77ef0725c4a431886ea6d30f03abddacd968e544d9b212382900a52"
40
+ "sha256": "6876fa8fd59bb5da0378023a374fb7cdc0d68bf9814f87f47aa7415fc5437bb7"
41
41
  },
42
42
  {
43
43
  "path": "litgoal/store.py",
44
- "sha256": "8f5f78fa78e7da2848c76ccc7e512d1a971b483b5de1c99cc79cc053354162b4"
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": "7761c417acfcd614e8d434b3ca11c498f48422a72f5b70f817a59326aca51b60"
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": "1f09901f9bb8f19b92add59c3613231228c8b5b7adbefae1cb3eb5cf7c3c61dc"
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": "3ff390e7a5847aebfa8943fe593fa386f9aeab2716fb30f7b20feaa3990f311f"
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": "1e30211324dfc09406db4cd9913bc9fe8c3d4d29407dd3d8a37392ddaf8ff06d"
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": "194a1d719c00564959da99715c86424a5ecd76196a4db9ca60e64be062dc70b5"
588
+ "sha256": "9a5243b68236866943b59191fb378d848d6b4f480ace1457291db15c4c57e772"
585
589
  },
586
590
  {
587
591
  "path": "skills/visual-qa/SKILL.md",
@@ -1,5 +1,5 @@
1
1
  name: lithermes
2
- version: 0.8.5
2
+ version: 0.8.6
3
3
  description: "Hermes-native workflow toolkit: litgoal durable runtime, 5-lane review orchestrator, Litwork commands, skills, and prompt steering."
4
4
  author: "Hermes Agent"
5
5
  kind: standalone
@@ -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` is invoked. The plan is
22
- the **durable artifact** that the subsequent goal loop executes — treat producing
23
- it with the same rigour you would bring to execution.
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
- LitHermes intentionally fuses planning with execution: `/lit-plan` bootstraps
26
- the goal and hands off to the execution loop. Do NOT impose a hard
27
- "planner never implements" rule here. The skill's job is to ensure that what
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
- **Narrow exception**: if this planning turn was triggered by a `/lit-plan
172
- --bootstrap` flag or an equivalent start-work invocation that is meant to get
173
- execution started immediately, you may proceed to Phase 4 without waiting but
174
- only when the brief is unambiguous, Trivial tier, and exploration found no
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 a plan
22
- file (`plans/<slug>.md`), opens or resumes a durable run, and drives every
23
- top-level checkbox to completion through strict gates. The skill never re-plans
24
- from scratch mid-run; all recovery is from durable artifacts.
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 slug but a one-liner brief supplied bootstrap a plan
36
- first (see §0 below), then open the run.
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 — No-plan bootstrap (only when no plan file exists)
41
+ ## §0 — Approved plan required
41
42
 
42
- If `/start-work` was given a brief but `plans/<slug>.md` does not yet exist:
43
-
44
- 1. Derive a slug from the brief (kebab-case, 40 chars).
45
- 2. Write `plans/<slug>.md` with:
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.5",
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",