@litfamily/litmuse 0.1.2 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 — 2026-10-28
4
+
5
+ - After you save a plan, typing `go` within 30 minutes starts it. A plan saved by another session, or an older one, does not.
6
+ - lit-plan checks that every command touching a path outside the project is covered by a `Scope roots:` line, and start-work points to that check. A command quoted in a code block or inline code is treated as text and does not count.
7
+ - The LIT card is clearer about other sessions: running ones come first, then idle ones, each named by its skill and, inside herdr, its pane. A finished start-work stays as done for a minute and then leaves. When a newer LitMuse is installed than the one this session loaded, the card says `restart to <version>`.
8
+ - `lit-adhd` reads your plans before transcripts, keeps finished plans as complete, picks up the `## Next` section of this worktree's handoff, marks sessions as done or idle, and opens with a three-line summary. Its next step no longer repeats one that is already done.
9
+ - A linked git worktree keeps its own `.litmuse` state instead of writing into the main checkout's.
10
+ - New `TRADEMARK.md`: the code stays MIT; the LitMuse and LitFamily names and art are covered there.
11
+ - The hook code changed, so Muse asks you to approve the LitMuse hooks again after the update; `npx @litfamily/litmuse install` lists them and asks once.
12
+
3
13
  ## 0.1.2 — 2026-10-27
4
14
 
5
15
  - Three new skills, so LitMuse now has 42. `lit-schematic` draws P&IDs with ISA-5.1 tags and control loops, apparatus figures for papers and isometric cutaways, as editable SVG with a QA gate. `lit-hijack` studies a tool you point it at and rebuilds what you want from it clean-room, with a gate after each phase. `lit-adhd` (also `lit-rewind`) is a briefing across your recent Muse sessions: what each one asked, did and is waiting on, with a single next step that a bare `go` picks up.
package/README.md CHANGED
@@ -178,7 +178,7 @@ The document, diagram, README-cover and figure skills ship pinned engines (`offi
178
178
 
179
179
  **4 commands**: `/lit <route> <task>`, `/lit-korean <file or text>`, `/lit-status`, `/lit-hud`.
180
180
 
181
- **10 hooks**: SessionStart (LitMuse context and routes; after a compaction it re-injects the saved handoff once, and it adds the answer style you chose with `litmuse style`), UserPromptSubmit (`lit` activation; a later `lit` while its route is active continues it, `lit <other route>` switches, a bare `lit` resumes a started plan, `lit new` or `lit restart` starts over), PreToolUse (during lit-plan: writes through write_file, edit_file, apply_patch or delete_file stay inside `plans/` and `.litmuse/`, shell commands are read-only, and only read-only LitMuse agents may be spawned; on every route: guards against clustered negations and clustered hedges that a write adds to Markdown or text prose), PostToolUse (the tool ledger, plus the whole-line comments an edit added for comment-checker and an advisory lit-humanizer check on written prose; on the motion route it also notes each render and each image `read_file` opened), PostToolUseFailure (ledger), Stop (a lit-plan turn ends after its plan file exists; a motion turn ends when the skill's done-check passes on the last render, or after two holds with a plain not-done notice; start-work keeps going while plan rows are open and litgoal while criteria are open, each at most 3 holds in a row without progress), PreCompact (writes `.litmuse/handoff/latest.md` before Muse compacts), SubagentStart (the return format for child agents), SubagentStop and SessionEnd (ledger lines). The hooks also keep a live record of the route, plan progress and running child agents, which `/lit-status` and `npx @litfamily/litmuse status --card` show, because Muse's `/hud` shows fixed fields only.
181
+ **10 hooks**: SessionStart (LitMuse context and routes; after a compaction it re-injects the saved handoff once, and it adds the answer style you chose with `litmuse style`), UserPromptSubmit (`lit` activation; a later `lit` while its route is active continues it, `lit <other route>` switches, a bare `lit` resumes a started plan, `lit new` or `lit restart` starts over), PreToolUse (during lit-plan: writes through write_file, edit_file, apply_patch or delete_file stay inside `plans/` and `.litmuse/`, shell commands are read-only, and only read-only LitMuse agents may be spawned; on every route: guards against clustered negations and clustered hedges that a write adds to Markdown or text prose), PostToolUse (the tool ledger, plus the whole-line comments an edit added for comment-checker and an advisory lit-humanizer check on written prose; on the motion route it also notes each render and each image `read_file` opened), PostToolUseFailure (ledger), Stop (a lit-plan turn ends after its plan file exists, and a one-word `go` in the same session within 30 minutes then starts that plan with start-work; a motion turn ends when the skill's done-check passes on the last render, or after two holds with a plain not-done notice; start-work keeps going while plan rows are open and litgoal while criteria are open, each at most 3 holds in a row without progress), PreCompact (writes `.litmuse/handoff/latest.md` before Muse compacts), SubagentStart (the return format for child agents), SubagentStop and SessionEnd (ledger lines). The hooks also keep a live record of the route, plan progress and running child agents, plus each session's liveness (PostToolUse says alive, Stop idle, SessionEnd ended), which `/lit-status` and `npx @litfamily/litmuse status --card` show, because Muse's `/hud` shows fixed fields only. The card lists other sessions of the project running first, then idle, keeps a finished start-work as done for a minute, names a herdr pane session `<skill> · <pane>`, and says `restart to <version>` when a newer LitMuse was installed after the session started.
182
182
 
183
183
  **11 agents**, spawned as `litmuse/<name>`: lit-planner, lit-executor, lit-verifier, qa-runner, quality-reviewer, librarian-researcher, and five Korean prose agents. Reviewers and the Korean agents get read and search tools only.
184
184
 
@@ -215,3 +215,5 @@ LitMuse is one of the seven LitFamily packages, and the seven armored robots on
215
215
  ## License
216
216
 
217
217
  MIT, © LitMuse contributors. LitMuse is an independent community project and is not affiliated with or endorsed by Meta.
218
+
219
+ The LitMuse and LitFamily names, logos and mascot art are covered by [TRADEMARK.md](./TRADEMARK.md).
package/README_ko-KR.md CHANGED
@@ -178,7 +178,7 @@ Muse의 `/hud`는 켜고 끄는 고정 항목 목록이라서 플러그인 글
178
178
 
179
179
  **명령 4개**: `/lit <경로> <작업>`, `/lit-korean <파일 또는 글>`, `/lit-status`, `/lit-hud`.
180
180
 
181
- **훅 10개**: SessionStart(LitMuse 컨텍스트와 경로 표. 컴팩션 뒤에는 저장해 둔 인계 메모를 한 번 다시 넣고, `litmuse style`로 고른 답변 스타일도 더함), UserPromptSubmit(맨 앞의 `lit` 활성화. 같은 경로가 이미 켜져 있으면 이어서 진행, 다른 경로면 전환, 맨 `lit`은 시작한 계획을 재개, `lit new`·`lit restart`는 새로 시작), PreToolUse(lit-plan 동안 write_file·edit_file·apply_patch·delete_file 쓰기는 `plans/`와 `.litmuse/` 안으로 제한하고 셸 명령은 읽기 전용만 허용, 자식 에이전트는 읽기 전용 LitMuse 에이전트만 허용, 모든 경로에서 쓰기가 Markdown이나 텍스트 글에 새로 더하는 부정문 몰림과 얼버무림 몰림 검사), PostToolUse(도구 기록, 편집이 새로 더한 한 줄 주석을 comment-checker에 넘기고 쓴 글에는 lit-humanizer 참고 검사. 모션 경로에서는 렌더 실행과 `read_file`로 연 이미지도 기록), PostToolUseFailure(기록), Stop(lit-plan 턴은 계획 파일이 생긴 뒤 끝남. 모션 턴은 마지막 렌더가 스킬의 완료 검사를 통과하면 끝나고, 두 번 붙잡은 뒤에는 아직 끝나지 않았다고 분명히 말하게 함. start-work는 계획 행이, litgoal은 기준이 남아 있으면 이어 가되 진전 없이 연속 3번까지만 붙잡음), PreCompact(Muse가 컴팩션하기 전에 `.litmuse/handoff/latest.md`를 씀), SubagentStart(자식 에이전트의 보고 형식), SubagentStop·SessionEnd(기록 줄). 훅은 경로, 계획 진행, 실행 중인 자식 에이전트도 실시간 기록으로 남기며, Muse의 `/hud`는 고정 항목만 보여 주므로 이 기록은 `/lit-status`와 `npx @litfamily/litmuse status --card`로 봅니다.
181
+ **훅 10개**: SessionStart(LitMuse 컨텍스트와 경로 표. 컴팩션 뒤에는 저장해 둔 인계 메모를 한 번 다시 넣고, `litmuse style`로 고른 답변 스타일도 더함), UserPromptSubmit(맨 앞의 `lit` 활성화. 같은 경로가 이미 켜져 있으면 이어서 진행, 다른 경로면 전환, 맨 `lit`은 시작한 계획을 재개, `lit new`·`lit restart`는 새로 시작), PreToolUse(lit-plan 동안 write_file·edit_file·apply_patch·delete_file 쓰기는 `plans/`와 `.litmuse/` 안으로 제한하고 셸 명령은 읽기 전용만 허용, 자식 에이전트는 읽기 전용 LitMuse 에이전트만 허용, 모든 경로에서 쓰기가 Markdown이나 텍스트 글에 새로 더하는 부정문 몰림과 얼버무림 몰림 검사), PostToolUse(도구 기록, 편집이 새로 더한 한 줄 주석을 comment-checker에 넘기고 쓴 글에는 lit-humanizer 참고 검사. 모션 경로에서는 렌더 실행과 `read_file`로 연 이미지도 기록), PostToolUseFailure(기록), Stop(lit-plan 턴은 계획 파일이 생긴 뒤 끝나고, 같은 세션에서 30분 안에 `go` 한 단어를 입력하면 그 계획을 start-work로 시작. 모션 턴은 마지막 렌더가 스킬의 완료 검사를 통과하면 끝나고, 두 번 붙잡은 뒤에는 아직 끝나지 않았다고 분명히 말하게 함. start-work는 계획 행이, litgoal은 기준이 남아 있으면 이어 가되 진전 없이 연속 3번까지만 붙잡음), PreCompact(Muse가 컴팩션하기 전에 `.litmuse/handoff/latest.md`를 씀), SubagentStart(자식 에이전트의 보고 형식), SubagentStop·SessionEnd(기록 줄). 훅은 경로, 계획 진행, 실행 중인 자식 에이전트와 세션별 활동 상태(PostToolUse는 alive, Stop은 idle, SessionEnd는 ended)도 실시간 기록으로 남기며, Muse의 `/hud`는 고정 항목만 보여 주므로 이 기록은 `/lit-status`와 `npx @litfamily/litmuse status --card`로 봅니다. 카드는 같은 프로젝트의 다른 세션을 실행 중인 것부터 쉬는 것 순으로 보여 주고, 끝난 start-work는 1분 동안 done으로 두었다가 지우며, herdr 창 세션은 `<skill> · <pane>`으로 표시하고, 세션 시작 뒤 더 새 LitMuse가 설치되면 `restart to <버전>`을 띄웁니다.
182
182
 
183
183
  **에이전트 11개**, `litmuse/<이름>`으로 띄웁니다: lit-planner, lit-executor, lit-verifier, qa-runner, quality-reviewer, librarian-researcher, 그리고 한국어 글 에이전트 다섯. 리뷰어와 한국어 에이전트는 읽기와 검색 도구만 씁니다.
184
184
 
@@ -215,3 +215,5 @@ LitMuse는 LitFamily 패키지 일곱 개 가운데 하나이고, 표지의 로
215
215
  ## 라이선스
216
216
 
217
217
  MIT, © LitMuse contributors. LitMuse는 독립 커뮤니티 프로젝트이며 Meta와 제휴하거나 Meta의 보증을 받은 프로젝트가 아닙니다.
218
+
219
+ LitMuse와 LitFamily 이름, 로고, 마스코트 그림에는 [TRADEMARK.md](./TRADEMARK.md)가 적용됩니다.
package/TRADEMARK.md ADDED
@@ -0,0 +1,12 @@
1
+ # Trademark policy
2
+
3
+ The code in this repository is free to use under the MIT License (see `LICENSE`). The names **LitMuse**
4
+ and **LitFamily**, and the logos and mascot art, are not part of that license.
5
+
6
+ - You can fork, change and share the code under MIT.
7
+ - If you publish a changed version, give it a different name and logo, and do not suggest that the
8
+ LitMuse maintainers made or endorse it.
9
+ - You can say in plain text that your project is "based on LitMuse" or "a fork of LitMuse".
10
+ - Unchanged copies, such as mirrors and package caches, may keep the name.
11
+
12
+ Questions: open an issue at https://github.com/wjgoarxiv/litmuse/issues.
@@ -1,8 +1,14 @@
1
1
  import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { latestFile, readStage, stageFile, workspaceHash } from '../plugin/hooks/lib/stage.mjs';
3
+ import { latestFile, readLiveness, readStage, stageFile, workspaceHash } from '../plugin/hooks/lib/stage.mjs';
4
+ import { newerThan } from '../plugin/hooks/lib/common.mjs';
5
+ import { readManifest } from './bundle.mjs';
6
+ import { paths } from './paths.mjs';
4
7
  import { gauge } from '../plugin/lib/rewind/gauge.mjs';
5
- import { REVIEW_LANES } from '../plugin/hooks/lib/workflow-stack.mjs';
8
+ import { REVIEW_LANES, projectKey, resolveProjectStateRoot } from '../plugin/hooks/lib/workflow-stack.mjs';
9
+
10
+ // The hooks write `.litmuse` at the project state root, which may sit above the card's cwd.
11
+ const stacksDir = (cwd) => join(resolveProjectStateRoot(cwd), '.litmuse', 'workflow-stack');
6
12
 
7
13
  export function fmtDuration(ms) {
8
14
  const s = Math.max(0, Math.floor(ms / 1000));
@@ -92,7 +98,7 @@ export function rainbowLine(text) {
92
98
  // Fresh workflow stacks mean recent sessions worth briefing: any stack file touched in 24 h.
93
99
  export function freshStacks(cwd, now = Date.now()) {
94
100
  try {
95
- const dir = join(cwd, '.litmuse', 'workflow-stack');
101
+ const dir = stacksDir(cwd);
96
102
  const day = 24 * 60 * 60 * 1000;
97
103
  return readdirSync(dir).some((name) => name.endsWith('.json') && now - statSync(join(dir, name)).mtimeMs <= day);
98
104
  } catch {
@@ -104,7 +110,7 @@ export function freshStacks(cwd, now = Date.now()) {
104
110
  // resolves from exactly one source, in this order: the frame's recorded k/n, the named plan's
105
111
  // rows, the active goal's criteria, otherwise the indeterminate pulse. Frames without a stage
106
112
  // (a pure `record` spine) and frames older than 24 h are dropped; sibling sessions older than
107
- // 15 minutes are skipped. Order is fixed (own stack, then siblings by file name), so a repaint
113
+ // 15 minutes are skipped. Order is fixed (own stack, then other sessions as below), so a repaint
108
114
  // with no change is byte-identical.
109
115
  const THREAD_CAP = 6;
110
116
  const THREAD_STALE_MS = 24 * 60 * 60 * 1000;
@@ -140,45 +146,104 @@ const frameRow = (frame, session, cwd, goal) => ({
140
146
  verdicts: frame.verdicts && typeof frame.verdicts === 'object' ? { ...frame.verdicts } : undefined,
141
147
  });
142
148
 
143
- export function threadRows({ cwd, session, now = Date.now() }) {
149
+ // Other sessions of this project come from two sources: their fresh workflow stacks and their liveness
150
+ // records (alive, idle, ended, written by the hooks). Each reads `running`, `idle` or `done`; a session
151
+ // that ended, or whose start-work plan is fully ticked, reads `done` for a minute and then leaves the
152
+ // card. Running rows come first, then paused and idle ones, then done; ties keep the name order. A
153
+ // session started in a herdr pane is named `<skill> · <pane>` instead of by its id, and a session with
154
+ // no stack still gets a row (skill from its stage record, else `session`), even as the only row.
155
+ const DONE_SHOW_MS = 60 * 1000;
156
+ const OTHER_RANK = Object.freeze({ running: 0, paused: 1, idle: 1, done: 2 });
157
+ const fileKey = (session) => String(session).replace(/[^A-Za-z0-9_-]/gu, '_').slice(0, 128);
158
+
159
+ const finishedAt = (frame, live, cwd) => {
160
+ if (live?.state === 'ended') return Date.parse(live.at);
161
+ if (frame?.skill === 'start-work' && frame.ref?.plan) {
162
+ const plan = planProgress(cwd, frame.ref.plan);
163
+ if (plan && plan.total > 0 && plan.done >= plan.total && Number.isFinite(plan.mtimeMs)) return plan.mtimeMs;
164
+ }
165
+ return null;
166
+ };
167
+
168
+ function otherRows({ cwd, own, names, stacks, env, now, goal }) {
169
+ const others = new Map();
170
+ for (const name of names) {
171
+ const path = join(stacks, name);
172
+ let mtime = 0;
173
+ try {
174
+ mtime = statSync(path).mtimeMs;
175
+ } catch {
176
+ continue;
177
+ }
178
+ if (now - mtime > SIBLING_FRESH_MS) continue;
179
+ const top = readStackFile(path)?.frames.at(-1);
180
+ if (top?.stage) others.set(name.replace(/\.json$/, ''), { frame: top });
181
+ }
182
+ const root = projectKey(cwd);
183
+ for (const live of readLiveness(env)) {
184
+ if (typeof live.session !== 'string' || typeof live.cwd !== 'string' || live.session === own) continue;
185
+ if (projectKey(live.cwd) !== root) continue;
186
+ const entry = others.get(live.session);
187
+ if (!entry && live.state !== 'ended' && now - Date.parse(live.at) > SIBLING_FRESH_MS) continue;
188
+ others.set(live.session, { ...entry, live });
189
+ }
190
+ const rows = [];
191
+ for (const [id, { frame, live }] of [...others].sort(([a], [b]) => a.localeCompare(b))) {
192
+ const done = finishedAt(frame, live, cwd);
193
+ if (done !== null && !(now - done <= DONE_SHOW_MS)) continue;
194
+ const word = done !== null ? 'done' : live?.state === 'idle' ? 'idle' : 'running';
195
+ const route = frame ? null : readStage(stageFile(cwd, id, env))?.route;
196
+ const row = frame ? frameRow(frame, id, cwd, goal) : { session: id, skill: typeof route === 'string' && route ? route : 'session', stage: null, gauge: '' };
197
+ // The status word already says running; an unknown count adds nothing.
198
+ if (row.gauge === gauge(null, null)) row.gauge = '';
199
+ rows.push({
200
+ ...row,
201
+ status: frame?.status === 'paused' && word !== 'done' ? 'paused' : word,
202
+ pane: typeof live?.pane === 'string' ? live.pane : null,
203
+ sibling: true,
204
+ });
205
+ }
206
+ return rows.sort((a, b) => OTHER_RANK[a.status] - OTHER_RANK[b.status]);
207
+ }
208
+
209
+ export function threadRows({ cwd, session, env = process.env, now = Date.now() }) {
144
210
  const goal = activeGoal(cwd);
145
- let dir = null;
211
+ const stacks = stacksDir(cwd);
212
+ let dir = [];
146
213
  try {
147
- dir = readdirSync(join(cwd, '.litmuse', 'workflow-stack'));
148
- } catch {
149
- return [];
150
- }
151
- const own = session ? `${String(session).replace(/[^A-Za-z0-9_-]/gu, '_').slice(0, 128)}.json` : null;
214
+ dir = readdirSync(stacks);
215
+ } catch { /* no stacks yet: liveness may still name other sessions */ }
216
+ const own = session ? fileKey(session) : null;
152
217
  const rows = [];
153
218
  if (own) {
154
- const stack = readStackFile(join(cwd, '.litmuse', 'workflow-stack', own));
219
+ const stack = readStackFile(join(stacks, `${own}.json`));
155
220
  for (const frame of stack?.frames ?? []) {
156
221
  if (!frame.stage) continue;
157
222
  if (frame.at && now - Date.parse(frame.at) > THREAD_STALE_MS) continue;
158
223
  rows.push(frameRow(frame, session, cwd, goal));
159
224
  }
160
225
  }
161
- for (const name of dir.filter((entry) => entry.endsWith('.json') && entry !== own).sort()) {
162
- const path = join(cwd, '.litmuse', 'workflow-stack', name);
163
- let mtime = 0;
164
- try {
165
- mtime = statSync(path).mtimeMs;
166
- } catch {
167
- continue;
168
- }
169
- if (now - mtime > SIBLING_FRESH_MS) continue;
170
- const stack = readStackFile(path);
171
- const top = stack?.frames.at(-1);
172
- if (!top?.stage) continue;
173
- rows.push({ ...frameRow(top, name.replace(/\.json$/, ''), cwd, goal), sibling: true });
174
- }
226
+ const names = dir.filter((entry) => entry.endsWith('.json') && entry !== `${own}.json`);
227
+ rows.push(...otherRows({ cwd, own, names, stacks, env, now, goal }));
175
228
  return { rows: rows.slice(0, THREAD_CAP), omitted: Math.max(0, rows.length - THREAD_CAP) };
176
229
  }
177
230
 
231
+ // The restart chip: the installed bundle carries a newer version than the code this session loaded at
232
+ // SessionStart, so the hooks and skills in use are older until Muse restarts. Unknown either way: no chip.
233
+ function restartTo(rec, env) {
234
+ if (typeof rec?.loadedVersion !== 'string') return null;
235
+ try {
236
+ const installed = readManifest(paths(env).stage).version;
237
+ return typeof installed === 'string' && newerThan(installed, rec.loadedVersion) ? installed : null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
178
243
  export function buildCard({ cwd, session, env = process.env, now = Date.now() }) {
179
244
  const rec = loadStage({ cwd, session, env });
180
245
  const goal = activeGoal(cwd);
181
- const threads = threadRows({ cwd, session: rec?.session ?? session ?? null, now });
246
+ const threads = threadRows({ cwd, session: rec?.session ?? session ?? null, env, now });
182
247
  if (!rec) return { active: false, route: null, goal, plan: planProgress(cwd, null), lanes: [], threads, idle: freshStacks(cwd, now), now, noColor: Boolean(env.NO_COLOR) };
183
248
  const lanes = (rec.lanes ?? []).map((l) => ({
184
249
  id: l.id, type: l.type, status: l.status,
@@ -188,6 +253,7 @@ export function buildCard({ cwd, session, env = process.env, now = Date.now() })
188
253
  active: Boolean(rec.route), route: rec.route ?? null, keyword: rec.keyword ?? null, kind: rec.kind ?? null,
189
254
  session: rec.session ?? null, activeMs: rec.route && rec.activatedAt ? now - rec.activatedAt : null,
190
255
  plan: planProgress(cwd, rec.plan ?? null), lanes, goal, threads, idle: !rec.route && freshStacks(cwd, now), updated: rec.updated ?? null, now, noColor: Boolean(env.NO_COLOR),
256
+ restartTo: restartTo(rec, env),
191
257
  };
192
258
  }
193
259
 
@@ -248,14 +314,16 @@ export function renderCard(card) {
248
314
  const out = [];
249
315
  const color = !card.noColor;
250
316
  out.push(headlineFor(card, color));
317
+ if (card.restartTo) out.push(`restart to ${card.restartTo}: LitMuse was updated after this session loaded it`);
251
318
  if (!card.active && card.idle) out.push('idle — say `lit adhd` for the cross-session brief');
252
319
  const threads = card.threads?.rows ?? [];
253
320
  if (threads.length) {
254
321
  out.push('threads:');
255
322
  threads.forEach((thread, index) => {
256
323
  const branch = index === threads.length - 1 && !card.threads.omitted ? '└─' : '├─';
257
- const where = thread.sibling ? ` (${thread.session})` : '';
258
- out.push(`${branch} ${thread.skill}${thread.stage ? ` ${thread.stage}` : ''} ${thread.status} ${thread.gauge}${where}`);
324
+ const name = thread.pane ? `${thread.skill} · ${thread.pane}` : thread.skill;
325
+ const where = thread.sibling && !thread.pane ? ` (${thread.session})` : '';
326
+ out.push(`${branch} ${name}${thread.stage ? ` ${thread.stage}` : ''} ${thread.status}${thread.gauge ? ` ${thread.gauge}` : ''}${where}`);
259
327
  });
260
328
  if (card.threads.omitted) out.push(`+${card.threads.omitted} more`);
261
329
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litmuse",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Installer for LitMuse: skills, hooks and agents that help Meta's Muse Code plan, build, check and hand off work.",
5
5
  "keywords": [
6
6
  "muse",
@@ -47,7 +47,8 @@
47
47
  "README.md",
48
48
  "README_ko-KR.md",
49
49
  "CHANGELOG.md",
50
- "LICENSE"
50
+ "LICENSE",
51
+ "TRADEMARK.md"
51
52
  ],
52
53
  "publishConfig": {
53
54
  "access": "public"
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "name": "litmuse",
4
4
  "displayName": "LitMuse",
5
- "version": "0.1.2",
5
+ "version": "0.1.3",
6
6
  "description": "LitFamily workflows for Muse Code: bare lit activation, planning, evidence-bound execution, review, research, handoff and Korean prose care.",
7
7
  "compat": {
8
8
  "source": "native",
@@ -251,7 +251,7 @@
251
251
  "node",
252
252
  "hooks/session-start.mjs",
253
253
  "--digest",
254
- "11ba46df6010f4d8"
254
+ "aa3c8c817b3b5263"
255
255
  ],
256
256
  "timeoutMs": 5000
257
257
  },
@@ -262,7 +262,7 @@
262
262
  "node",
263
263
  "hooks/user-prompt-submit.mjs",
264
264
  "--digest",
265
- "c55594062398b5ea"
265
+ "17cbcfe331b17df6"
266
266
  ],
267
267
  "timeoutMs": 5000,
268
268
  "statusMessage": "LitMuse route check"
@@ -274,7 +274,7 @@
274
274
  "node",
275
275
  "hooks/pre-tool-use.mjs",
276
276
  "--digest",
277
- "b98a3b323bb1756c"
277
+ "a46607c7dd5c7dcd"
278
278
  ],
279
279
  "timeoutMs": 5000
280
280
  },
@@ -285,7 +285,7 @@
285
285
  "node",
286
286
  "hooks/post-tool-use.mjs",
287
287
  "--digest",
288
- "1aae7c29d41747b8"
288
+ "0c70692956033463"
289
289
  ],
290
290
  "timeoutMs": 5000
291
291
  },
@@ -296,7 +296,7 @@
296
296
  "node",
297
297
  "hooks/post-tool-use-failure.mjs",
298
298
  "--digest",
299
- "3f1b940d78192ff9"
299
+ "6904dd5da8def1dd"
300
300
  ],
301
301
  "timeoutMs": 5000
302
302
  },
@@ -307,7 +307,7 @@
307
307
  "node",
308
308
  "hooks/stop.mjs",
309
309
  "--digest",
310
- "0bd9020bf0bbc1af"
310
+ "ffd3ec1cb2687eb2"
311
311
  ],
312
312
  "timeoutMs": 5000
313
313
  },
@@ -318,7 +318,7 @@
318
318
  "node",
319
319
  "hooks/subagent-start.mjs",
320
320
  "--digest",
321
- "6694ead2b6e3ac2b"
321
+ "b97c259487df63c1"
322
322
  ],
323
323
  "timeoutMs": 5000
324
324
  },
@@ -329,7 +329,7 @@
329
329
  "node",
330
330
  "hooks/subagent-stop.mjs",
331
331
  "--digest",
332
- "023daac9ec9f7aeb"
332
+ "6d4dbf1e969d4d7b"
333
333
  ],
334
334
  "timeoutMs": 5000
335
335
  },
@@ -340,7 +340,7 @@
340
340
  "node",
341
341
  "hooks/session-end.mjs",
342
342
  "--digest",
343
- "18f92d375de7d7b0"
343
+ "b0f6069317813e37"
344
344
  ],
345
345
  "timeoutMs": 5000
346
346
  },
@@ -351,7 +351,7 @@
351
351
  "node",
352
352
  "hooks/pre-compact.mjs",
353
353
  "--digest",
354
- "dfdcd29de71fafe0"
354
+ "c844b43a044a2533"
355
355
  ],
356
356
  "timeoutMs": 5000
357
357
  }
@@ -9,7 +9,8 @@ Print the LitMuse status card for this workspace. Scope: $ARGUMENTS (empty means
9
9
  - Route and time active: the newest record from `latest.json` (route, activatedAt, kind), or else the `route` on the last line of the newest `.litmuse/ledger/*.jsonl`.
10
10
  - Plan: the plan named in the record, or else the newest `plans/*.md`. Count `- [x] N.` against all `- [ ] N.` and `- [x] N.` rows, and name the first open row.
11
11
  - Lanes: the record's `lanes` entries with `status` running, done or failed, and their elapsed time (`endedAt` or now, minus `startedAt`).
12
- - Threads: `.litmuse/workflow-stack/<session>.json` frames (paused spines above running sides) plus other sessions' fresh paused tops: a real gauge when the count is known, the `… N steps` fact line otherwise — never a placeholder. Six rows at most, then `+N more`; an idle card offers `lit adhd` instead.
12
+ - Threads: `.litmuse/workflow-stack/<session>.json` frames (paused spines above running sides) at the project state root (a linked worktree keeps its own), then other sessions of this project from their fresh stack tops and their liveness records (`stage/liveness/<session>.json`: `alive`, `idle`, `ended`). Other sessions read `running`, `idle` or `done`, running first; a session that ended or whose start-work plan is fully ticked reads `done` for one minute, then leaves; a session in a herdr pane is named `<skill> · <pane>`; a lone other session still gets its row. A real gauge when the count is known, the `… N steps` fact line otherwise — never a placeholder. Six rows at most, then `+N more`; an idle card offers `lit adhd` instead.
13
+ - Restart chip: when the installed bundle (`$XDG_DATA_HOME/litmuse/bundle/.muse-plugin/plugin.json`) is newer than the `loadedVersion` SessionStart recorded, add `restart to <version>`; when either is unknown, add nothing.
13
14
  - Goal: each `.litmuse/goals/*.json` whose status is `active`, with its objective and the criteria that are not `pass`.
14
15
  3. Add the first lines of `.litmuse/handoff/latest.md` if present, and the count of tool events and failures in the newest ledger.
15
16
 
@@ -1,9 +1,10 @@
1
- // The `go` half of the lit-adhd brief: the collector writes this session's next-step record
2
- // under `.litmuse/adhd/`, and a later whole-prompt `go` reads it back and deletes it. The session
1
+ // The `go` half of the lit-adhd brief and of a saved lit-plan: the collector (or the Stop hook,
2
+ // for a plan) writes this session's next-step record under `.litmuse/adhd/`, and a later
3
+ // whole-prompt `go` reads it back and deletes it. The session
3
4
  // binding is the file name, so another session's record is never opened. A step older than 30
4
5
  // minutes is stale: the brief it came from no longer describes the work.
5
- import { closeSync, constants, fstatSync, openSync, readFileSync, unlinkSync } from 'node:fs';
6
- import { join } from 'node:path';
6
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
7
8
 
8
9
  import { workspaceOf } from './common.mjs';
9
10
  import { resolveProjectStateRoot } from './workflow-stack.mjs';
@@ -20,6 +21,49 @@ const nextStepFile = (cwd, sessionId) => {
20
21
  return join(resolveProjectStateRoot(cwd), '.litmuse', 'adhd', `next-${id}.json`);
21
22
  };
22
23
 
24
+ const PLAN_NAME = /^plans\/[A-Za-z0-9._-]+\.md$/u;
25
+
26
+ // `.litmuse` and `.litmuse/adhd` must be real folders (or not there yet): a linked one would carry
27
+ // the record out of the project.
28
+ const realDirs = (root, dir) => {
29
+ for (const part of [join(root, '.litmuse'), dir]) {
30
+ try {
31
+ if (!lstatSync(part).isDirectory()) return false;
32
+ } catch { /* absent: mkdir makes a real one */ }
33
+ }
34
+ mkdirSync(dir, { recursive: true });
35
+ return !lstatSync(join(root, '.litmuse')).isSymbolicLink() && !lstatSync(dir).isSymbolicLink();
36
+ };
37
+
38
+ /**
39
+ * The lit-plan half of `go`: once a planning turn has saved `plans/<slug>.md`, records it as this
40
+ * session's `start-plan` step, so a whole-prompt `go` within 30 minutes starts start-work on it.
41
+ * Plan names outside `plans/<slug>.md` and linked state folders write nothing. Never throws.
42
+ */
43
+ export const putNextStep = (payload, plan, now = Date.now()) => {
44
+ const sessionId = typeof payload?.session_id === 'string' && payload.session_id ? payload.session_id : null;
45
+ if (!sessionId || typeof plan !== 'string' || !PLAN_NAME.test(plan) || /[\r\n]/u.test(plan)) return null;
46
+ try {
47
+ const cwd = workspaceOf(payload);
48
+ const path = nextStepFile(cwd, sessionId);
49
+ if (!realDirs(resolveProjectStateRoot(cwd), dirname(path))) return null;
50
+ const staging = `${path}.${process.pid}.tmp`;
51
+ try {
52
+ unlinkSync(staging);
53
+ } catch { /* no leftover */ }
54
+ const fd = openSync(staging, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
55
+ try {
56
+ writeSync(fd, `${JSON.stringify({ kind: 'start-plan', target: { plan }, at: new Date(now).toISOString() })}\n`);
57
+ } finally {
58
+ closeSync(fd);
59
+ }
60
+ renameSync(staging, path);
61
+ return path;
62
+ } catch {
63
+ return null;
64
+ }
65
+ };
66
+
23
67
  /**
24
68
  * Reads and deletes this session's next-step record. Returns `{kind, target}` when the record is
25
69
  * whole and at most 30 minutes old, otherwise null; a stale or unreadable record is deleted all
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, mkdirSync, lstatSync, renameSync, realpathSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, lstatSync, renameSync, realpathSync, readdirSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
@@ -66,8 +66,9 @@ export function readStage(file) {
66
66
  }
67
67
  }
68
68
 
69
- // mutate(record) returns the next record (or nothing to keep it). Never throws.
70
- export function updateStage(payload, mutate, env = process.env) {
69
+ // mutate(record) returns the next record (or nothing to keep it). Never throws. `pointer: false` leaves
70
+ // `latest.json` alone, for writes that say nothing about which session the user is driving.
71
+ export function updateStage(payload, mutate, env = process.env, { pointer = true } = {}) {
71
72
  try {
72
73
  const cwd = workspaceOf(payload);
73
74
  const file = stageFile(cwd, payload.session_id, env);
@@ -83,9 +84,10 @@ export function updateStage(payload, mutate, env = process.env) {
83
84
  next.lanes = next.lanes.slice(-MAX_LANES);
84
85
  for (const k of ['route', 'keyword', 'plan', 'kind']) if (k in next) next[k] = clip(next[k]);
85
86
  if (!atomicWrite(file, next)) return false;
86
- const pointer = readStage(latestFile(env)) ?? {};
87
- pointer[next.workspace] = { session: payload.session_id, updated: now };
88
- atomicWrite(latestFile(env), pointer);
87
+ if (!pointer) return true;
88
+ const latest = readStage(latestFile(env)) ?? {};
89
+ latest[next.workspace] = { session: payload.session_id, updated: now };
90
+ atomicWrite(latestFile(env), latest);
89
91
  return true;
90
92
  } catch {
91
93
  return false;
@@ -141,6 +143,51 @@ export function recordLaneStop(payload, env = process.env) {
141
143
  }, env);
142
144
  }
143
145
 
146
+ // Liveness: each session's last hook event as one small record, `stage/liveness/<session>.json`, apart from the
147
+ // stage record so a tool call never rewrites `latest.json`. PostToolUse and its failure twin say `alive`, Stop says
148
+ // `idle`, SessionEnd says `ended`. The card reads these to sort other sessions into running, idle and gone. A
149
+ // repeat of the same state inside 30 s writes nothing, so a busy session costs one write per half minute.
150
+ export const LIVENESS_STATES = Object.freeze(['alive', 'idle', 'ended']);
151
+ const LIVENESS_REPEAT_MS = 30_000;
152
+ // A herdr pane id is short and plain (`w2B:p3`); anything else is dropped rather than stored.
153
+ const PANE_ID = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,31}$/;
154
+
155
+ export function livenessDir(env = process.env) {
156
+ return join(stageRoot(env), 'liveness');
157
+ }
158
+
159
+ export function readLiveness(env = process.env) {
160
+ try {
161
+ return readdirSync(livenessDir(env)).filter((n) => n.endsWith('.json')).map((n) => readStage(join(livenessDir(env), n))).filter(Boolean);
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ export function markLiveness(payload, state, { env = process.env, now = Date.now() } = {}) {
168
+ try {
169
+ const id = safeId(payload?.session_id);
170
+ if (!id || !LIVENESS_STATES.includes(state)) return false;
171
+ const root = stageRoot(env);
172
+ const dir = livenessDir(env);
173
+ if (isLink(root) || !safeDir(dir)) return false;
174
+ const file = join(dir, `${id}.json`);
175
+ const prev = isLink(file) ? null : readStage(file);
176
+ if (prev && prev.state === state && now - Date.parse(prev.at) < LIVENESS_REPEAT_MS) return false;
177
+ const cwd = workspaceOf(payload);
178
+ const pane = typeof env.HERDR_PANE_ID === 'string' && PANE_ID.test(env.HERDR_PANE_ID) ? env.HERDR_PANE_ID : null;
179
+ return atomicWrite(file, { version: 1, session: id, state, at: new Date(now).toISOString(), cwd, workspace: workspaceHash(cwd), pane });
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ // The plugin version this session's hooks and skills were loaded from, for the card's restart chip.
186
+ export function recordLoadedVersion(payload, version, env = process.env) {
187
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+/.test(version)) return false;
188
+ return updateStage(payload, (rec) => ({ ...rec, loadedVersion: version }), env, { pointer: false });
189
+ }
190
+
144
191
  export function recordSessionEnd(payload, env = process.env) {
145
192
  return updateStage(payload, (rec, now) => {
146
193
  rec.route = null;
@@ -8,7 +8,7 @@
8
8
  // file per session, whole, and the status card reads it back through its own guarded read. Frames
9
9
  // keep skills, stages and counts only; never a prompt, a command or a path other than a
10
10
  // project-relative plan name.
11
- import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, writeFileSync, existsSync } from 'node:fs';
11
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, writeFileSync, existsSync } from 'node:fs';
12
12
  import { basename, dirname, join, resolve } from 'node:path';
13
13
 
14
14
  export const MAX_FRAMES = 3;
@@ -239,19 +239,37 @@ export const applyStackEvent = (stack, event, now = new Date()) => {
239
239
  };
240
240
 
241
241
  // State placement: the outermost git root owns `.litmuse`, so a nested package shares its
242
- // parent's state; without git the workspace itself owns it.
242
+ // parent's state; without git the workspace itself owns it. A linked worktree (its `.git` is a
243
+ // file) is its own root: the climb stops there, so its state never lands in the main checkout.
244
+ // Every reader and writer of `.litmuse` resolves through this one function.
243
245
  export const resolveProjectStateRoot = (startDir = process.cwd()) => {
244
246
  const start = resolve(startDir);
245
247
  let root = null;
246
248
  let current = start;
247
249
  for (;;) {
248
- if (existsSync(join(current, '.git'))) root = current;
250
+ const git = join(current, '.git');
251
+ if (existsSync(git)) {
252
+ root = current;
253
+ try {
254
+ if (!lstatSync(git).isDirectory()) return root;
255
+ } catch { /* vanished between checks: keep climbing */ }
256
+ }
249
257
  const parent = dirname(current);
250
258
  if (parent === current) return root ?? start;
251
259
  current = parent;
252
260
  }
253
261
  };
254
262
 
263
+ // The project a recorded cwd belongs to, for comparing records: links resolved first, so a session
264
+ // that reached the project through a symlinked path (`/var` vs `/private/var`) is the same project.
265
+ export const projectKey = (dir) => {
266
+ let real = resolve(dir);
267
+ try {
268
+ real = realpathSync(real);
269
+ } catch { /* gone or unreadable: compare as written */ }
270
+ return resolveProjectStateRoot(real);
271
+ };
272
+
255
273
  const fileName = (sessionId) => `${String(sessionId ?? 'session').replace(/[^A-Za-z0-9_-]/gu, '_').slice(0, 128) || 'session'}.json`;
256
274
 
257
275
  export const stackPath = (cwd, sessionId) => join(resolveProjectStateRoot(cwd), '.litmuse', 'workflow-stack', fileName(sessionId));
@@ -1,6 +1,8 @@
1
1
  import { run, activeRoute, appendLedger, toolName, toolInput } from './lib/common.mjs';
2
+ import { markLiveness } from './lib/stage.mjs';
2
3
 
3
4
  run((payload) => {
5
+ markLiveness(payload, 'alive');
4
6
  const state = activeRoute(payload);
5
7
  if (!state) return;
6
8
  appendLedger(payload, {
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  import { run, activeRoute, appendLedger, emitContext, toolName, toolInput, workspaceOf, PLUGIN_ROOT } from './lib/common.mjs';
5
5
  import { extractAddedComments } from './lib/added-comments.mjs';
6
6
  import { MOTION_ROUTE, recordMotionTool } from './lib/motion.mjs';
7
+ import { markLiveness } from './lib/stage.mjs';
7
8
 
8
9
  const FILE_TOOLS = new Set(['write_file', 'edit_file', 'apply_patch']);
9
10
  const PROSE_FILE = /\.(md|mdx|markdown|txt)$/i;
@@ -82,6 +83,7 @@ async function proseBlock(tool, input, cwd) {
82
83
  }
83
84
 
84
85
  run((payload) => {
86
+ markLiveness(payload, 'alive');
85
87
  postWrite(payload).catch(() => {});
86
88
  });
87
89
 
@@ -1,8 +1,9 @@
1
1
  import { run, readSession, writeSession, appendLedger } from './lib/common.mjs';
2
- import { recordSessionEnd } from './lib/stage.mjs';
2
+ import { markLiveness, recordSessionEnd } from './lib/stage.mjs';
3
3
 
4
4
  run((payload) => {
5
5
  recordSessionEnd(payload);
6
+ markLiveness(payload, 'ended');
6
7
  const state = readSession(payload);
7
8
  if (!state) return;
8
9
  if (state.route) appendLedger(payload, { event: 'SessionEnd', route: state.route });