@litfamily/lithermes 1.0.30 → 1.0.32
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 +7 -7
- package/README_Ko-KR.md +7 -7
- package/assets/lithermes-plugin/adhd_stack.py +45 -1
- package/assets/lithermes-plugin/bounded_work.py +41 -0
- package/assets/lithermes-plugin/core.py +23 -0
- package/assets/lithermes-plugin/core_contexts.py +1 -1
- package/assets/lithermes-plugin/core_plans.py +80 -0
- package/assets/lithermes-plugin/core_workflow_commands.py +11 -5
- package/assets/lithermes-plugin/payload-version.json +17 -17
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
- package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +19 -11
- package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +31 -8
- package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +12 -1
- package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +17 -1
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +135 -39
- package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +33 -1
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
- package/package.json +1 -1
- package/readme-assets/badge-version.svg +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: seven armored robots power on one by one, the LitHermes robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
5
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
4
|
+
<a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/badge-version.svg" alt="1.0.32" /></a>
|
|
5
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/badge-license.svg" alt="MIT license" /></a>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
10
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
9
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a>
|
|
10
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition film</a>
|
|
11
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
# LitHermes
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
A plugin for **Hermes Agent**. Put `lit` in front of a request, and Hermes plans the task, does it, checks the result and writes down where things stand, so the next session can pick up where this one stopped.
|
|
19
19
|
|
|
20
|
-
**[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
20
|
+
**[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/README_Ko-KR.md)
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
package/README_Ko-KR.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 일곱 로봇 패널이 차례로 켜지고, LitHermes 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
5
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
4
|
+
<a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/badge-version.svg" alt="1.0.32" /></a>
|
|
5
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/badge-license.svg" alt="MIT license" /></a>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
10
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
9
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a>
|
|
10
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition 영상</a>
|
|
11
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
# LitHermes
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
**Hermes Agent**용 플러그인입니다. 요청 앞에 `lit`을 붙이면 Hermes가 작업을 계획하고, 해내고, 결과를 확인한 뒤 어디까지 왔는지 적어 둡니다. 그래서 다음 세션이 그 기록을 읽고 멈춘 자리에서 이어 갈 수 있습니다.
|
|
19
19
|
|
|
20
|
-
**[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
20
|
+
**[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.32/README.md)
|
|
21
21
|
|
|
22
22
|
## 설치
|
|
23
23
|
|
|
@@ -11,7 +11,8 @@ the loop exactly where it was.
|
|
|
11
11
|
A bare ``go`` (also ``lit go``, ``ㄱㄱ``, ``고``, ``진행``, as the whole prompt) continues the
|
|
12
12
|
step the last brief offered: the hook reads the next-step record the collector wrote for this
|
|
13
13
|
session (``.hermes/lithermes/adhd/next-<session>.json``) and returns a short context note for
|
|
14
|
-
the model. A
|
|
14
|
+
the model. A plan saved under ``plans/`` during a lit-plan turn writes the same record
|
|
15
|
+
(``start-plan``), so ``go`` right after planning shows that plan's ``/start-work`` command. A record older than 30 minutes, or no record at all, reads as no next step on
|
|
15
16
|
record. Hermes has no typed ``go`` command; ``go`` is a word the hook consumes.
|
|
16
17
|
"""
|
|
17
18
|
from __future__ import annotations
|
|
@@ -158,6 +159,49 @@ def finish(workspace: Path | str, session_id: Any, skill: str = "") -> bool:
|
|
|
158
159
|
return False
|
|
159
160
|
|
|
160
161
|
|
|
162
|
+
def active_skill(workspace: Path | str, session_id: Any) -> str:
|
|
163
|
+
"""The skill of this session's latest active frame, or "" when none is active."""
|
|
164
|
+
for frame in reversed(_frames(read_stack(workspace), str(session_id or ""))):
|
|
165
|
+
if frame.get("status") == "active":
|
|
166
|
+
return str(frame.get("skill") or "")
|
|
167
|
+
return ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_next_step(
|
|
171
|
+
workspace: Path | str, session_id: Any, kind: str, target: dict[str, Any], *, now: float | None = None
|
|
172
|
+
) -> bool:
|
|
173
|
+
"""Write this session's `{kind, target, at}` record, the shape the collector writes.
|
|
174
|
+
|
|
175
|
+
Refuses a symlinked adhd or ``.hermes`` directory and stages through an exclusive,
|
|
176
|
+
no-follow, pid-unique file, so a planted link is never written through. Never raises.
|
|
177
|
+
"""
|
|
178
|
+
import time as _time
|
|
179
|
+
from datetime import datetime, timezone
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
directory = adhd_dir(workspace)
|
|
183
|
+
for probe in (directory, Path(workspace) / ".hermes"):
|
|
184
|
+
if probe.is_symlink():
|
|
185
|
+
return False
|
|
186
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
at = datetime.fromtimestamp(now if now is not None else _time.time(), tz=timezone.utc)
|
|
188
|
+
payload = {"kind": kind, "target": target, "at": at.isoformat(timespec="milliseconds").replace("+00:00", "Z")}
|
|
189
|
+
path = next_step_path(workspace, session_id)
|
|
190
|
+
staging = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
191
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
192
|
+
fd = os.open(staging, flags, 0o600)
|
|
193
|
+
try:
|
|
194
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
195
|
+
handle.write(json.dumps(payload) + "\n")
|
|
196
|
+
os.replace(staging, path)
|
|
197
|
+
except BaseException:
|
|
198
|
+
staging.unlink(missing_ok=True)
|
|
199
|
+
raise
|
|
200
|
+
return True
|
|
201
|
+
except (OSError, ValueError, TypeError):
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
161
205
|
def read_next_step(workspace: Path | str, session_id: Any) -> dict[str, Any] | None:
|
|
162
206
|
"""This session's next-step record, or None when it is missing or malformed. Never raises."""
|
|
163
207
|
try:
|
|
@@ -801,6 +801,45 @@ def _terminal_requirement(args: dict[str, Any]) -> tuple[str, list[Path]] | None
|
|
|
801
801
|
return action, [_canonical_tool_path(args.get("workdir") or Path.cwd())]
|
|
802
802
|
|
|
803
803
|
|
|
804
|
+
_OBSERVER_HERDR_READS = {
|
|
805
|
+
"pane": {"list", "read", "get", "layout"},
|
|
806
|
+
"agent": {"list", "get", "read"},
|
|
807
|
+
"tab": {"list"},
|
|
808
|
+
"workspace": {"list"},
|
|
809
|
+
}
|
|
810
|
+
_OBSERVER_FILTERS = {"grep", "rg", "head", "tail", "wc"}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def classify_observation(command: Any) -> bool:
|
|
814
|
+
"""True when a terminal command only reads: a herdr read or a grep/rg pipeline.
|
|
815
|
+
|
|
816
|
+
Pipes are the only shell control admitted; redirects, chaining, command
|
|
817
|
+
substitution and newlines still refuse, as in ``_terminal_requirement``.
|
|
818
|
+
"""
|
|
819
|
+
text = str(command or "").strip()
|
|
820
|
+
if not text or _SHELL_CONTROL_RE.search(text.replace("|", "")):
|
|
821
|
+
return False
|
|
822
|
+
segments = text.split("|")
|
|
823
|
+
for index, segment in enumerate(segments):
|
|
824
|
+
try:
|
|
825
|
+
words = shlex.split(segment)
|
|
826
|
+
except ValueError:
|
|
827
|
+
return False
|
|
828
|
+
if not words:
|
|
829
|
+
return False
|
|
830
|
+
executable = Path(words[0]).name
|
|
831
|
+
if executable == "rg" and any(word.startswith("--pre") for word in words):
|
|
832
|
+
return False
|
|
833
|
+
if index == 0 and executable == "herdr":
|
|
834
|
+
if len(words) < 3 or words[2] not in _OBSERVER_HERDR_READS.get(words[1], set()):
|
|
835
|
+
return False
|
|
836
|
+
elif index == 0 and executable not in {"grep", "rg"}:
|
|
837
|
+
return False
|
|
838
|
+
elif index > 0 and executable not in _OBSERVER_FILTERS:
|
|
839
|
+
return False
|
|
840
|
+
return True
|
|
841
|
+
|
|
842
|
+
|
|
804
843
|
def _tool_requirement(tool_name: str, args: dict[str, Any]) -> tuple[str, list[Path]] | None:
|
|
805
844
|
if tool_name in _READ_ONLY_TOOLS:
|
|
806
845
|
return None
|
|
@@ -866,6 +905,8 @@ def enforce_tool_authority(tool_name: Any, args: Any, session_id: Any) -> dict[s
|
|
|
866
905
|
exact_session = str(session_id or "")
|
|
867
906
|
bound = [state for state in states if state.get("active_session_id") == exact_session]
|
|
868
907
|
if not exact_session or not bound:
|
|
908
|
+
if name == "terminal" and classify_observation(payload.get("command")):
|
|
909
|
+
return None
|
|
869
910
|
return _block_tool("Bounded work mutation blocked: the exact Hermes session is not bound.")
|
|
870
911
|
if any(state.get("status") == "paused" for state in bound):
|
|
871
912
|
return _block_tool("Bounded work mutation blocked: this Hermes session has paused work.")
|
|
@@ -149,6 +149,7 @@ def post_tool_call(**kwargs: Any) -> None:
|
|
|
149
149
|
)
|
|
150
150
|
if not paths:
|
|
151
151
|
return None
|
|
152
|
+
_arm_plan_go(str(kwargs.get("session_id") or ""), paths)
|
|
152
153
|
key = str(kwargs.get("session_id") or "") or str(kwargs.get("task_id") or "")
|
|
153
154
|
if not key:
|
|
154
155
|
return None
|
|
@@ -168,6 +169,24 @@ def post_tool_call(**kwargs: Any) -> None:
|
|
|
168
169
|
return None
|
|
169
170
|
|
|
170
171
|
|
|
172
|
+
def _arm_plan_go(session_id: str, paths: list[str]) -> None:
|
|
173
|
+
"""After a lit-plan turn writes ``plans/<name>.md``, record it as this session's `go` step."""
|
|
174
|
+
if not session_id:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
workspace = Path.cwd().resolve()
|
|
178
|
+
if _adhd_stack.active_skill(workspace, session_id) != "lit-plan":
|
|
179
|
+
return
|
|
180
|
+
for raw in reversed(paths):
|
|
181
|
+
target = Path(raw).expanduser()
|
|
182
|
+
target = (target if target.is_absolute() else workspace / target).resolve()
|
|
183
|
+
if target.parent == workspace / "plans" and target.suffix == ".md":
|
|
184
|
+
_adhd_stack.write_next_step(workspace, session_id, "start-plan", {"plan": f"plans/{target.name}"})
|
|
185
|
+
return
|
|
186
|
+
except (OSError, RuntimeError, ValueError):
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
|
|
171
190
|
def post_api_request(**kwargs: Any) -> None:
|
|
172
191
|
"""Record Hermes' numeric provider usage receipt in the local ledger.
|
|
173
192
|
|
|
@@ -1694,6 +1713,10 @@ def pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
|
|
|
1694
1713
|
# route envelope only; native /goal is user-managed and never mutated here.
|
|
1695
1714
|
bind_obj = _extract_bind_goal(user_message)
|
|
1696
1715
|
if bind_obj:
|
|
1716
|
+
# The /lit-plan envelope marks this session's lit-plan frame, so the plan
|
|
1717
|
+
# it saves arms `go` the same way a natural `lit plan` turn does.
|
|
1718
|
+
if session_id and "<lithermes-plan-context>" in user_message:
|
|
1719
|
+
_adhd_stack.push(Path.cwd().resolve(), session_id, "lit-plan")
|
|
1697
1720
|
return None
|
|
1698
1721
|
# A bare `go` continues the step the last brief offered. The hook consumes
|
|
1699
1722
|
# the word and injects the next-step record; it never runs a command.
|
|
@@ -152,7 +152,7 @@ _SKILL_ROUTE_CONTRACTS = {
|
|
|
152
152
|
),
|
|
153
153
|
"lit-adhd": (
|
|
154
154
|
"Brief task",
|
|
155
|
-
"Mode Contract: read-only. Run the installed collector once and write the five-part brief from its JSON only: headline, one card per session, roadmap timeline, paused work, one next step. Gauges are copied verbatim and never drawn by hand.",
|
|
155
|
+
"Mode Contract: read-only. Run the installed collector once and write the five-part brief from its JSON only, after the Done so far/Doing now/Next opener: headline, one card per session, roadmap timeline, paused work, one next step. Gauges are copied verbatim and never drawn by hand.",
|
|
156
156
|
"Read the installed lit-adhd skill first. Run node scripts/adhd.mjs --json from the installed skill root (--here/--since/--max only when asked). Copy every gauge verbatim; an unknown total is the pulse line, never ?, never a guess. End with the one next step from next: the /start-work command when next.command names one, otherwise continue in chat. Hermes has no typed go; offer `lit adhd` for the next brief.",
|
|
157
157
|
"Single-session recaps stay with lit-recap; execution stays with start-work.",
|
|
158
158
|
),
|
|
@@ -408,6 +408,86 @@ def plan_structure_issues(markdown: str) -> list[str]:
|
|
|
408
408
|
return issues
|
|
409
409
|
|
|
410
410
|
|
|
411
|
+
# `Scope roots: <path> (<actions>)` names a folder outside the project that the work
|
|
412
|
+
# reaches. Hermes bounded work grants only inside its worktree, so the line is advisory:
|
|
413
|
+
# it tells the reviewer and the user, before execution, what the todos will touch.
|
|
414
|
+
SCOPE_ROOT_LINE = re.compile(r"^Scope roots?:[ \t]*(\S+)[ \t]*\(([a-z, -]+)\)[ \t]*$")
|
|
415
|
+
_ABSOLUTE_PATH = re.compile(r"(?<![\w.~/-])/[\w.@+%,=~-]+(?:/[\w.@+%,=~-]+)+/?")
|
|
416
|
+
_CODE_SPAN = re.compile(r"`([^`]*)`")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _path_mentions(line: str) -> list[str]:
|
|
420
|
+
# A code span holding whitespace is a quoted command, not a folder; a span that
|
|
421
|
+
# is one bare path still counts.
|
|
422
|
+
def unquote(match: re.Match[str]) -> str:
|
|
423
|
+
span = match.group(1).strip()
|
|
424
|
+
return " " if re.search(r"\s", span) else f" {span} "
|
|
425
|
+
|
|
426
|
+
return _ABSOLUTE_PATH.findall(_CODE_SPAN.sub(unquote, line))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _nearest_directory(path: Path) -> Path:
|
|
430
|
+
for candidate in (path, *path.parents):
|
|
431
|
+
if candidate.is_dir():
|
|
432
|
+
return candidate.resolve()
|
|
433
|
+
return Path(path.anchor or "/")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _too_broad(directory: Path) -> bool:
|
|
437
|
+
return len(directory.parents) <= 1 or directory == Path.home().resolve()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _covers(root: Path, path: Path) -> bool:
|
|
441
|
+
return path == root or root in path.parents
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def scope_root_issues(markdown: str, workspace: Path | str) -> list[str]:
|
|
445
|
+
"""Advisory: absolute paths under '## Todos' outside the project and every Scope roots line.
|
|
446
|
+
|
|
447
|
+
Fenced lines, command spans and other sections do not count. A root that is the
|
|
448
|
+
filesystem root, a top-level folder or the bare home folder never covers anything.
|
|
449
|
+
One message per folder, naming the line to add or asking for a narrower folder.
|
|
450
|
+
"""
|
|
451
|
+
project = Path(workspace).resolve()
|
|
452
|
+
lines = list(_outside_fence_lines(markdown))
|
|
453
|
+
roots = []
|
|
454
|
+
for _, raw in lines:
|
|
455
|
+
match = SCOPE_ROOT_LINE.match(raw)
|
|
456
|
+
if match:
|
|
457
|
+
root = Path(match.group(1)).expanduser()
|
|
458
|
+
root = (root if root.is_absolute() else project / root).resolve()
|
|
459
|
+
if not _too_broad(root):
|
|
460
|
+
roots.append(root)
|
|
461
|
+
issues: list[str] = []
|
|
462
|
+
seen: set[Path] = set()
|
|
463
|
+
section = ""
|
|
464
|
+
for _, raw in lines:
|
|
465
|
+
if raw.startswith("## "):
|
|
466
|
+
section = raw.strip()
|
|
467
|
+
continue
|
|
468
|
+
if section != _TODOS_HEADING:
|
|
469
|
+
continue
|
|
470
|
+
for mention in _path_mentions(raw):
|
|
471
|
+
path = mention.rstrip(".,:;)")
|
|
472
|
+
directory = _nearest_directory(Path(path))
|
|
473
|
+
resolved = Path(path).resolve()
|
|
474
|
+
if any(_covers(root, candidate) for root in (project, *roots) for candidate in (resolved, directory)):
|
|
475
|
+
continue
|
|
476
|
+
if directory in seen:
|
|
477
|
+
continue
|
|
478
|
+
seen.add(directory)
|
|
479
|
+
if _too_broad(directory):
|
|
480
|
+
issues.append(
|
|
481
|
+
f"{path} is outside the project; name a narrower existing folder on a Scope roots line"
|
|
482
|
+
)
|
|
483
|
+
else:
|
|
484
|
+
issues.append(
|
|
485
|
+
f"{path} is outside the project and no Scope roots line covers it; "
|
|
486
|
+
f"add: Scope roots: {directory} (read)"
|
|
487
|
+
)
|
|
488
|
+
return issues
|
|
489
|
+
|
|
490
|
+
|
|
411
491
|
def assert_plan_structure(markdown: str) -> None:
|
|
412
492
|
"""Raise unless the plan satisfies the row grammar. Use before handoff."""
|
|
413
493
|
issues = plan_structure_issues(markdown)
|
|
@@ -6,7 +6,7 @@ try:
|
|
|
6
6
|
LITGOAL_MEANING, _skill_body_block, conditional_uiux_skill_blocks, contract_route_block,
|
|
7
7
|
reader_facing_contract_block,
|
|
8
8
|
)
|
|
9
|
-
from .core_plans import plan_structure_issues, unchecked_items
|
|
9
|
+
from .core_plans import plan_structure_issues, scope_root_issues, unchecked_items
|
|
10
10
|
from .core_reader_args import parse_reader_facing_command_args
|
|
11
11
|
from .core_routing import bind_goal_marker
|
|
12
12
|
from .core_runs import build_run_agent_message, find_plan, latest_start_work_run, load_run_state, write_run_state
|
|
@@ -17,7 +17,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
17
17
|
LITGOAL_MEANING, _skill_body_block, conditional_uiux_skill_blocks, contract_route_block,
|
|
18
18
|
reader_facing_contract_block,
|
|
19
19
|
)
|
|
20
|
-
from core_plans import plan_structure_issues, unchecked_items
|
|
20
|
+
from core_plans import plan_structure_issues, scope_root_issues, unchecked_items
|
|
21
21
|
from core_reader_args import parse_reader_facing_command_args
|
|
22
22
|
from core_routing import bind_goal_marker
|
|
23
23
|
from core_runs import build_run_agent_message, find_plan, latest_start_work_run, load_run_state, write_run_state
|
|
@@ -144,14 +144,20 @@ def command_start_work(raw_args: str) -> str | dict[str, str]:
|
|
|
144
144
|
raise ValueError(msg)
|
|
145
145
|
conditional_uiux = conditional_uiux_skill_blocks(text)
|
|
146
146
|
open_items = unchecked_items(text)
|
|
147
|
+
outside = scope_root_issues(text, workspace)
|
|
148
|
+
scope_note = (
|
|
149
|
+
"\nScope roots advisory (Hermes grants nothing outside the worktree; settle these "
|
|
150
|
+
"with the user before the todos reach them):\n" + "\n".join(f"- {issue}" for issue in outside[:8])
|
|
151
|
+
if outside else ""
|
|
152
|
+
)
|
|
147
153
|
if dry_run:
|
|
148
154
|
preview = "\n".join(f"- {item}" for item in open_items[:8]) or "- no unchecked items found"
|
|
149
155
|
if resume:
|
|
150
156
|
run_dir = latest_start_work_run(workspace, plan)
|
|
151
157
|
if not run_dir:
|
|
152
158
|
return f"BLOCKED: /start-work --resume found no prior run for {plan}."
|
|
153
|
-
return f"LitHermes resume dry-run for {plan}: would resume {run_dir}\n{preview}"
|
|
154
|
-
return f"LitHermes dry-run for {plan}:\n{preview}"
|
|
159
|
+
return f"LitHermes resume dry-run for {plan}: would resume {run_dir}\n{preview}{scope_note}"
|
|
160
|
+
return f"LitHermes dry-run for {plan}:\n{preview}{scope_note}"
|
|
155
161
|
|
|
156
162
|
if resume:
|
|
157
163
|
run_dir = latest_start_work_run(workspace, plan)
|
|
@@ -189,7 +195,7 @@ def command_start_work(raw_args: str) -> str | dict[str, str]:
|
|
|
189
195
|
display = (
|
|
190
196
|
f"Started LitHermes work run from approved plan: {run_dir}\n"
|
|
191
197
|
f"Plan: {plan}\n"
|
|
192
|
-
f"Open items:\n{first_items}"
|
|
198
|
+
f"Open items:\n{first_items}{scope_note}"
|
|
193
199
|
)
|
|
194
200
|
return {
|
|
195
201
|
"display": display,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"syncedAt": "2026-10-
|
|
2
|
+
"syncedAt": "2026-10-28T14:17:34.226Z",
|
|
3
3
|
"source": "bundled-payload",
|
|
4
|
-
"sourceHash": "
|
|
4
|
+
"sourceHash": "4b34138dc5258862c601aff0cb5ef841a2ca102319a24a6316c6c5977b099639",
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "NOTICE.md",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "adhd_stack.py",
|
|
20
|
-
"sha256": "
|
|
20
|
+
"sha256": "0a1e3e45f6697918758b1454a53f034a61658f3980430c5c1a15c149a9ba7012"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "auto_handoff.py",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"path": "bounded_work.py",
|
|
32
|
-
"sha256": "
|
|
32
|
+
"sha256": "12159eed9ea3ca71d1de7b10c12b4dea5cb273d9abe7b1b52dddad1738cbeb04"
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"path": "bounded_work_tools.py",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "core.py",
|
|
40
|
-
"sha256": "
|
|
40
|
+
"sha256": "68641a92c3cc389c46bc1c1dc8bfab7e72fed16c10769a0f5ad7a6327258178e"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "core_commands.py",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"path": "core_contexts.py",
|
|
48
|
-
"sha256": "
|
|
48
|
+
"sha256": "4b8ab6c18ac58c1b9472547c6f4c50aa64b40878f86f848e6e1ca5a5dc800640"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "core_contract.py",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "core_plans.py",
|
|
56
|
-
"sha256": "
|
|
56
|
+
"sha256": "e1f138375a88a5098ac091444fbf528eeb8e83d750c68a4ced5072c779b1c2e4"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "core_reader_args.py",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
"path": "core_workflow_commands.py",
|
|
88
|
-
"sha256": "
|
|
88
|
+
"sha256": "734133dbfac98d98fff445c5787fab49899c94a9f72f5aace27665798765ee37"
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
"path": "deliverable_hedge_guard.py",
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
},
|
|
186
186
|
{
|
|
187
187
|
"path": "plugin.yaml",
|
|
188
|
-
"sha256": "
|
|
188
|
+
"sha256": "568a992a47e5bece2aaa24ae0930e3a205fac0cfb58ed20d84c00364bd8d4478"
|
|
189
189
|
},
|
|
190
190
|
{
|
|
191
191
|
"path": "provider_metrics.py",
|
|
@@ -1333,7 +1333,7 @@
|
|
|
1333
1333
|
},
|
|
1334
1334
|
{
|
|
1335
1335
|
"path": "skills/frontend-ui-ux/references/complete-contract.md",
|
|
1336
|
-
"sha256": "
|
|
1336
|
+
"sha256": "ac5181cf8922fdf980d136b23c2da9cd8d4ec34b03dce165e25bc591e90abd0d"
|
|
1337
1337
|
},
|
|
1338
1338
|
{
|
|
1339
1339
|
"path": "skills/frontend-ui-ux/references/composition.md",
|
|
@@ -1489,19 +1489,19 @@
|
|
|
1489
1489
|
},
|
|
1490
1490
|
{
|
|
1491
1491
|
"path": "skills/lit-adhd/SKILL.md",
|
|
1492
|
-
"sha256": "
|
|
1492
|
+
"sha256": "5c7b769bbca2e534874425e755d0b8d01a57f3b042a7368ba7575cceea0a7c76"
|
|
1493
1493
|
},
|
|
1494
1494
|
{
|
|
1495
1495
|
"path": "skills/lit-adhd/references/brief-contract.md",
|
|
1496
|
-
"sha256": "
|
|
1496
|
+
"sha256": "e7ee488082297403e1ff5796f7a117fe69e7e62fbae749c547ec41dd1b70bfc4"
|
|
1497
1497
|
},
|
|
1498
1498
|
{
|
|
1499
1499
|
"path": "skills/lit-adhd/references/card-examples.md",
|
|
1500
|
-
"sha256": "
|
|
1500
|
+
"sha256": "b077110d4eb04ce96640646280544dd4eb5ca287d0492356dd62b376aa162129"
|
|
1501
1501
|
},
|
|
1502
1502
|
{
|
|
1503
1503
|
"path": "skills/lit-adhd/references/status-taxonomy.md",
|
|
1504
|
-
"sha256": "
|
|
1504
|
+
"sha256": "3f18ce0e1509d0c3e8d48730fb29625883d52eb05cc7bdf4dbac35c5df77291d"
|
|
1505
1505
|
},
|
|
1506
1506
|
{
|
|
1507
1507
|
"path": "skills/lit-adhd/scripts/adhd.mjs",
|
|
@@ -1509,7 +1509,7 @@
|
|
|
1509
1509
|
},
|
|
1510
1510
|
{
|
|
1511
1511
|
"path": "skills/lit-adhd/scripts/lib/collect.mjs",
|
|
1512
|
-
"sha256": "
|
|
1512
|
+
"sha256": "ca056223c8dba8008543ba900a45f4bbea5859119215ad6a2a35cf0fb34ed68f"
|
|
1513
1513
|
},
|
|
1514
1514
|
{
|
|
1515
1515
|
"path": "skills/lit-adhd/scripts/lib/gauge.mjs",
|
|
@@ -3677,7 +3677,7 @@
|
|
|
3677
3677
|
},
|
|
3678
3678
|
{
|
|
3679
3679
|
"path": "skills/lit-plan/SKILL.md",
|
|
3680
|
-
"sha256": "
|
|
3680
|
+
"sha256": "8325532a4394f033feeb5a55d64938967458a87324dd688b4783050858195014"
|
|
3681
3681
|
},
|
|
3682
3682
|
{
|
|
3683
3683
|
"path": "skills/lit-pptx/FORBIDDEN_TERMS.json",
|
|
@@ -4945,7 +4945,7 @@
|
|
|
4945
4945
|
},
|
|
4946
4946
|
{
|
|
4947
4947
|
"path": "skills/visual-qa/references/complete-contract.md",
|
|
4948
|
-
"sha256": "
|
|
4948
|
+
"sha256": "a0ca33f1bc157de7e2c0cbbe38688fee6c7818674c691bbb3dbd6d18083cf932"
|
|
4949
4949
|
},
|
|
4950
4950
|
{
|
|
4951
4951
|
"path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
|
|
@@ -445,7 +445,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
|
|
|
445
445
|
|
|
446
446
|
```text
|
|
447
447
|
$ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
|
|
448
|
-
Installed LitHermes 1.0.
|
|
448
|
+
Installed LitHermes 1.0.32
|
|
449
449
|
plugin: $ISOLATED/plugins/lithermes
|
|
450
450
|
model config: updated
|
|
451
451
|
|
|
@@ -462,7 +462,7 @@ installed payload: PASS
|
|
|
462
462
|
enabled config: PASS
|
|
463
463
|
|
|
464
464
|
$ hermes lithermes doctor
|
|
465
|
-
[OK] plugin.yaml readable (version 1.0.
|
|
465
|
+
[OK] plugin.yaml readable (version 1.0.32)
|
|
466
466
|
[OK] skills bundled: 40
|
|
467
467
|
[OK] litgoal durable runtime importable
|
|
468
468
|
```
|
|
@@ -51,17 +51,23 @@ verdicts: [PASS, FAIL, BLOCKED]
|
|
|
51
51
|
|
|
52
52
|
## #contract.outputs
|
|
53
53
|
|
|
54
|
-
The brief
|
|
54
|
+
The brief opens with three lines, each from records only (`references/brief-contract.md`, "The opener"):
|
|
55
|
+
|
|
56
|
+
- `Done so far:` / `지금까지:` — this worktree's newest commits in the window (two at most), then its finished start-work runs (`finished.runs`, written `plans/<f>.md (start-work complete)`) and fully ticked plans (`finished.plans`), three items in all; with none, "nothing finished in this window".
|
|
57
|
+
- `Doing now:` / `지금:` — this session's active frame from `workflows`, else its own active `startWork` run with the gauge, else the newest active session's goal, else another session's active run marked `(another session)`; with none, "nothing running".
|
|
58
|
+
- `Next:` / `다음:` — the same one step the box at the end names.
|
|
59
|
+
|
|
60
|
+
Then the brief has five parts, in this order:
|
|
55
61
|
|
|
56
62
|
1. **Headline.** Sessions found, how many wait on the user, the one next step: `3 sessions · 1 waiting on you · next: start plan (run /start-work plans/<plan>.md)` / `세션 3개 · 나를 기다리는 것 1 · 다음: start plan (/start-work plans/<plan>.md 실행)`. The headline doubles as the counts-only status line: session counts first, then plan and goal gauges, each a real `k/n` from the first source that knows it, never `?`, never `k/?`.
|
|
57
63
|
2. **A full card per live session, a table for ended ones.** Waiting, active and unknown sessions get a full card: a status badge (`● waiting on you`, `▶ active`, `○ unknown`, in the user's language), the goal in plain words (the first ask, clipped), a real gauge or the known-facts line, `now:` naming the last tool call on active sessions, what was done (tools used and commits), what is next. Group cards under their worktree when sessions span more than one. Ended sessions collapse to one table row each (id, folder, goal, age, done; at most eight, newest first).
|
|
58
|
-
3. **Roadmap timeline.** Done `✓` (
|
|
64
|
+
3. **Roadmap timeline.** Done `✓` (landed commits with dates), now `▶` (the current item of each open plan), later `○` (remaining names), then litgoal objectives with their gauges. Plans from other worktrees carry their worktree name. When `plansOmitted` is above zero, say how many more open plans exist. Only plans changed inside the window are listed, this worktree's too; a fully ticked plan is finished work for the opener, never a roadmap row.
|
|
59
65
|
4. **Paused work.** Each paused bounded-work run from `startWork` with its gauge and an approval offer: `answer in chat to approve or deny the boundary`. Approval stays with the user in words; the grant settles it. Never start or approve it yourself.
|
|
60
|
-
5. **One next step in a box.** Exactly one, from `next`: what it does, then the slash command when `next.command` names one, otherwise "continue in chat". When `next` is `none`, show the suggestion the collector printed
|
|
66
|
+
5. **One next step in a box.** Exactly one, from `next`: what it does, then the slash command when `next.command` names one, otherwise "continue in chat". When `next` is `none`, the first `## Next` item of this worktree's own HANDOFF.md (`handoffs[].next` with `local: true`) is the step (`HANDOFF.md Next: <item>`); without one, show the suggestion the collector printed. Either is shown, never recorded, and never a second candidate.
|
|
61
67
|
|
|
62
68
|
**Progress gauges (D9), required.** One real gauge per session and per plan, each copied verbatim from the collector, in the form `[▉▉▉░░] 3/5` (filled `▉` done, light `░` remaining, then done/total). The remaining items are listed by name under each gauge, from its `remaining` array, clipped by the collector to fit. A gauge renders only when both numbers are real: an unknown total is the known-facts line (`… 3 steps`, or `… running` when nothing is counted), never `?`, never a guess, never an estimate, never a gauge of your own.
|
|
63
69
|
|
|
64
|
-
Reader mode is the default conversational projection: the five parts above are the result, so give them and nothing more. Open the brief with the label `REWIND <<` on its own line (the rewind look; Hermes shows no colour or animation, so the label and badges are the whole look). Keep collector metadata such as byte budgets, source maps and file paths internal unless the user asks; technical or audit detail appears only when the current request asks for it. A material failure, risk or uncertainty stays visible: a source that read `unknown` is named in one line at the end (`모름: herdr, git` / `unknown: herdr, git`), and a truncated read (`budget.exhausted`) is stated as "older sessions were not read".
|
|
70
|
+
Reader mode is the default conversational projection: the opener and the five parts above are the result, so give them and nothing more. Open the brief with the label `REWIND <<` on its own line (the rewind look; Hermes shows no colour or animation, so the label and badges are the whole look), then the three opener lines. Keep collector metadata such as byte budgets, source maps and file paths internal unless the user asks; technical or audit detail appears only when the current request asks for it. A material failure, risk or uncertainty stays visible: a source that read `unknown` is named in one line at the end (`모름: herdr, git` / `unknown: herdr, git`), and a truncated read (`budget.exhausted`) is stated as "older sessions were not read". Plans are read before session records; when the plans alone fill the budget, `roadmap.plansSkipped` says how many were not read, and the brief says so ("2 plans were not read (read budget used up)").
|
|
65
71
|
|
|
66
72
|
The Hermes chat surface is not animated and carries no colour, so the psychedelic look does not port: the brief is plain text with no colour codes, and no colour escape ever enters the collector JSON. This skill asks no theme or preset, so the ask-first rule does not apply to it.
|
|
67
73
|
|
|
@@ -74,7 +80,7 @@ limitations_channel: inline
|
|
|
74
80
|
|
|
75
81
|
## #contract.evidence
|
|
76
82
|
|
|
77
|
-
- The collector JSON is the evidence. Every claim in the brief maps to a field in it: `sessions[].asked`, `done.tools`, `done.commits`, `status`, `roadmap`, `workflows`, `startWork`, `next`.
|
|
83
|
+
- The collector JSON is the evidence. Every claim in the brief maps to a field in it: `sessions[].asked`, `done.tools`, `done.commits`, `status`, `roadmap`, `finished`, `handoffs[].next`, `workflows`, `startWork`, `next`.
|
|
78
84
|
- A session's "what that means" is a one-clause reading of its `asked`, `done.tools` and `done.commits` only. When all are empty, say what is known (elapsed time, last action) and mark nothing as done.
|
|
79
85
|
- Real-surface proof for this skill lives in the repo's tests (`test/lit-adhd.test.js`, `test/python/test_lit_adhd.py`) and in recorded real-session briefs; detailed evidence stays internal to the run.
|
|
80
86
|
|
|
@@ -123,15 +129,15 @@ The collector (`skills/lit-adhd/scripts/lib/collect.mjs`, served as `scripts/adh
|
|
|
123
129
|
| Source | What it gives | Missing reads |
|
|
124
130
|
| --- | --- | --- |
|
|
125
131
|
| Hermes sessions, `~/.hermes/state.db` read-only (`HERMES_SESSION_STORE` overrides it) | per session: title, first and last ask, tools used, status | `sessions: unknown` |
|
|
126
|
-
| `plans/*.md` checkbox rows | open plans
|
|
132
|
+
| `plans/*.md` checkbox rows | open plans changed in the window (current item, done/total, remaining names) and fully ticked ones as finished work | `plans: unknown` |
|
|
127
133
|
| `.hermes/lithermes/litgoal/*/goals.json` | objective and passed/total criteria per goal | `litgoal: unknown` |
|
|
128
|
-
| `HANDOFF*.md` and the `.handoff/` directory | headings from the first 40 lines
|
|
134
|
+
| `HANDOFF*.md` and the `.handoff/` directory | headings from the first 40 lines; for this worktree's own, the first `## Next` item | `handoffs: unknown` |
|
|
129
135
|
| `git log --since` | commits in the window, matched to sessions by time and branch | `git: unknown` |
|
|
130
136
|
| `.hermes/lithermes/adhd/stack.json` | active disciplines per session from the prompt hook | `workflowStack: unknown` |
|
|
131
|
-
| start-work runs and bounded-work states | live runs and their plans, paused boundaries with progress | `startWork: unknown` |
|
|
137
|
+
| start-work runs and bounded-work states | live and finished runs (`done`, `completed`) and their plans, paused boundaries with progress | `startWork: unknown` |
|
|
132
138
|
| `herdr agent list` (only when `HERDR_ENV=1`) | agent panes | `herdr: unknown` |
|
|
133
139
|
|
|
134
|
-
|
|
140
|
+
Plans, goals, handoffs, the stack and start-work states are read first; sessions are read after them, newest first, under one 8 MiB budget for the whole run; a session belongs to a worktree when the repo root or cwd its record carries sits inside it. Archived and hidden sessions are skipped. Every string is redacted before it leaves the collector: credential shapes, `NAME=value` with a secret-like name, URI userinfo, any current environment value of eight or more characters, and the home directory (shown as `~`). Assistant text and changed files are not session records on this host, so the brief shows commits and tool names instead.
|
|
135
141
|
|
|
136
142
|
## Status taxonomy
|
|
137
143
|
|
|
@@ -142,6 +148,8 @@ Sessions are read newest first under one 8 MiB budget for the whole run; a sessi
|
|
|
142
148
|
|
|
143
149
|
Sessions waiting on the user come first, then active ones, newest first.
|
|
144
150
|
|
|
151
|
+
A start-work run whose plan has every row ticked reads `done`, and completed bounded work reads `completed`; both are finished work on the opener's `Done so far:` line. A session never reads `done` on Hermes: run records carry no session id and completed bounded work clears its owner, so nothing ties a finished run to the session that ran it (`references/status-taxonomy.md`).
|
|
152
|
+
|
|
145
153
|
## The next step
|
|
146
154
|
|
|
147
155
|
`next` is chosen by code, in this priority order, and the brief offers exactly that one:
|
|
@@ -153,7 +161,7 @@ Sessions waiting on the user come first, then active ones, newest first.
|
|
|
153
161
|
|
|
154
162
|
## Continuing after the brief
|
|
155
163
|
|
|
156
|
-
After a brief, the user's bare `go` (also `lit go`, `ㄱㄱ`, `고`, `진행`, as the whole prompt) continues the step the brief offered, in the same session, within 30 minutes. The prompt hook reads the next-step record the collector wrote for this session and tells the model what to do next: a paused boundary is shown for a chat answer, an active run continues in chat, a single open plan starts through `/start-work`. Anything else typed after the brief is an ordinary prompt. There is no typed `go` command on Hermes; `go` is a word the hook consumes, and an expired or missing record reads as no next step on record.
|
|
164
|
+
After a brief, the user's bare `go` (also `lit go`, `ㄱㄱ`, `고`, `진행`, as the whole prompt) continues the step the brief offered, in the same session, within 30 minutes. The prompt hook reads the next-step record the collector wrote for this session and tells the model what to do next: a paused boundary is shown for a chat answer, an active run continues in chat, a single open plan starts through `/start-work`. Anything else typed after the brief is an ordinary prompt. A plan saved under `plans/` in a lit-plan turn writes the same record (`start-plan`), so `go` right after planning shows that plan's `/start-work` command too. There is no typed `go` command on Hermes; `go` is a word the hook consumes, and an expired or missing record reads as no next step on record.
|
|
157
165
|
|
|
158
166
|
## Relation to lit-recap
|
|
159
167
|
|
|
@@ -178,7 +186,7 @@ The prompt hook records each lit route activation as a frame in the adhd stack f
|
|
|
178
186
|
## Stop and report
|
|
179
187
|
|
|
180
188
|
- Stop with `BLOCKED: lit-adhd collector unavailable (<reason>)` when the collector exits non-zero or is not found; never rebuild the brief from memory.
|
|
181
|
-
- The reply is the five-part brief (summary, session cards, roadmap, paused work, one next step) and nothing more. Name every `unknown` source once on the last line.
|
|
189
|
+
- The reply is the three opener lines and the five-part brief (summary, session cards, roadmap, paused work, one next step) and nothing more. Name every `unknown` source once on the last line.
|
|
182
190
|
- Start nothing: the user's next reply starts the next step, not this turn.
|
|
183
191
|
|
|
184
192
|
## Pitfalls
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
# lit-adhd brief contract
|
|
2
2
|
|
|
3
|
-
The brief the model writes from one collector run.
|
|
4
|
-
session, roadmap timeline, paused work, one next step. Dense, pretty,
|
|
3
|
+
The brief the model writes from one collector run. Three opener lines, then five parts, in order:
|
|
4
|
+
headline, one card per session, roadmap timeline, paused work, one next step. Dense, pretty,
|
|
5
|
+
plain words.
|
|
6
|
+
|
|
7
|
+
## The opener
|
|
8
|
+
|
|
9
|
+
Three lines before the headline, each from records only:
|
|
10
|
+
|
|
11
|
+
- `Done so far:` (`지금까지:`) — this worktree's newest commits in the window (two at most), then
|
|
12
|
+
its finished start-work runs (`plans/<f>.md (start-work complete)`) and its plans with every
|
|
13
|
+
row ticked, three items in all. With none: "nothing finished in this window".
|
|
14
|
+
- `Doing now:` (`지금:`) — this session's active discipline frame, else its own running
|
|
15
|
+
start-work with the gauge, else the newest active session's goal, else another session's
|
|
16
|
+
running start-work marked `(another session)`. With none: "nothing running".
|
|
17
|
+
- `Next:` (`다음:`) — the same one step the box at the end names, with its slash command.
|
|
18
|
+
|
|
19
|
+
The plain-text render prints the three lines as they are; Hermes shows them as plain text, so no
|
|
20
|
+
fence or tag wraps them.
|
|
5
21
|
|
|
6
22
|
## Forbidden glyphs (A)
|
|
7
23
|
|
|
@@ -27,17 +43,21 @@ surfaces; a session id with no recorded value reads `unknown`.
|
|
|
27
43
|
age in the largest unit under an `ago` header, done as commit count or tools used; more
|
|
28
44
|
ended sessions than fit read `…(+N more ended)`. Hermes records no changed-file list,
|
|
29
45
|
so the table never shows one.
|
|
30
|
-
3. **Roadmap timeline.** `✓`
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
3. **Roadmap timeline.** `✓` landed commits with dates, `▶` the current item of each open plan
|
|
47
|
+
with its gauge, `○` remaining names, then litgoal objectives with gauges. Other worktrees'
|
|
48
|
+
plans carry their worktree name. `plansOmitted` above zero is stated. Only plans changed
|
|
49
|
+
inside the window are listed, this worktree's included: a stale plan is left out. A plan
|
|
50
|
+
with every row ticked is finished work (`finished.plans`, named in the opener), never roadmap.
|
|
33
51
|
4. **Paused work.** Each paused bounded-work run with its gauge and an approval offer in words.
|
|
34
52
|
Approval stays with the user: a chat answer approves or denies, and only the bounded-work
|
|
35
53
|
grant settles it. The brief never starts, approves, or denies anything itself.
|
|
36
54
|
5. **One next step in a box.** Exactly the `next` record: what it does, then the slash command
|
|
37
55
|
(`/start-work plans/<f>.md`) when `next.command` names one, otherwise "continue in chat".
|
|
38
|
-
When `next` is `none`, the
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
When `next` is `none`, the first item under `## Next` in this worktree's own HANDOFF.md
|
|
57
|
+
(`handoffs[].next`, `local: true`) comes first (`HANDOFF.md Next: <item>`); without one the
|
|
58
|
+
box shows the suggestion — a waiting session to answer, an open plan item, another active
|
|
59
|
+
session to check, another ended one to pick up — and the headline names it; it is shown,
|
|
60
|
+
never recorded. Another worktree's handoff never supplies the step. Never a second
|
|
41
61
|
candidate, never a `go` to type: Hermes has no typed `go` command.
|
|
42
62
|
|
|
43
63
|
## Surface notes (C, D)
|
|
@@ -51,5 +71,8 @@ surfaces; a session id with no recorded value reads `unknown`.
|
|
|
51
71
|
|
|
52
72
|
- Name every `unknown` source once on the last line (`unknown: herdr, git`).
|
|
53
73
|
- Say "older sessions were not read" when `budget.exhausted` is true.
|
|
74
|
+
- Say "N plans were not read (read budget used up)" when `roadmap.plansSkipped` is above zero.
|
|
75
|
+
Plans are read before session transcripts, so this happens only when the plans themselves
|
|
76
|
+
fill the 8 MiB budget.
|
|
54
77
|
- Name the collector failure and stop with `BLOCKED:` when the collector fails; never rebuild
|
|
55
78
|
the brief from memory, from the session store, or from a guess.
|
|
@@ -12,6 +12,8 @@ verbatim; only the connecting words change with the language. The JSON excerpt c
|
|
|
12
12
|
{ "id8": "2fad84a7", "title": "schematic lane", "asked": ["render the figure"], "done": { "tools": { "bash": 4 }, "commits": [] }, "status": "ended", "progress": { "gauge": "[▉▉▉] 3/3", "remaining": [] } }
|
|
13
13
|
],
|
|
14
14
|
"roadmap": { "progress": { "gauge": "[▉▉▉▉░░░░░░] 10/20" }, "plans": [{ "plan": "plans/lit-adhd.md", "local": true, "current": "11. Skill lit-adhd", "progress": { "gauge": "[▉▉▉▉░░░░░░] 10/19" } }], "plansOmitted": 0 },
|
|
15
|
+
"finished": { "runs": [], "plans": [] },
|
|
16
|
+
"commits": [{ "hash": "bdea372", "at": "2026-10-25T09:12:00Z", "subject": "feat(adhd): collect cross-session records" }],
|
|
15
17
|
"startWork": [{ "id8": "f06fb727", "workId": "work-abc", "status": "paused", "pendingBoundary": { "boundary": "b-1" } }],
|
|
16
18
|
"next": { "kind": "approve-boundary", "target": { "workId": "work-abc" }, "say": null },
|
|
17
19
|
"sources": { "herdr": "unknown" }
|
|
@@ -20,6 +22,10 @@ verbatim; only the connecting words change with the language. The JSON excerpt c
|
|
|
20
22
|
|
|
21
23
|
## English
|
|
22
24
|
|
|
25
|
+
> Done so far: feat(adhd): collect cross-session records
|
|
26
|
+
> Doing now: pptx lane
|
|
27
|
+
> Next: approve boundary: work-abc. Answer in chat.
|
|
28
|
+
>
|
|
23
29
|
> lit-adhd: 3 session(s) · 1 waiting on you · next: approve boundary
|
|
24
30
|
>
|
|
25
31
|
> == ● waiting on you · 655f39d3 · ADHD lane
|
|
@@ -50,6 +56,10 @@ verbatim; only the connecting words change with the language. The JSON excerpt c
|
|
|
50
56
|
|
|
51
57
|
## 한국어
|
|
52
58
|
|
|
59
|
+
> 지금까지: feat(adhd): collect cross-session records
|
|
60
|
+
> 지금: pptx lane
|
|
61
|
+
> 다음: approve boundary: work-abc. 채팅으로 답하세요.
|
|
62
|
+
>
|
|
53
63
|
> lit-adhd: 세션 3개 · 나를 기다리는 것 1 · 다음: approve boundary
|
|
54
64
|
>
|
|
55
65
|
> == ● 나를 기다림 · 655f39d3 · ADHD lane
|
|
@@ -82,5 +92,6 @@ verbatim; only the connecting words change with the language. The JSON excerpt c
|
|
|
82
92
|
|
|
83
93
|
- They do not estimate the pptx lane's progress: with no count it reads `[…] running`, copied as is.
|
|
84
94
|
- They do not describe work the records do not show: the pptx lane's "did" line names tools and commits only.
|
|
85
|
-
- They offer one next step only, and do not start it.
|
|
95
|
+
- They offer one next step only, and do not start it; the opener's `Next:` line and the box name the same step.
|
|
96
|
+
- Their `Done so far:` line holds only records: commits, finished start-work runs and fully ticked plans. With none, it reads "nothing finished in this window".
|
|
86
97
|
- They never show `?`, `?/?`, `[???]` or `k/?`: an unknown total is the pulse line, and the examples carry none of the four.
|
|
@@ -14,6 +14,22 @@ with the words below and never invents a fifth.
|
|
|
14
14
|
|
|
15
15
|
Order in the brief: waiting sessions first, then active, ended and unknown; newest first inside each group.
|
|
16
16
|
|
|
17
|
+
## Start-work statuses
|
|
18
|
+
|
|
19
|
+
`startWork[].status` describes a run, not a session:
|
|
20
|
+
|
|
21
|
+
| Status | Set when |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `active` | A start-work run whose plan still has an unticked row, or bounded work in progress |
|
|
24
|
+
| `paused` | Bounded work stopped at a boundary that waits on a chat answer |
|
|
25
|
+
| `done` | A start-work run whose plan has every row ticked |
|
|
26
|
+
| `completed` | Bounded work the owner completed |
|
|
27
|
+
|
|
28
|
+
A `done` or `completed` run inside the window is listed in `finished.runs` and named on the
|
|
29
|
+
opener's `Done so far:` line. A session itself never reads `done` on Hermes: a start-work run
|
|
30
|
+
record carries no session id, and completed bounded work clears its owner, so no record ties a
|
|
31
|
+
finished run to the session that ran it. Sessions keep the four statuses above.
|
|
32
|
+
|
|
17
33
|
## Sources
|
|
18
34
|
|
|
19
35
|
Each entry of `sources` is `ok` or `unknown`. An `unknown` source is named once on the brief's last
|
|
@@ -24,7 +40,7 @@ line and never filled in from memory.
|
|
|
24
40
|
| `sessions` | Hermes `state.db` sessions |
|
|
25
41
|
| `plans` | `plans/*.md` checkbox rows |
|
|
26
42
|
| `litgoal` | `.hermes/lithermes/litgoal/*/goals.json` |
|
|
27
|
-
| `handoffs` | `HANDOFF*.md` and `.handoff/HANDOFF.md` headings |
|
|
43
|
+
| `handoffs` | `HANDOFF*.md` and `.handoff/HANDOFF.md` headings, plus the first `## Next` item of this worktree's own |
|
|
28
44
|
| `git` | `git log --since` |
|
|
29
45
|
| `workflowStack` | `.hermes/lithermes/adhd/stack.json` |
|
|
30
46
|
| `startWork` | start-work runs and bounded-work states |
|
|
@@ -68,14 +68,15 @@ const entries = (directory) => {
|
|
|
68
68
|
|
|
69
69
|
const defaultExec = (file, args, options) => execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...options });
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// The nearest `.git` (a directory, or the file a linked worktree carries) bounds the state root,
|
|
72
|
+
// so a worktree nested inside another checkout never reads that checkout's stale plans.
|
|
73
|
+
const nearestRoot = (startDir) => {
|
|
72
74
|
const start = resolve(startDir);
|
|
73
|
-
let root = null;
|
|
74
75
|
let current = start;
|
|
75
76
|
for (;;) {
|
|
76
|
-
if (existsSync(join(current, '.git')))
|
|
77
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
77
78
|
const parent = dirname(current);
|
|
78
|
-
if (parent === current) return
|
|
79
|
+
if (parent === current) return start;
|
|
79
80
|
current = parent;
|
|
80
81
|
}
|
|
81
82
|
};
|
|
@@ -90,25 +91,41 @@ const worktreesOf = (cwd, exec) => {
|
|
|
90
91
|
}
|
|
91
92
|
};
|
|
92
93
|
|
|
93
|
-
const
|
|
94
|
+
const mtimeOf = (path) => {
|
|
95
|
+
try {
|
|
96
|
+
return lstatSync(path).mtimeMs;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// A plan met once the read budget is spent is counted in `skipped.count`, so the brief can say so.
|
|
103
|
+
const plansIn = (root, budget, redact, local, skipped) => {
|
|
94
104
|
const list = entries(join(root, 'plans'));
|
|
95
105
|
if (!list) return null;
|
|
96
106
|
return list.filter((entry) => entry.isFile() && entry.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
|
|
97
|
-
|
|
107
|
+
if (budget.used >= budget.limit) {
|
|
108
|
+
skipped.count += 1;
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const path = join(root, 'plans', entry.name);
|
|
112
|
+
const text = readSmallFile(path, budget);
|
|
98
113
|
if (text === null) return [];
|
|
99
114
|
// Column-zero `- [ ] N. title` and `- [ ] FN. title` rows; a non-numbered checkbox row
|
|
100
115
|
// still counts, the same way the source collector counts every checkbox row.
|
|
101
116
|
const rows = parsePlanTaskRows(text);
|
|
102
117
|
if (!rows.length) return [];
|
|
103
118
|
const open = rows.filter((row) => !row.checked);
|
|
104
|
-
if (!open.length) return [];
|
|
105
119
|
const done = rows.length - open.length;
|
|
120
|
+
// Every row ticked: kept and marked complete, as finished work for the opener, never roadmap.
|
|
106
121
|
return [{
|
|
107
122
|
worktree: redact(root),
|
|
108
123
|
plan: `plans/${entry.name}`,
|
|
109
124
|
local,
|
|
125
|
+
complete: open.length === 0,
|
|
126
|
+
changedMs: mtimeOf(path),
|
|
110
127
|
rows: rows.map((row) => ({ checked: row.checked, title: clipToWidth(redact(row.text), NAME_MAX) })),
|
|
111
|
-
current: clipToWidth(redact(open[0].text), NAME_MAX),
|
|
128
|
+
current: open.length ? clipToWidth(redact(open[0].text), NAME_MAX) : null,
|
|
112
129
|
progress: progressOf(done, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX))),
|
|
113
130
|
}];
|
|
114
131
|
});
|
|
@@ -147,7 +164,23 @@ const goalsOf = (root, budget, redact) => {
|
|
|
147
164
|
});
|
|
148
165
|
};
|
|
149
166
|
|
|
150
|
-
|
|
167
|
+
// The first item under a `## Next` (or `## Next steps`) heading, its list marker removed.
|
|
168
|
+
const nextItemOf = (text) => {
|
|
169
|
+
let inside = false;
|
|
170
|
+
for (const raw of text.split('\n')) {
|
|
171
|
+
const line = raw.trim();
|
|
172
|
+
if (/^#{1,6}\s/u.test(line)) {
|
|
173
|
+
if (inside) return null;
|
|
174
|
+
inside = /^#{1,6}\s+next(?:\s+steps?)?$/iu.test(line);
|
|
175
|
+
} else if (inside && line) {
|
|
176
|
+
return line.replace(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/u, '');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Only this worktree's own handoff offers a `next`; another worktree's never does.
|
|
183
|
+
const handoffsOf = (root, budget, redact, local) => {
|
|
151
184
|
const files = (entries(root) ?? [])
|
|
152
185
|
.filter((entry) => entry.isFile() && /^HANDOFF.*\.md$/u.test(entry.name))
|
|
153
186
|
.map((entry) => join(root, entry.name));
|
|
@@ -156,7 +189,8 @@ const handoffsOf = (root, budget, redact) => {
|
|
|
156
189
|
return files.map((path) => {
|
|
157
190
|
const text = readSmallFile(path, budget) ?? '';
|
|
158
191
|
const headings = text.split('\n').slice(0, 40).filter((line) => /^#{1,3} /u.test(line)).map((line) => clipToWidth(redact(line), 100));
|
|
159
|
-
|
|
192
|
+
const next = local ? nextItemOf(text) : null;
|
|
193
|
+
return { worktree: redact(root), file: basename(path), headings, local, next: next ? clipToWidth(redact(next), 120) : null };
|
|
160
194
|
});
|
|
161
195
|
};
|
|
162
196
|
|
|
@@ -197,7 +231,7 @@ const stacksOf = (root, budget, redact) => {
|
|
|
197
231
|
};
|
|
198
232
|
|
|
199
233
|
// Hermes start-work state: newest `runs/*/state.json` with `command: start-work` (a run has no
|
|
200
|
-
// status field, so an unfinished plan reads active and a finished plan reads
|
|
234
|
+
// status field, so an unfinished plan reads active and a finished plan reads done), plus
|
|
201
235
|
// bounded-work `work/*/state.json` records, which carry a real status and a pending boundary.
|
|
202
236
|
const startWorkOf = (root, budget, redact, sessionId) => {
|
|
203
237
|
const items = [];
|
|
@@ -218,7 +252,8 @@ const startWorkOf = (root, budget, redact, sessionId) => {
|
|
|
218
252
|
worktree: redact(root),
|
|
219
253
|
workId: `run-${String(newestRun.run_id ?? 'unknown').slice(0, 16)}`,
|
|
220
254
|
plan: plan ? redact(plan) : 'unknown',
|
|
221
|
-
status: rows.length && !open.length ? '
|
|
255
|
+
status: rows.length && !open.length ? 'done' : 'active',
|
|
256
|
+
atMs: Date.parse(String(newestRun.created_at ?? '')),
|
|
222
257
|
sessionId: sessionId ?? null,
|
|
223
258
|
id8: sessionId ? String(sessionId).slice(0, 8) : 'unknown',
|
|
224
259
|
progress: rows.length
|
|
@@ -246,6 +281,7 @@ const startWorkOf = (root, budget, redact, sessionId) => {
|
|
|
246
281
|
workId: typeof state.work_id === 'string' ? state.work_id : entry.name,
|
|
247
282
|
plan: plan ? redact(plan) : 'unknown',
|
|
248
283
|
status,
|
|
284
|
+
atMs: Date.parse(String(state.updated_at ?? state.created_at ?? '')),
|
|
249
285
|
sessionId: owner,
|
|
250
286
|
id8: owner ? owner.slice(0, 8) : 'unknown',
|
|
251
287
|
...(boundary ? { pendingBoundary: { boundary: clipToWidth(redact(String(boundary.id ?? 'unknown')), NAME_MAX) } } : {}),
|
|
@@ -270,6 +306,8 @@ const herdrOf = (env, exec, redact) => {
|
|
|
270
306
|
};
|
|
271
307
|
|
|
272
308
|
const known = (list) => (list === null ? 'unknown' : 'ok');
|
|
309
|
+
// A run records its plan as an absolute path; inside its own worktree it reads `plans/<f>.md`.
|
|
310
|
+
const planInWorktree = (worktree, plan) => (plan.startsWith(`${worktree}/`) ? plan.slice(worktree.length + 1) : plan);
|
|
273
311
|
const statusRank = { 'waiting-on-user': 0, active: 1, ended: 2, unknown: 3 };
|
|
274
312
|
|
|
275
313
|
/**
|
|
@@ -293,10 +331,27 @@ export const collectAdhd = ({
|
|
|
293
331
|
const sinceMs = parseSince(since) ?? parseSince(DEFAULT_SINCE);
|
|
294
332
|
const limit = Number.isInteger(max) && max > 0 ? Math.min(max, 50) : DEFAULT_MAX;
|
|
295
333
|
const start = normalizeRoot(resolve(cwd));
|
|
296
|
-
const stateRoot =
|
|
334
|
+
const stateRoot = nearestRoot(start);
|
|
297
335
|
const listed = here ? null : worktreesOf(start, exec);
|
|
298
336
|
const roots = [...new Set([start, ...(here ? [] : listed ?? [stateRoot])])];
|
|
299
337
|
|
|
338
|
+
// The small on-disk records first, so the session transcripts read after them cannot spend
|
|
339
|
+
// the budget a plan or handoff needs.
|
|
340
|
+
const collectEach = (read) => {
|
|
341
|
+
const results = roots.map(read);
|
|
342
|
+
return results.every((value) => value === null) ? null : results.flatMap((value) => value ?? []);
|
|
343
|
+
};
|
|
344
|
+
const isLocal = (root) => resolve(root) === stateRoot || resolve(root) === start;
|
|
345
|
+
const plansSkipped = { count: 0 };
|
|
346
|
+
const plans = collectEach((root) => plansIn(root, budget, redact, isLocal(root), plansSkipped));
|
|
347
|
+
const litgoal = collectEach((root) => {
|
|
348
|
+
const goals = goalsOf(root, budget, redact);
|
|
349
|
+
return goals ? goals.map((goal) => ({ ...goal, local: isLocal(root) })) : null;
|
|
350
|
+
});
|
|
351
|
+
const handoffs = roots.flatMap((root) => handoffsOf(root, budget, redact, resolve(root) === start));
|
|
352
|
+
const workflows = collectEach((root) => stacksOf(root, budget, redact));
|
|
353
|
+
const startWork = collectEach((root) => startWorkOf(root, budget, redact, sessionId));
|
|
354
|
+
|
|
300
355
|
const found = findHermesSessions(hermesStorePath(env), roots, { exec });
|
|
301
356
|
const sessions = [];
|
|
302
357
|
for (const entry of found.sessions) {
|
|
@@ -308,19 +363,7 @@ export const collectAdhd = ({
|
|
|
308
363
|
sessions.push(session);
|
|
309
364
|
}
|
|
310
365
|
|
|
311
|
-
const collectEach = (read) => {
|
|
312
|
-
const results = roots.map(read);
|
|
313
|
-
return results.every((value) => value === null) ? null : results.flatMap((value) => value ?? []);
|
|
314
|
-
};
|
|
315
|
-
const plans = collectEach((root) => plansIn(root, budget, redact, resolve(root) === stateRoot || resolve(root) === start));
|
|
316
|
-
const litgoal = collectEach((root) => {
|
|
317
|
-
const goals = goalsOf(root, budget, redact);
|
|
318
|
-
return goals ? goals.map((goal) => ({ ...goal, local: resolve(root) === stateRoot || resolve(root) === start })) : null;
|
|
319
|
-
});
|
|
320
|
-
const handoffs = roots.flatMap((root) => handoffsOf(root, budget, redact));
|
|
321
366
|
const commits = collectEach((root) => commitsOf(root, sinceMs, now, exec, redact));
|
|
322
|
-
const workflows = collectEach((root) => stacksOf(root, budget, redact));
|
|
323
|
-
const startWork = collectEach((root) => startWorkOf(root, budget, redact, sessionId));
|
|
324
367
|
const herdr = herdrOf(env, exec, redact);
|
|
325
368
|
|
|
326
369
|
// A session waits on the user when it asked something, or holds a paused boundary.
|
|
@@ -340,9 +383,13 @@ export const collectAdhd = ({
|
|
|
340
383
|
}
|
|
341
384
|
sessions.sort((a, b) => statusRank[a.status] - statusRank[b.status] || (b.lastMs ?? 0) - (a.lastMs ?? 0));
|
|
342
385
|
|
|
343
|
-
//
|
|
386
|
+
// Open plans changed inside the window are listed, this worktree's first, then the most recently
|
|
387
|
+
// changed, up to a short cap; a stale plan is left out here and elsewhere. The roadmap gauge
|
|
344
388
|
// counts this worktree only, since a sum over unrelated branches measures nothing.
|
|
345
|
-
const
|
|
389
|
+
const recent = (plan) => plan.changedMs !== null && now - plan.changedMs <= sinceMs;
|
|
390
|
+
const orderedPlans = (plans ?? [])
|
|
391
|
+
.filter((plan) => !plan.complete && recent(plan))
|
|
392
|
+
.sort((a, b) => Number(b.local) - Number(a.local) || (b.changedMs ?? 0) - (a.changedMs ?? 0));
|
|
346
393
|
const shownPlans = orderedPlans.slice(0, PLAN_LIMIT);
|
|
347
394
|
const localGoals = (litgoal ?? []).filter((goal) => goal.local);
|
|
348
395
|
const roadmapItems = [...orderedPlans.filter((plan) => plan.local).map((plan) => plan.progress), ...localGoals.map((goal) => goal.progress)];
|
|
@@ -370,9 +417,18 @@ export const collectAdhd = ({
|
|
|
370
417
|
roadmap: {
|
|
371
418
|
plans: shownPlans,
|
|
372
419
|
plansOmitted: orderedPlans.length - shownPlans.length,
|
|
420
|
+
plansSkipped: plansSkipped.count,
|
|
373
421
|
litgoal: litgoal ?? [],
|
|
374
422
|
progress: roadmapItems.length ? aggregateProgress(roadmapItems) : progressOf(null, null),
|
|
375
423
|
},
|
|
424
|
+
// Finished inside the window: start-work runs whose plan is fully ticked, completed bounded
|
|
425
|
+
// work, and plans with every row ticked.
|
|
426
|
+
finished: {
|
|
427
|
+
runs: (startWork ?? [])
|
|
428
|
+
.filter((work) => ['done', 'completed'].includes(work.status) && Number.isFinite(work.atMs) && now - work.atMs <= sinceMs)
|
|
429
|
+
.map(({ worktree, plan, workId }) => ({ worktree, plan: planInWorktree(worktree, plan), workId })),
|
|
430
|
+
plans: (plans ?? []).filter((plan) => plan.complete && recent(plan)).map(({ worktree, plan, local, progress }) => ({ worktree, plan, local, gauge: progress.gauge })),
|
|
431
|
+
},
|
|
376
432
|
workflows: (workflows ?? []).map((frame) => ({ ...frame, self: Boolean(sessionId) && frame.sessionId === sessionId })),
|
|
377
433
|
startWork: startWork ?? [],
|
|
378
434
|
handoffs,
|
|
@@ -385,7 +441,7 @@ export const collectAdhd = ({
|
|
|
385
441
|
if (record && sessionId) {
|
|
386
442
|
try {
|
|
387
443
|
// The record lives beside the working directory the hook reads (`go`
|
|
388
|
-
// uses `Path.cwd()`), not at the
|
|
444
|
+
// uses `Path.cwd()`), not at the git root: a brief run from a
|
|
389
445
|
// subdirectory must be continued from that same directory.
|
|
390
446
|
writeNextStep(start, sessionId, next, new Date(now));
|
|
391
447
|
data.nextRecorded = true;
|
|
@@ -394,7 +450,11 @@ export const collectAdhd = ({
|
|
|
394
450
|
}
|
|
395
451
|
}
|
|
396
452
|
for (const frame of data.workflows) delete frame.sessionId;
|
|
397
|
-
for (const work of data.startWork)
|
|
453
|
+
for (const work of data.startWork) {
|
|
454
|
+
work.self = Boolean(sessionId) && work.sessionId === sessionId;
|
|
455
|
+
delete work.sessionId;
|
|
456
|
+
delete work.atMs;
|
|
457
|
+
}
|
|
398
458
|
return data;
|
|
399
459
|
};
|
|
400
460
|
|
|
@@ -474,15 +534,56 @@ const endedTable = (sessions, nowMs) => {
|
|
|
474
534
|
return ['| id | where | goal | ago | done |', '|---|---|---|---|---|', ...rows, ...more];
|
|
475
535
|
};
|
|
476
536
|
|
|
477
|
-
/**
|
|
537
|
+
/**
|
|
538
|
+
* The three opening lines, from records only. Done: this worktree's newest commits in the window
|
|
539
|
+
* (two at most), then its finished start-work runs and fully ticked plans (three items in all).
|
|
540
|
+
* Doing: this session's active frame, else its own running start-work, else the newest active
|
|
541
|
+
* session's goal, else another session's running start-work. Next: the same step the box names.
|
|
542
|
+
*/
|
|
543
|
+
const openerLines = (data, nextText) => {
|
|
544
|
+
const local = data.scope.worktrees[0];
|
|
545
|
+
const runs = (data.finished?.runs ?? []).filter((run) => run.worktree === local);
|
|
546
|
+
const runPlans = new Set(runs.map((run) => run.plan));
|
|
547
|
+
const finished = [
|
|
548
|
+
...(data.commits ?? []).filter((commit) => commit.worktree === local).slice(0, 2).map((commit) => commit.subject),
|
|
549
|
+
...runs.map((run) => `${run.plan} (start-work complete)`),
|
|
550
|
+
...(data.finished?.plans ?? []).filter((plan) => plan.worktree === local && !runPlans.has(plan.plan)).map((plan) => `${plan.plan} ${plan.gauge}`),
|
|
551
|
+
];
|
|
552
|
+
const done = [...new Set(finished)].slice(0, 3).join('; ') || 'nothing finished in this window';
|
|
553
|
+
const frame = (data.workflows ?? []).filter((item) => item.self && item.status === 'active').at(-1);
|
|
554
|
+
const running = (data.startWork ?? []).filter((item) => item.status === 'active');
|
|
555
|
+
const work = running.find((item) => item.self);
|
|
556
|
+
const active = (data.sessions ?? []).find((session) => session.status === 'active');
|
|
557
|
+
const doing = frame
|
|
558
|
+
? [frame.skill, frame.stage].filter((part) => part && part !== 'unknown').join(' · ')
|
|
559
|
+
: work
|
|
560
|
+
? `start-work ${planInWorktree(work.worktree, work.plan)} ${work.progress.gauge}`
|
|
561
|
+
: active
|
|
562
|
+
? clipToWidth(goalOf(active), 80)
|
|
563
|
+
: running[0]
|
|
564
|
+
? `start-work ${planInWorktree(running[0].worktree, running[0].plan)} ${running[0].progress.gauge} (another session)`
|
|
565
|
+
: 'nothing running';
|
|
566
|
+
return [`Done so far: ${done}`, `Doing now: ${doing}`, `Next: ${nextText}`];
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/** Plain-text brief: opener, headline, cards for live sessions, ended table, roadmap, one next step in a box. */
|
|
478
570
|
export const renderAdhd = (data, width = 100, sessionId = null) => {
|
|
479
571
|
const out = [];
|
|
480
572
|
const push = (text) => out.push(line(text, width));
|
|
481
573
|
const recorded = data.next.kind !== 'none';
|
|
482
|
-
|
|
574
|
+
// With no step on record, this worktree's HANDOFF.md `## Next` item comes before any suggestion.
|
|
575
|
+
const handoffNext = (data.handoffs ?? []).find((handoff) => handoff.local && handoff.next)?.next;
|
|
576
|
+
const step = recorded
|
|
577
|
+
? data.next
|
|
578
|
+
: handoffNext
|
|
579
|
+
? { kind: 'suggest', label: 'handoff next', say: `HANDOFF.md Next: ${handoffNext}`, command: null }
|
|
580
|
+
: suggestNext(data, sessionId);
|
|
483
581
|
const stepName = recorded ? data.next.kind.replace(/-/gu, ' ') : step.label;
|
|
484
582
|
const stepSay = recorded && data.next.say ? ` (say "${data.next.say}")` : '';
|
|
485
583
|
const stepRun = step.command ? ` (run ${step.command})` : recorded ? '' : ' (continue in chat)';
|
|
584
|
+
const target = recorded ? data.next.target?.plan ?? data.next.target?.workId ?? '' : '';
|
|
585
|
+
const nextText = recorded ? `${stepName}${target ? `: ${target}` : ''}${stepSay}${stepRun}` : `${step.say}${stepRun}`;
|
|
586
|
+
for (const opener of openerLines(data, nextText)) push(opener);
|
|
486
587
|
push(`lit-adhd: ${data.summary.sessions} session(s) · ${data.summary.waitingOnUser} waiting on you · next: ${stepName}${stepSay}${stepRun}`);
|
|
487
588
|
const live = data.sessions.filter((session) => session.status !== 'ended');
|
|
488
589
|
const ended = data.sessions.filter((session) => session.status === 'ended');
|
|
@@ -512,16 +613,11 @@ export const renderAdhd = (data, width = 100, sessionId = null) => {
|
|
|
512
613
|
for (const goal of data.roadmap.litgoal) push(`○ litgoal ${goal.progress.gauge} ${goal.objective}`);
|
|
513
614
|
for (const commit of data.commits.slice(0, 5)) push(`✓ ${commit.hash} ${commit.at.slice(0, 10)} ${commit.subject}`);
|
|
514
615
|
}
|
|
515
|
-
{
|
|
516
|
-
if (recorded) {
|
|
517
|
-
const target = data.next.target?.plan ?? data.next.target?.workId ?? '';
|
|
518
|
-
push(`-- next --\n> ${data.next.kind.replace(/-/gu, ' ')}${target ? `: ${target}` : ''}${stepSay}${stepRun}.`);
|
|
519
|
-
} else {
|
|
520
|
-
push(`-- next --\n> ${step.say}${stepRun}.`);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
616
|
+
push(`-- next --\n> ${nextText}.`);
|
|
523
617
|
const unknownSources = Object.entries(data.sources).filter(([, status]) => status !== 'ok').map(([name]) => name);
|
|
524
618
|
if (unknownSources.length) push(`unknown: ${unknownSources.join(', ')}`);
|
|
619
|
+
const skipped = data.roadmap.plansSkipped ?? 0;
|
|
620
|
+
if (skipped) push(`${skipped} ${skipped === 1 ? 'plan was' : 'plans were'} not read (read budget used up)`);
|
|
525
621
|
return `${out.join('\n')}\n`;
|
|
526
622
|
};
|
|
527
623
|
|
|
@@ -446,6 +446,30 @@ must carry an **action / output / verification** contract:
|
|
|
446
446
|
7. **Commit, when approved and in scope**: a Conventional Commit message in
|
|
447
447
|
the form `<type>(<scope>): <imperative>`.
|
|
448
448
|
|
|
449
|
+
### Scope Roots For Outside Folders
|
|
450
|
+
|
|
451
|
+
When a todo names an absolute path outside the project (a sibling checkout, a
|
|
452
|
+
shared data folder, a scratch directory), add one column-zero line per folder
|
|
453
|
+
near the top of the plan, before you save it:
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
Scope roots: <path> (<actions>)
|
|
457
|
+
Scope roots: /srv/shared/fixtures (read)
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`<actions>` is a comma list such as `read`, `write`, `execute`. Name the
|
|
461
|
+
narrowest existing folder: the filesystem root, a top-level folder and the bare
|
|
462
|
+
home folder never count as cover. Paths inside fenced blocks, inline code spans
|
|
463
|
+
that hold a whole command, and sections other than `## Todos` are not checked.
|
|
464
|
+
|
|
465
|
+
On Hermes this is **advisory**. Bounded work (`/lit-loop init --grant
|
|
466
|
+
ACTION@ROOT`) only grants roots inside its own worktree, so a Scope roots line
|
|
467
|
+
cannot become a grant. It tells the reviewer and the user, before execution,
|
|
468
|
+
which outside folders the todos will touch. `/start-work` (and its `--dry-run`)
|
|
469
|
+
runs the same check and lists every todo path no line covers, with the line to
|
|
470
|
+
add, under "Scope roots advisory"; it does not block the run. Settle those
|
|
471
|
+
folders with the user before the todo that reaches them.
|
|
472
|
+
|
|
449
473
|
### Failure And Decision Branches
|
|
450
474
|
|
|
451
475
|
Add **failure and decision branches** only where outcomes genuinely change the
|
|
@@ -534,10 +558,18 @@ Once both passes clear, output:
|
|
|
534
558
|
4. The handoff line — this bridges to the execution loop:
|
|
535
559
|
|
|
536
560
|
```
|
|
537
|
-
Plan is ready.
|
|
561
|
+
Plan is ready: plans/<slug>.md. To start it, type `go` within 30 minutes
|
|
562
|
+
(I will show the /start-work command), or run /start-work plans/<slug>.md yourself.
|
|
538
563
|
The plan path will appear in each execution commit footer: Plan: plans/<slug>.md
|
|
539
564
|
```
|
|
540
565
|
|
|
566
|
+
Saving `plans/<slug>.md` in this lit-plan turn is what arms `go`: the
|
|
567
|
+
`post_tool_call` hook records a `start-plan` next step for this session only
|
|
568
|
+
(`.hermes/lithermes/adhd/next-<session>.json`). A `go` from another session, or
|
|
569
|
+
more than 30 minutes later, finds nothing to continue. `go` only presents the
|
|
570
|
+
`/start-work` command for the user to run; it never starts execution, so the
|
|
571
|
+
approval gate above still holds. Save the plan only after the gate clears.
|
|
572
|
+
|
|
541
573
|
Do **not** reproduce the full plan in the chat output — it is already written to
|
|
542
574
|
disk. If the user wants to read it, they can open `plans/<slug>.md`.
|
|
543
575
|
|
|
@@ -485,7 +485,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
|
|
|
485
485
|
|
|
486
486
|
```text
|
|
487
487
|
$ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
|
|
488
|
-
Installed LitHermes 1.0.
|
|
488
|
+
Installed LitHermes 1.0.32
|
|
489
489
|
plugin: $ISOLATED/plugins/lithermes
|
|
490
490
|
model config: updated
|
|
491
491
|
|
|
@@ -502,7 +502,7 @@ installed payload: PASS
|
|
|
502
502
|
enabled config: PASS
|
|
503
503
|
|
|
504
504
|
$ hermes lithermes doctor
|
|
505
|
-
[OK] plugin.yaml readable (version 1.0.
|
|
505
|
+
[OK] plugin.yaml readable (version 1.0.32)
|
|
506
506
|
[OK] skills bundled: 40
|
|
507
507
|
[OK] litgoal durable runtime importable
|
|
508
508
|
```
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.32"><title>release: 1.0.32</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.32</text></g></svg>
|