lithermes-ai 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/README_Ko-KR.md +245 -0
  4. package/assets/lithermes-plugin/NOTICE.md +37 -0
  5. package/assets/lithermes-plugin/README.md +40 -0
  6. package/assets/lithermes-plugin/__init__.py +179 -0
  7. package/assets/lithermes-plugin/core.py +853 -0
  8. package/assets/lithermes-plugin/litgoal/__init__.py +10 -0
  9. package/assets/lithermes-plugin/litgoal/cli.py +133 -0
  10. package/assets/lithermes-plugin/litgoal/hook.py +48 -0
  11. package/assets/lithermes-plugin/litgoal/model.py +171 -0
  12. package/assets/lithermes-plugin/litgoal/runtime.py +273 -0
  13. package/assets/lithermes-plugin/litgoal/store.py +93 -0
  14. package/assets/lithermes-plugin/litgoal/tools.py +228 -0
  15. package/assets/lithermes-plugin/payload-version.json +471 -0
  16. package/assets/lithermes-plugin/plugin.yaml +9 -0
  17. package/assets/lithermes-plugin/skills/ai-slop-remover/SKILL.md +142 -0
  18. package/assets/lithermes-plugin/skills/comment-checker/SKILL.md +50 -0
  19. package/assets/lithermes-plugin/skills/debugging/SKILL.md +116 -0
  20. package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +108 -0
  21. package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +121 -0
  22. package/assets/lithermes-plugin/skills/debugging/references/methodology/04-oracle-triple.md +136 -0
  23. package/assets/lithermes-plugin/skills/debugging/references/methodology/05-escalate.md +69 -0
  24. package/assets/lithermes-plugin/skills/debugging/references/methodology/06-fix.md +116 -0
  25. package/assets/lithermes-plugin/skills/debugging/references/methodology/08-qa.md +94 -0
  26. package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +164 -0
  27. package/assets/lithermes-plugin/skills/debugging/references/methodology/partial-runtime-evidence.md +229 -0
  28. package/assets/lithermes-plugin/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
  29. package/assets/lithermes-plugin/skills/debugging/references/runtimes/go.md +252 -0
  30. package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +484 -0
  31. package/assets/lithermes-plugin/skills/debugging/references/runtimes/node.md +260 -0
  32. package/assets/lithermes-plugin/skills/debugging/references/runtimes/python.md +248 -0
  33. package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +234 -0
  34. package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +212 -0
  35. package/assets/lithermes-plugin/skills/debugging/references/tools/playwright-cli.md +194 -0
  36. package/assets/lithermes-plugin/skills/debugging/references/tools/pwndbg.md +263 -0
  37. package/assets/lithermes-plugin/skills/debugging/references/tools/pwntools.md +265 -0
  38. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +77 -0
  39. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +374 -0
  40. package/assets/lithermes-plugin/skills/litgoal/.gitkeep +0 -0
  41. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +207 -0
  42. package/assets/lithermes-plugin/skills/litwork/SKILL.md +262 -0
  43. package/assets/lithermes-plugin/skills/lsp/SKILL.md +53 -0
  44. package/assets/lithermes-plugin/skills/programming/SKILL.md +463 -0
  45. package/assets/lithermes-plugin/skills/programming/references/go/README.md +90 -0
  46. package/assets/lithermes-plugin/skills/programming/references/go/backend-stack.md +641 -0
  47. package/assets/lithermes-plugin/skills/programming/references/go/bootstrap.md +328 -0
  48. package/assets/lithermes-plugin/skills/programming/references/go/bubbletea-v2.md +360 -0
  49. package/assets/lithermes-plugin/skills/programming/references/go/cobra-stack.md +468 -0
  50. package/assets/lithermes-plugin/skills/programming/references/go/concurrency.md +362 -0
  51. package/assets/lithermes-plugin/skills/programming/references/go/data-modeling.md +329 -0
  52. package/assets/lithermes-plugin/skills/programming/references/go/error-handling.md +359 -0
  53. package/assets/lithermes-plugin/skills/programming/references/go/golangci-strict.md +236 -0
  54. package/assets/lithermes-plugin/skills/programming/references/go/grpc-connect.md +375 -0
  55. package/assets/lithermes-plugin/skills/programming/references/go/libraries.md +337 -0
  56. package/assets/lithermes-plugin/skills/programming/references/go/one-liners.md +202 -0
  57. package/assets/lithermes-plugin/skills/programming/references/go/sqlc-pgx.md +471 -0
  58. package/assets/lithermes-plugin/skills/programming/references/go/testing.md +467 -0
  59. package/assets/lithermes-plugin/skills/programming/references/go/type-patterns.md +298 -0
  60. package/assets/lithermes-plugin/skills/programming/references/python/README.md +314 -0
  61. package/assets/lithermes-plugin/skills/programming/references/python/async-anyio.md +442 -0
  62. package/assets/lithermes-plugin/skills/programming/references/python/data-modeling.md +233 -0
  63. package/assets/lithermes-plugin/skills/programming/references/python/data-processing.md +133 -0
  64. package/assets/lithermes-plugin/skills/programming/references/python/error-handling.md +218 -0
  65. package/assets/lithermes-plugin/skills/programming/references/python/fastapi-stack.md +316 -0
  66. package/assets/lithermes-plugin/skills/programming/references/python/httpx2-optimization.md +360 -0
  67. package/assets/lithermes-plugin/skills/programming/references/python/libraries.md +307 -0
  68. package/assets/lithermes-plugin/skills/programming/references/python/one-liners.md +268 -0
  69. package/assets/lithermes-plugin/skills/programming/references/python/orjson-stack.md +378 -0
  70. package/assets/lithermes-plugin/skills/programming/references/python/pydantic-ai.md +285 -0
  71. package/assets/lithermes-plugin/skills/programming/references/python/pyproject-strict.md +232 -0
  72. package/assets/lithermes-plugin/skills/programming/references/python/textual-tui.md +201 -0
  73. package/assets/lithermes-plugin/skills/programming/references/python/type-patterns.md +176 -0
  74. package/assets/lithermes-plugin/skills/programming/references/rust/README.md +317 -0
  75. package/assets/lithermes-plugin/skills/programming/references/rust/async-tokio.md +299 -0
  76. package/assets/lithermes-plugin/skills/programming/references/rust/axum-stack.md +467 -0
  77. package/assets/lithermes-plugin/skills/programming/references/rust/cargo-strict.md +317 -0
  78. package/assets/lithermes-plugin/skills/programming/references/rust/clap-stack.md +409 -0
  79. package/assets/lithermes-plugin/skills/programming/references/rust/concurrency.md +375 -0
  80. package/assets/lithermes-plugin/skills/programming/references/rust/libraries.md +439 -0
  81. package/assets/lithermes-plugin/skills/programming/references/rust/one-liners.md +291 -0
  82. package/assets/lithermes-plugin/skills/programming/references/rust/proptest-insta.md +429 -0
  83. package/assets/lithermes-plugin/skills/programming/references/rust/type-state.md +354 -0
  84. package/assets/lithermes-plugin/skills/programming/references/rust/unsafe-discipline.md +250 -0
  85. package/assets/lithermes-plugin/skills/programming/references/rust/zero-cost-safety.md +527 -0
  86. package/assets/lithermes-plugin/skills/programming/references/rust-ub/README.md +289 -0
  87. package/assets/lithermes-plugin/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
  88. package/assets/lithermes-plugin/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
  89. package/assets/lithermes-plugin/skills/programming/references/typescript/README.md +195 -0
  90. package/assets/lithermes-plugin/skills/programming/references/typescript/backend-hono.md +672 -0
  91. package/assets/lithermes-plugin/skills/programming/references/typescript/bootstrap.md +199 -0
  92. package/assets/lithermes-plugin/skills/programming/references/typescript/data-modeling.md +202 -0
  93. package/assets/lithermes-plugin/skills/programming/references/typescript/error-handling.md +169 -0
  94. package/assets/lithermes-plugin/skills/programming/references/typescript/tsconfig-strict.md +152 -0
  95. package/assets/lithermes-plugin/skills/programming/references/typescript/type-patterns.md +196 -0
  96. package/assets/lithermes-plugin/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
  97. package/assets/lithermes-plugin/skills/programming/scripts/go/new-project.py +138 -0
  98. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/.editorconfig +13 -0
  99. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/.golangci.yml +95 -0
  100. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
  101. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
  102. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
  103. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/ci.yml +37 -0
  104. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/config.go +24 -0
  105. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/gitignore +15 -0
  106. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
  107. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/run.go +15 -0
  108. package/assets/lithermes-plugin/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
  109. package/assets/lithermes-plugin/skills/programming/scripts/python/new-project.py +172 -0
  110. package/assets/lithermes-plugin/skills/programming/scripts/python/new-script.py +116 -0
  111. package/assets/lithermes-plugin/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
  112. package/assets/lithermes-plugin/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
  113. package/assets/lithermes-plugin/skills/programming/scripts/rust/new-project.py +175 -0
  114. package/assets/lithermes-plugin/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
  115. package/assets/lithermes-plugin/skills/programming/scripts/typescript/new-project.ts +177 -0
  116. package/assets/lithermes-plugin/skills/refactor/SKILL.md +770 -0
  117. package/assets/lithermes-plugin/skills/remove-ai-slops/SKILL.md +335 -0
  118. package/assets/lithermes-plugin/skills/review-work/SKILL.md +562 -0
  119. package/assets/lithermes-plugin/skills/rules/SKILL.md +41 -0
  120. package/assets/lithermes-plugin/skills/start-work/SKILL.md +332 -0
  121. package/bin/lithermes.js +8 -0
  122. package/cover.png +0 -0
  123. package/package.json +39 -0
  124. package/src/cli.js +129 -0
  125. package/src/lib/check.js +94 -0
  126. package/src/lib/config.js +170 -0
  127. package/src/lib/files.js +65 -0
  128. package/src/lib/hermesDiscovery.js +50 -0
  129. package/src/lib/hud.js +121 -0
  130. package/src/lib/install.js +159 -0
  131. package/src/lib/patch.js +153 -0
  132. package/src/lib/skins.js +113 -0
  133. package/src/lib/spinner.js +104 -0
@@ -0,0 +1,853 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import importlib
5
+ import re
6
+ import shlex
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Iterable
12
+
13
+ try:
14
+ get_hermes_home = importlib.import_module("hermes_constants").get_hermes_home
15
+ except Exception:
16
+ import os
17
+
18
+ def get_hermes_home() -> Path:
19
+ val = (os.environ.get("HERMES_HOME") or "").strip()
20
+ return Path(val).expanduser() if val else Path.home() / ".hermes"
21
+
22
+
23
+ # Durable litgoal runtime state lives under <workspace>/.hermes/lithermes/<dir>.
24
+ # This constant anchors every litgoal state path (goals/ledger/evidence).
25
+ LITGOAL_STATE_DIRNAME = "litgoal"
26
+
27
+ # 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
+ MAX_TASK_LEN = 4000
34
+ _SLUG_PATTERN = re.compile(r"[^a-z0-9]+")
35
+
36
+ LIT_CONTEXT = "\n".join(
37
+ [
38
+ "<lithermes-litwork>",
39
+ "The user invoked Litwork/LitHermes. Operate in a durable, evidence-first loop:",
40
+ "- restate the concrete completion promise before changing files;",
41
+ "- keep the implementation scoped to the current repository and existing Hermes patterns;",
42
+ "- use focused tests and manual verification evidence before claiming done;",
43
+ "- preserve unrelated user changes and avoid destructive git commands;",
44
+ "- keep local state, plans, and evidence under plans/ or .hermes/lithermes when useful.",
45
+ "",
46
+ "<lithermes-loop-discipline>",
47
+ "Per success criterion, loop: PIN -> RED -> GREEN -> VERIFY -> SURFACE -> CLEAN -> RECORD.",
48
+ "- RED: write the failing test FIRST; capture the assertion message proving it fails for the right reason.",
49
+ "- GREEN: smallest change to flip RED->GREEN; capture the passing output.",
50
+ "- SURFACE (manual QA): actually run ONE channel scenario end-to-end and capture the artifact path:",
51
+ " HTTP (`curl -i` / APIRequestContext), tmux (`tmux new-session`/`send-keys`/`capture-pane`),",
52
+ " browser use, or computer use. TESTS ALONE NEVER PROVE DONE; `--dry-run`/'looks correct' never count.",
53
+ "- CLEAN (paired, never skip): tear down every artifact the QA spawned (kill PIDs, `tmux kill-session`,",
54
+ " free ports, `rm -rf` temp dirs) and append a one-line cleanup receipt next to the artifact, e.g.",
55
+ " `cleanup: killed 12345; tmux kill-session lit-qa-foo; rm -rf /tmp/lit.aB12`. No receipt => criterion stays open.",
56
+ "- Reviewer gate (triggered): on 3+ files OR 20+ turns OR refactor/migration/security or an explicit",
57
+ " 'strictly/rigorously/엄밀' request, delegate_task a strict reviewer; treat the verdict as binding;",
58
+ " loop until UNCONDITIONAL approval ('looks good but...' = rejection).",
59
+ "</lithermes-loop-discipline>",
60
+ "",
61
+ "<lithermes-goal-bootstrap>",
62
+ "Goal binding in Hermes:",
63
+ "- Hermes has no model-facing goal tools; do not invoke get_goal/create_goal/update_goal.",
64
+ "- LitHermes binds the native standing /goal for you (session goal manager) so it persists.",
65
+ "- Track success criteria + evidence with the durable lithermes goal tools (goal_set,",
66
+ " goal_add_criterion, goal_evidence, goal_criterion_status, goal_complete) and inspect",
67
+ " with `hermes lithermes goal status`. The native evidence-judge decides completion.",
68
+ "</lithermes-goal-bootstrap>",
69
+ "</lithermes-litwork>",
70
+ ]
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class CommandArgs:
76
+ positional: list[str]
77
+ options: dict[str, str | bool]
78
+
79
+
80
+ def slugify(text: str, fallback: str = "lithermes-plan") -> str:
81
+ lowered = text.strip().lower()
82
+ slug = _SLUG_PATTERN.sub("-", lowered).strip("-")
83
+ return (slug[:60].strip("-") or fallback)
84
+
85
+
86
+ def utc_now() -> datetime:
87
+ return datetime.now(timezone.utc)
88
+
89
+
90
+ def run_id(prefix: str = "run") -> str:
91
+ return f"{prefix}-{utc_now().strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
92
+
93
+
94
+ def parse_args(raw_args: str) -> CommandArgs:
95
+ try:
96
+ tokens = shlex.split(raw_args or "")
97
+ except ValueError as exc:
98
+ raise ValueError(f"could not parse arguments: {exc}") from exc
99
+
100
+ positional: list[str] = []
101
+ options: dict[str, str | bool] = {}
102
+ i = 0
103
+ while i < len(tokens):
104
+ token = tokens[i]
105
+ if not token.startswith("--"):
106
+ positional.append(token)
107
+ i += 1
108
+ continue
109
+
110
+ key_value = token[2:]
111
+ if not key_value:
112
+ i += 1
113
+ continue
114
+ if "=" in key_value:
115
+ key, value = key_value.split("=", 1)
116
+ options[key] = value
117
+ i += 1
118
+ continue
119
+ key = key_value
120
+ if i + 1 < len(tokens) and not tokens[i + 1].startswith("--"):
121
+ options[key] = tokens[i + 1]
122
+ i += 2
123
+ else:
124
+ options[key] = True
125
+ i += 1
126
+
127
+ return CommandArgs(positional=positional, options=options)
128
+
129
+
130
+ def workspace_from_option(value: str | bool | None) -> Path:
131
+ if isinstance(value, str) and value.strip():
132
+ return Path(value).expanduser().resolve()
133
+ return Path.cwd().resolve()
134
+
135
+
136
+ def plan_dir(workspace: Path) -> Path:
137
+ return workspace / "plans"
138
+
139
+
140
+ def lithermes_dir(workspace: Path) -> Path:
141
+ return workspace / ".hermes" / "lithermes"
142
+
143
+
144
+ def event_log_path() -> Path:
145
+ return get_hermes_home() / "lithermes" / "events.jsonl"
146
+
147
+
148
+ def append_jsonl(path: Path, payload: dict[str, Any]) -> None:
149
+ path.parent.mkdir(parents=True, exist_ok=True)
150
+ with path.open("a", encoding="utf-8") as handle:
151
+ handle.write(json.dumps(payload, sort_keys=True) + "\n")
152
+
153
+
154
+ def record_event(event: str, **fields: Any) -> None:
155
+ payload = {
156
+ "event": event,
157
+ "timestamp": utc_now().isoformat(),
158
+ **fields,
159
+ }
160
+ try:
161
+ append_jsonl(event_log_path(), payload)
162
+ except OSError:
163
+ pass
164
+
165
+
166
+ _RUN_CONTEXT_TASK_PATTERN = re.compile(r"^task:\s*(?P<task>.+?)\s*$", re.MULTILINE)
167
+
168
+
169
+ def bind_native_goal(session_id: str, goal_text: str) -> bool:
170
+ """Bind the NATIVE Hermes goal via the session GoalManager.
171
+
172
+ Hermes has no model-facing goal tools — goals are set through the `/goal`
173
+ command + hermes_cli.goals.GoalManager. We set it programmatically so a
174
+ LitHermes Litwork run makes the standing `/goal` actually stick (and the
175
+ native evidence-judge Stop-hook engages). Never clobbers an active goal.
176
+ Import is guarded so the plugin still loads / tests run outside Hermes.
177
+ """
178
+ goal_text = (goal_text or "").strip()
179
+ if not session_id or not goal_text:
180
+ return False
181
+ try:
182
+ from hermes_cli.goals import GoalManager
183
+ except (ImportError, ModuleNotFoundError):
184
+ return False
185
+ try:
186
+ mgr = GoalManager(session_id)
187
+ if mgr.is_active():
188
+ return False
189
+ mgr.set(goal_text)
190
+ except Exception:
191
+ return False
192
+ record_event("native_goal_bound", session_id=session_id)
193
+ return True
194
+
195
+
196
+ def _clamp_task(task: str) -> str:
197
+ """Bound a triggered task before it is bound into native goal / run-state.
198
+
199
+ A pasted multi-thousand-char prompt would otherwise inflate the injected
200
+ LIT_CONTEXT and the persisted run-state. Clamp to MAX_TASK_LEN chars.
201
+ """
202
+ task = task.strip()
203
+ if len(task) > MAX_TASK_LEN:
204
+ return task[:MAX_TASK_LEN].rstrip() + " […]"
205
+ return task
206
+
207
+
208
+ def _extract_run_context_task(message: str) -> str:
209
+ m = _RUN_CONTEXT_TASK_PATTERN.search(message)
210
+ return _clamp_task(m.group("task")) if m else ""
211
+
212
+
213
+ def pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
214
+ user_message = str(kwargs.get("user_message") or "")
215
+ session_id = str(kwargs.get("session_id") or "")
216
+ # The /lit command injects a run-context message. Bind the native goal from
217
+ # it (we have session_id here), then skip re-injecting context.
218
+ if "<lithermes-run-context>" in user_message:
219
+ task = _extract_run_context_task(user_message)
220
+ if task:
221
+ bind_native_goal(session_id, task)
222
+ return None
223
+ if not LIT_PATTERN.search(user_message):
224
+ return None
225
+ direct = DIRECT_LIT_PATTERN.match(user_message)
226
+ run_context = ""
227
+ if direct:
228
+ task = _clamp_task(direct.group("task"))
229
+ if task:
230
+ bind_native_goal(session_id, task)
231
+ workspace = Path.cwd().resolve()
232
+ run_dir = write_run_state(
233
+ workspace,
234
+ task=task,
235
+ command="lit",
236
+ )
237
+ run_context = "\n\n" + build_run_agent_message(load_run_state(run_dir))
238
+ record_event(
239
+ "litwork_trigger",
240
+ session_id=session_id,
241
+ platform=str(kwargs.get("platform") or ""),
242
+ )
243
+ return {"context": LIT_CONTEXT + run_context}
244
+
245
+
246
+ def subagent_stop(**kwargs: Any) -> None:
247
+ """Record each delegate_task child (e.g. a review lane) to the LitHermes ledger.
248
+
249
+ Hermes fires this once per child after delegate_task finishes. Observer-only:
250
+ the return value is ignored.
251
+ """
252
+ record_event(
253
+ "subagent_stop",
254
+ parent_session_id=str(kwargs.get("parent_session_id") or ""),
255
+ child_role=str(kwargs.get("child_role") or ""),
256
+ child_status=str(kwargs.get("child_status") or ""),
257
+ duration_ms=kwargs.get("duration_ms"),
258
+ )
259
+ return None
260
+
261
+
262
+ def build_goal_instruction(
263
+ objective: str,
264
+ *,
265
+ plan: Path | None = None,
266
+ workspace: Path | None = None,
267
+ ) -> str:
268
+ objective = objective.strip() or "Complete the requested LitHermes task with evidence."
269
+ plan_line = f"Plan: {plan}" if plan else "Plan: none"
270
+ workspace_line = f"Workspace: {workspace}" if workspace else "Workspace: current"
271
+ return "\n".join(
272
+ [
273
+ "<lithermes-goal-instruction>",
274
+ "Hermes goal handoff.",
275
+ workspace_line,
276
+ plan_line,
277
+ "",
278
+ "Standing goal (native):",
279
+ "- Hermes has NO model-facing goal tools. Do not invoke get_goal, create_goal, or",
280
+ " update_goal — they do not exist in Hermes and the request will fail.",
281
+ "- LitHermes binds the native standing goal for you via the session goal manager",
282
+ f" (the /goal equivalent), so it persists across turns: \"{objective}\".",
283
+ f"- If it is not bound, the user can set it with: /goal {objective}",
284
+ "- The native evidence-judge decides completion across turns; keep producing real evidence.",
285
+ "",
286
+ "Durable litgoal layer (criteria + evidence + gate) — drive via the lithermes goal tools:",
287
+ "- goal_set to declare the objective and 3+ upfront success criteria (happy/edge/regression);",
288
+ "- goal_add_criterion / goal_evidence(kind=red|green|scenario|cleanup) / goal_criterion_status as you work;",
289
+ "- goal_steer to redirect, goal_checkpoint to snapshot; inspect via `hermes lithermes goal status`;",
290
+ "- goal_complete is REFUSED until every criterion has green + scenario evidence and no blocker is open.",
291
+ "",
292
+ "Isolation: for risky/parallel edits use a worktree (EnterWorktree, or `claude --worktree <name> --tmux`).",
293
+ "",
294
+ "Delegation model (you conduct, workers play):",
295
+ "- Use delegate_task(tasks:[{goal, context}]) to fan out INDEPENDENT work in parallel;",
296
+ " children run concurrently and the parent blocks for all. Serialize only on a NAMED",
297
+ " dependency (a child consumes another's output or edits the same file).",
298
+ "- Do NOT trust a child's self-report: re-read its diff, re-run its tests, and run LSP",
299
+ " diagnostics yourself before accepting 'done'. Forward learnings to the next worker.",
300
+ "- Each child message is self-contained: goal + exact files in scope + constraints + the",
301
+ " verify commands + the ONE manual-QA channel + the exact evidence artifact path.",
302
+ "- Read-only codebase-search child: 'where is X / which files do Y' — fan out parallel",
303
+ " rg/LSP/AST/glob and return absolute paths plus the answer to the actual need.",
304
+ "- Read-only external-research child: an unfamiliar dependency/API — consult docs/gh/web",
305
+ " and cite SHA-pinned permalinks to primary sources; never mutate the worktree.",
306
+ "</lithermes-goal-instruction>",
307
+ ]
308
+ )
309
+
310
+
311
+ def create_plan(brief: str, workspace: Path | None = None) -> Path:
312
+ brief = brief.strip()
313
+ if not brief:
314
+ raise ValueError('usage: /lit-plan "what to build"')
315
+
316
+ root = (workspace or Path.cwd()).resolve()
317
+ out_dir = plan_dir(root)
318
+ out_dir.mkdir(parents=True, exist_ok=True)
319
+ path = out_dir / f"{slugify(brief)}.md"
320
+ if path.exists():
321
+ path = out_dir / f"{slugify(brief)}-{utc_now().strftime('%H%M%S')}.md"
322
+
323
+ now = utc_now().isoformat()
324
+ content = "\n".join(
325
+ [
326
+ f"# {brief}",
327
+ "",
328
+ f"Created: {now}",
329
+ "Source: lithermes",
330
+ "",
331
+ "## TL;DR",
332
+ "> Summary: <1-2 sentences>",
333
+ "> Deliverables: <bullet list>",
334
+ "> Effort: <Quick | Short | Medium | Large | XL>",
335
+ "> Risk: <Low | Medium | High> - <one-line driver>",
336
+ "",
337
+ "## Success Criteria",
338
+ "Declare 3+ upfront (happy / edge / regression). Each pairs an automated test",
339
+ "(written BEFORE code) with a manual-QA channel scenario. Format is parsed by the",
340
+ "runtime — keep the `C0NN | channel: | test: | scenario:` shape.",
341
+ "",
342
+ "- [ ] C001 | channel: tmux | test: <path::test_id> | scenario: Happy path — <user-visible outcome>",
343
+ "- [ ] C002 | channel: http | test: <path::test_id> | scenario: Edge/boundary — <malformed/empty/concurrent>",
344
+ "- [ ] C003 | channel: cli | test: <path::test_id> | scenario: Adjacent-surface regression — <named file+function>",
345
+ "",
346
+ "## Scope",
347
+ "### Must have",
348
+ "- [ ] <deliverable>",
349
+ "### Must NOT have (guardrails / anti-slop)",
350
+ "- [ ] <explicit exclusion>",
351
+ "",
352
+ "## Verification strategy",
353
+ "- Test decision: <TDD | tests-after> + framework",
354
+ "- QA policy: every criterion has an agent-executed channel scenario (tmux/http/browser/computer)",
355
+ "- Evidence: `.hermes/lithermes/runs/<run>/evidence/`",
356
+ "",
357
+ "## Execution strategy",
358
+ "### Parallel execution waves (target 5-8 tasks/wave)",
359
+ "- Wave 1 (no deps): ...",
360
+ "- Wave 2 (after Wave 1): ...",
361
+ "",
362
+ "### Dependency matrix",
363
+ "| Task | Depends on | Blocks | Parallel with |",
364
+ "|------|------------|--------|---------------|",
365
+ "| 1 | none | 2 | - |",
366
+ "",
367
+ "## Todos",
368
+ "> Implementation + Test = ONE task. Each carries References + Acceptance + QA + Commit.",
369
+ "",
370
+ "- [ ] 1. <task title>",
371
+ " - What to do: <steps>",
372
+ " - Must NOT do: <exclusions>",
373
+ " - References: `<file:line>` - <pattern/contract to follow>",
374
+ " - Acceptance: [ ] <verifiable command or assertion>",
375
+ " - QA scenario: tool=<tmux|curl|...> steps=<...> expected=<binary pass/fail> evidence=<path>",
376
+ " - Commit: `<type>(<scope>): <imperative>`",
377
+ "",
378
+ "## Final verification wave (after all tasks — ALL must pass)",
379
+ "- [ ] F1. Plan compliance audit — every task + acceptance criterion met",
380
+ "- [ ] F2. Code quality / diagnostics clean, idioms match, no dead code",
381
+ "- [ ] F3. Real manual QA — every criterion's channel scenario run with evidence + cleanup receipt",
382
+ "- [ ] F4. Scope fidelity — nothing extra, nothing Must-NOT-have introduced",
383
+ "",
384
+ "## Commit strategy",
385
+ "- Atomic Conventional Commits; each builds + tests green on its own.",
386
+ f"- Final footer: `Plan: plans/{path.name}`",
387
+ "",
388
+ "## Verification Evidence",
389
+ "- [ ] Record commands, outputs, transcripts, screenshots, and cleanup receipts that justify trust.",
390
+ "",
391
+ ]
392
+ )
393
+ path.write_text(content, encoding="utf-8")
394
+ record_event("plan_created", workspace=str(root), plan=str(path), brief=brief)
395
+ return path
396
+
397
+
398
+ def unchecked_items(markdown: str) -> list[str]:
399
+ items: list[str] = []
400
+ for line in markdown.splitlines():
401
+ stripped = line.strip()
402
+ if stripped.startswith("- [ ] "):
403
+ items.append(stripped[6:].strip())
404
+ return items
405
+
406
+
407
+ _CRITERION_PATTERN = re.compile(r"^- \[[ xX]\]\s*(C\d+)\s*\|(.*)$")
408
+
409
+
410
+ def extract_success_criteria(markdown: str) -> list[dict[str, str]]:
411
+ """Parse the plan's Success Criteria block.
412
+
413
+ Lines look like:
414
+ - [ ] C001 | channel: tmux | test: path::id | scenario: Happy path — ...
415
+ """
416
+ out: list[dict[str, str]] = []
417
+ for line in markdown.splitlines():
418
+ m = _CRITERION_PATTERN.match(line.strip())
419
+ if not m:
420
+ continue
421
+ crit: dict[str, str] = {"id": m.group(1), "qa_channel": "", "test_ref": "", "scenario": ""}
422
+ for field in m.group(2).split("|"):
423
+ if ":" not in field:
424
+ continue
425
+ key, _, value = field.partition(":")
426
+ key = key.strip().lower()
427
+ value = value.strip()
428
+ if key == "channel":
429
+ crit["qa_channel"] = value
430
+ elif key == "test":
431
+ crit["test_ref"] = value
432
+ elif key == "scenario":
433
+ crit["scenario"] = value
434
+ out.append(crit)
435
+ return out
436
+
437
+
438
+ def build_notepad(task: str, criteria: list[dict[str, str]]) -> str:
439
+ crit_lines = [
440
+ f"- {c['id']} [{c.get('qa_channel') or '?'}] {c.get('scenario') or ''} (test: {c.get('test_ref') or '?'})"
441
+ for c in criteria
442
+ ] or ["- (define success criteria before claiming progress)"]
443
+ return "\n".join(
444
+ [
445
+ f"# Litwork Notepad — {task}",
446
+ f"Started: {utc_now().isoformat()}",
447
+ "",
448
+ "## Plan (exhaustively detailed)",
449
+ "<every atomic step, in order>",
450
+ "",
451
+ "## Success criteria + QA scenarios",
452
+ *crit_lines,
453
+ "",
454
+ "## Now",
455
+ "<the single step in progress>",
456
+ "",
457
+ "## Todo",
458
+ "<every remaining step, ordered>",
459
+ "",
460
+ "## Findings",
461
+ "<non-obvious facts with file:line refs>",
462
+ "",
463
+ "## Learnings",
464
+ "<patterns / pitfalls to remember next turn>",
465
+ "",
466
+ ]
467
+ )
468
+
469
+
470
+ def record_criterion_event(run_dir: Path, criterion_id: str, kind: str, **fields: Any) -> None:
471
+ """Append a TDD/QA criterion event to the run ledger.
472
+
473
+ kinds: criterion_started, test_red_captured, test_green_captured,
474
+ scenario_executed, cleanup_receipt, criterion_complete, reviewer_verdict.
475
+ """
476
+ append_jsonl(
477
+ run_dir / "ledger.jsonl",
478
+ {"event": kind, "at": utc_now().isoformat(), "criterion_id": criterion_id, **fields},
479
+ )
480
+
481
+
482
+ def find_plan(name: str, workspace: Path) -> Path | None:
483
+ plans = plan_dir(workspace)
484
+ if not plans.exists():
485
+ return None
486
+
487
+ raw = name.strip()
488
+ if not raw:
489
+ candidates = sorted(plans.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
490
+ return candidates[0] if candidates else None
491
+
492
+ direct = Path(raw).expanduser()
493
+ if direct.exists():
494
+ return direct.resolve()
495
+
496
+ slug = slugify(raw, fallback=raw)
497
+ candidates = [
498
+ plans / raw,
499
+ plans / f"{raw}.md",
500
+ plans / slug,
501
+ plans / f"{slug}.md",
502
+ ]
503
+ for candidate in candidates:
504
+ if candidate.exists():
505
+ return candidate.resolve()
506
+ return None
507
+
508
+
509
+ def write_run_state(
510
+ workspace: Path,
511
+ *,
512
+ task: str,
513
+ command: str,
514
+ plan: Path | None = None,
515
+ completion_promise: str = "",
516
+ strategy: str = "continue",
517
+ ) -> Path:
518
+ rid = run_id("lithermes")
519
+ run_dir = lithermes_dir(workspace) / "runs" / rid
520
+ evidence_dir = run_dir / "evidence"
521
+ evidence_dir.mkdir(parents=True, exist_ok=True)
522
+
523
+ criteria: list[dict[str, str]] = []
524
+ if plan and plan.exists():
525
+ try:
526
+ criteria = extract_success_criteria(plan.read_text(encoding="utf-8"))
527
+ except OSError:
528
+ criteria = []
529
+
530
+ notepad = run_dir / "notepad.md"
531
+ notepad.write_text(build_notepad(task, criteria), encoding="utf-8")
532
+
533
+ state = {
534
+ "run_id": rid,
535
+ "created_at": utc_now().isoformat(),
536
+ "workspace": str(workspace),
537
+ "command": command,
538
+ "task": task,
539
+ "completion_promise": completion_promise,
540
+ "strategy": strategy,
541
+ "plan": str(plan) if plan else "",
542
+ "evidence_dir": str(evidence_dir),
543
+ "notepad_path": str(notepad),
544
+ "criteria": criteria,
545
+ "active_criterion": criteria[0]["id"] if criteria else "",
546
+ }
547
+ (run_dir / "state.json").write_text(
548
+ json.dumps(state, indent=2, sort_keys=True) + "\n",
549
+ encoding="utf-8",
550
+ )
551
+ append_jsonl(run_dir / "ledger.jsonl", {"event": "run_started", **state})
552
+ record_event("run_started", workspace=str(workspace), command=command, run_id=rid)
553
+ return run_dir
554
+
555
+
556
+ def load_run_state(run_dir: Path) -> dict[str, Any]:
557
+ return json.loads((run_dir / "state.json").read_text(encoding="utf-8"))
558
+
559
+
560
+ def build_run_agent_message(state: dict[str, Any]) -> str:
561
+ plan_line = f"\nPlan: {state['plan']}" if state.get("plan") else ""
562
+ promise = state.get("completion_promise") or "Complete the requested task with evidence."
563
+ plan_path = Path(state["plan"]) if state.get("plan") else None
564
+ workspace = Path(state["workspace"]) if state.get("workspace") else None
565
+ return "\n".join(
566
+ [
567
+ state["task"],
568
+ "",
569
+ build_goal_instruction(state["task"], plan=plan_path, workspace=workspace),
570
+ "",
571
+ "<lithermes-run-context>",
572
+ f"run_id: {state['run_id']}",
573
+ f"workspace: {state['workspace']}",
574
+ f"evidence_dir: {state['evidence_dir']}",
575
+ f"ledger: {Path(state['evidence_dir']).parent / 'ledger.jsonl'}",
576
+ f"strategy: {state['strategy']}",
577
+ f"completion_promise: {promise}",
578
+ f"task: {state['task']}{plan_line}",
579
+ "",
580
+ "Execute this LitHermes request now. Inspect the workspace as needed,",
581
+ "keep useful evidence under evidence_dir, append meaningful progress to the ledger,",
582
+ "and answer the user with the result instead of stopping after run creation.",
583
+ "Loop each criterion RED->GREEN->manual-QA->cleanup-receipt; when the work is risky, spans 3+",
584
+ "files, or the user demanded rigour, run the reviewer gate (delegate_task a strict reviewer)",
585
+ "and loop until UNCONDITIONAL approval before declaring done.",
586
+ "</lithermes-run-context>",
587
+ ]
588
+ )
589
+
590
+
591
+ def build_dispatch_result(run_dir: Path, *, display: str) -> dict[str, str]:
592
+ state = load_run_state(run_dir)
593
+ return {
594
+ "display": display,
595
+ "agent_message": build_run_agent_message(state),
596
+ "run_dir": str(run_dir),
597
+ }
598
+
599
+
600
+ def _join_positional(positional: Iterable[str]) -> str:
601
+ return " ".join(part for part in positional if part).strip()
602
+
603
+
604
+ def build_plan_agent_message(brief: str, plan: Path, workspace: Path) -> str:
605
+ return "\n".join(
606
+ [
607
+ brief,
608
+ "",
609
+ build_goal_instruction(brief, plan=plan, workspace=workspace),
610
+ "",
611
+ "<lithermes-plan-context>",
612
+ f"workspace: {workspace}",
613
+ f"plan: {plan}",
614
+ "",
615
+ "Run the LitHermes planning process — load the lithermes:lit-plan skill and follow it.",
616
+ "The plan file above is a scaffold to FILL, not a finished plan:",
617
+ "1) classify the request size (trivial / standard / architecture);",
618
+ "2) explore-first — fan out read-only delegate_task children to gather repo + external",
619
+ " facts BEFORE asking (discoverable facts -> explore; genuine preferences -> ask);",
620
+ "3) interview only the real unknowns;",
621
+ "4) APPROVAL GATE — present the facts found + remaining ambiguities (each with a",
622
+ " recommended default) + the intended approach, then WAIT for the user's explicit okay;",
623
+ "5) fill the scaffold, keeping the parseable Success Criteria (C0NN | channel: | test: |",
624
+ " scenario:) shape;",
625
+ "6) before finalizing, run a read-only pre-plan gap-analysis pass (contradictions,",
626
+ " ambiguity, missing constraints, execution risks) and a plan-review pass (references",
627
+ " resolve, tasks startable, QA scenarios concrete) and fold in the findings.",
628
+ "The native /goal is bound for you; track success criteria + evidence with the durable",
629
+ "goal tools (goal_set / goal_add_criterion / goal_evidence / goal_complete) and inspect",
630
+ "with `hermes lithermes goal status`.",
631
+ "</lithermes-plan-context>",
632
+ ]
633
+ )
634
+
635
+
636
+ def command_lit_plan(raw_args: str) -> dict[str, str]:
637
+ args = parse_args(raw_args)
638
+ workspace = workspace_from_option(args.options.get("worktree"))
639
+ brief = _join_positional(args.positional)
640
+ path = create_plan(brief, workspace)
641
+ return {
642
+ "display": f"Created LitHermes plan: {path}\nForwarding goal bootstrap to Hermes agent now.",
643
+ "agent_message": build_plan_agent_message(brief, path, workspace),
644
+ "plan": str(path),
645
+ }
646
+
647
+
648
+ def _command_lit_dispatch(raw_args: str, *, command: str) -> dict[str, str]:
649
+ args = parse_args(raw_args)
650
+ workspace = workspace_from_option(args.options.get("worktree"))
651
+ task = _join_positional(args.positional)
652
+ if not task:
653
+ raise ValueError('usage: /lit-loop "task" [--completion-promise TEXT] [--strategy reset|continue]')
654
+ completion = str(args.options.get("completion-promise") or "")
655
+ strategy = str(args.options.get("strategy") or "continue")
656
+ if strategy not in {"continue", "reset"}:
657
+ raise ValueError("--strategy must be either 'continue' or 'reset'")
658
+ run_dir = write_run_state(
659
+ workspace,
660
+ task=task,
661
+ command=command,
662
+ completion_promise=completion,
663
+ strategy=strategy,
664
+ )
665
+ promise = f"\nCompletion promise: {completion}" if completion else ""
666
+ display = (
667
+ f"Started LitHermes Litwork run: {run_dir}"
668
+ f"{promise}\nForwarding task to Hermes agent now."
669
+ )
670
+ return build_dispatch_result(run_dir, display=display)
671
+
672
+
673
+ def command_lit_loop(raw_args: str) -> dict[str, str]:
674
+ return _command_lit_dispatch(raw_args, command="lit-loop")
675
+
676
+
677
+ def command_lit(raw_args: str) -> dict[str, str]:
678
+ return _command_lit_dispatch(raw_args, command="lit")
679
+
680
+
681
+ def _run_git(workspace: Path, args: list[str]) -> str:
682
+ import subprocess
683
+
684
+ try:
685
+ out = subprocess.run(
686
+ ["git", *args],
687
+ cwd=str(workspace),
688
+ capture_output=True,
689
+ text=True,
690
+ timeout=20,
691
+ )
692
+ return out.stdout if out.returncode == 0 else ""
693
+ except Exception:
694
+ return ""
695
+
696
+
697
+ def detect_run_command(workspace: Path) -> str:
698
+ pkg = workspace / "package.json"
699
+ if pkg.exists():
700
+ try:
701
+ data = json.loads(pkg.read_text(encoding="utf-8"))
702
+ scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
703
+ for key in ("dev", "start", "serve"):
704
+ if key in scripts:
705
+ return f"npm run {key}"
706
+ except Exception:
707
+ pass
708
+ if (workspace / "Makefile").exists():
709
+ return "make (see Makefile targets)"
710
+ if (workspace / "docker-compose.yml").exists() or (workspace / "compose.yaml").exists():
711
+ return "docker compose up"
712
+ return "(detect manually — no dev/start script found)"
713
+
714
+
715
+ REVIEW_LANES = [
716
+ ("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."),
717
+ ("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."),
718
+ ("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."),
719
+ ("security", "Security (supplementary) — input validation, authz, secrets, data exposure, deps/CVEs, path/file ops. Severity CRITICAL/HIGH/MEDIUM/LOW. Verdict PASS/FAIL."),
720
+ ("context", "Context mining — git history, issues/PRs, related systems, developer TODO/warnings the diff may have missed. Verdict PASS/FAIL + discovered context."),
721
+ ]
722
+
723
+
724
+ def command_review_work(raw_args: str) -> dict[str, str]:
725
+ args = parse_args(raw_args)
726
+ workspace = workspace_from_option(args.options.get("worktree"))
727
+ base = str(args.options.get("base") or "HEAD~1")
728
+ changed = _run_git(workspace, ["diff", "--name-only", base]).strip()
729
+ diff = _run_git(workspace, ["diff", base])
730
+ if len(diff) > 60000:
731
+ diff = diff[:60000] + "\n... [diff truncated at 60k chars — lanes should read full files as needed]"
732
+ run_cmd = detect_run_command(workspace)
733
+ files_block = changed or "(no changed files detected vs " + base + ")"
734
+
735
+ lane_lines = []
736
+ for key, brief in REVIEW_LANES:
737
+ lane_lines.append(f"- lane[{key}]: {brief}")
738
+
739
+ agent_lines = [
740
+ "Run the LitHermes 5-lane review orchestrator on the current changes.",
741
+ "",
742
+ "<lithermes-review-work>",
743
+ f"workspace: {workspace}",
744
+ f"base: {base}",
745
+ f"run command (for QA lane): {run_cmd}",
746
+ "",
747
+ "Changed files:",
748
+ files_block,
749
+ "",
750
+ "Dispatch ALL FIVE lanes IN ONE delegate_task call — pass a `tasks` array of 5 entries,",
751
+ "each {goal: <lane brief>, context: <diff + changed files>}; children run in parallel and the",
752
+ "parent blocks until all five return:",
753
+ *lane_lines,
754
+ "",
755
+ "Each lane returns: verdict (PASS|FAIL), confidence, and findings with file:line.",
756
+ "Aggregate + dedupe findings across lanes. Gate is ALL-OR-NOTHING:",
757
+ "ANY lane FAIL => REVIEW FAILED (list blocking issues, prioritised by severity);",
758
+ "all five PASS => REVIEW PASSED (non-blocking suggestions only).",
759
+ "Load the lithermes:review-work skill for the full lane prompts and output contract.",
760
+ "",
761
+ "Diff under review:",
762
+ "```diff",
763
+ diff.strip() or "(empty diff)",
764
+ "```",
765
+ "</lithermes-review-work>",
766
+ ]
767
+ return {
768
+ "display": (
769
+ f"LitHermes review-work: 5 lanes over {base} "
770
+ f"({len([f for f in changed.splitlines() if f.strip()])} changed files). "
771
+ "Dispatching parallel review now."
772
+ ),
773
+ "agent_message": "\n".join(agent_lines),
774
+ }
775
+
776
+
777
+ def command_litgoal(raw_args: str) -> dict[str, str]:
778
+ args = parse_args(raw_args)
779
+ workspace = workspace_from_option(args.options.get("worktree"))
780
+ objective = _join_positional(args.positional)
781
+ intro = (
782
+ "Opened the LitHermes litgoal durable runtime."
783
+ if objective
784
+ else "LitHermes litgoal runtime."
785
+ )
786
+ agent_lines = [
787
+ objective or "Inspect and drive the active LitHermes litgoal.",
788
+ "",
789
+ "<lithermes-litgoal-command>",
790
+ f"workspace: {workspace}",
791
+ "Durable goal state lives under .hermes/lithermes/litgoal/ (goals.json + ledger.jsonl + evidence/).",
792
+ "Drive it through the model-facing goal tools, not prose:",
793
+ "- goal_status to read the active objective, criteria, evidence, and quality gate;",
794
+ "- goal_set to declare the objective and upfront success criteria (happy/edge/regression);",
795
+ "- goal_add_criterion / goal_evidence (red|green|scenario|cleanup) / goal_criterion_status as you work;",
796
+ "- goal_steer to redirect, goal_checkpoint to snapshot for resume;",
797
+ "- goal_complete only succeeds once the quality gate passes (every criterion has green + scenario",
798
+ " evidence and no unresolved review blocker). Inspect anytime with: hermes lithermes goal status.",
799
+ "Load the lithermes:litgoal skill for the full discipline.",
800
+ "</lithermes-litgoal-command>",
801
+ ]
802
+ return {
803
+ "display": f"{intro}\nState dir: {lithermes_dir(workspace) / 'litgoal'}",
804
+ "agent_message": "\n".join(agent_lines),
805
+ }
806
+
807
+
808
+ def command_start_work(raw_args: str) -> str | dict[str, str]:
809
+ args = parse_args(raw_args)
810
+ workspace = workspace_from_option(args.options.get("worktree"))
811
+ plan_name = _join_positional(args.positional)
812
+ dry_run = bool(args.options.get("dry-run"))
813
+ plan = find_plan(plan_name, workspace)
814
+
815
+ # No-plan bootstrap: /start-work with a brief but no matching plan creates the
816
+ # plan first (treating start-work as approval to bootstrap), then proceeds.
817
+ bootstrapped = False
818
+ if plan is None:
819
+ if not plan_name:
820
+ raise ValueError(
821
+ f"no plan found in {plan_dir(workspace)} and no brief given to bootstrap one"
822
+ )
823
+ if dry_run:
824
+ return (
825
+ f"LitHermes dry-run: no plan named '{plan_name}' found; "
826
+ f"would bootstrap a new plan from it in {plan_dir(workspace)}."
827
+ )
828
+ plan = create_plan(plan_name, workspace)
829
+ bootstrapped = True
830
+
831
+ text = plan.read_text(encoding="utf-8")
832
+ open_items = unchecked_items(text)
833
+ if dry_run:
834
+ preview = "\n".join(f"- {item}" for item in open_items[:8]) or "- no unchecked items found"
835
+ return f"LitHermes dry-run for {plan}:\n{preview}"
836
+
837
+ run_dir = write_run_state(
838
+ workspace,
839
+ task=f"Start work from {plan.name}",
840
+ command="start-work",
841
+ plan=plan,
842
+ )
843
+ first_items = "\n".join(f"- {item}" for item in open_items[:5]) or "- no unchecked items found"
844
+ display = (
845
+ f"{'Bootstrapped a new plan and started' if bootstrapped else 'Started'} "
846
+ f"LitHermes work run: {run_dir}\n"
847
+ f"Plan: {plan}\n"
848
+ f"Open items:\n{first_items}"
849
+ )
850
+ return {
851
+ "display": display,
852
+ "agent_message": build_run_agent_message(load_run_state(run_dir)),
853
+ }