@litfamily/litmuse 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6 — 2026-11-01
4
+
5
+ - `lit-typographic-motion` can lay real keyed footage over a stage film. A stage declares chroma-keyed clips in `stage/clips/clips.json` (an id, a folder, a frame count and a rate), the frames are 8-bit RGBA PNGs with real transparency, and the page registers each clip once with `LitStage.clip` and calls the returned function from `render(t)`; the frame shown is a plain function of film time, so a cue is `start = cue - (N - 1) / fps` and the flash audit and determinism replay see the final pixels. A declared clip is held to its own caps (12 clips, 360 frames in one and 1500 in all, 40 MB in one and 128 MB in all, 720 px on the long side, 30 fps) and no longer trips the flipbook rule; an undeclared frame folder is still refused with exit 17. Cover mode takes no clips. The manifest and the report list each clip.
6
+
7
+ ## 0.1.5 — 2026-10-31
8
+
9
+ - `lit-adhd` counts the other sessions and herdr agents at work before it offers this session's own next step. Before, the session that had started them was still told to type `lit continue` (a paused workflow) or `go` (its own start-work run) while they worked, and its own card showed `lit continue`. Now it says to wait for them and keeps `go` silent; a pending approval is still offered, and `lit continue` typed by hand still works.
10
+ - LitMuse can now leave a short block of ground rules in Muse's own user rules file, `~/.config/muse/AGENTS.md`, which Muse loads at the start of every session. The hooks keep carrying the full guidance; the block only says that `lit` is LitMuse's activation word, where plans and handoffs live, that nothing is pushed, published, tagged or version-bumped without a yes, and that work counts as done after its tests pass and the changed surface was checked. It is yours to decide: an interactive install asks once (a No is remembered), `--yes` adds it, an install with no terminal and no `--yes` never writes it, `--no-agent-file` skips one run, and `litmuse config agentFile off|on` removes or restores it at once. Only the marked lines are LitMuse's; your text stays byte for byte, removing the block (or `uninstall`) returns the file as it was, and a copy of your file is saved the first time. A symbolic link, a non-UTF-8 file, a damaged marker pair or a file past Muse's 32,000-byte load limit is left alone and reported. `doctor` and `status` show the state.
11
+ - `lit-adhd` no longer tells you to type `go` while other sessions or herdr agents are still working in this repo: it says to wait for them, or to answer the one waiting on you, and ends with "Do not type `go` until they finish." It offers `go` only for a step `go` actually runs and only for this session's own work, so an approval step or another session's run never carries it. Its handoff suggestion skips items already marked done and reads the newest handoff file. A `go` typed after the brief first checks that the step still holds, and starts nothing if it does not.
12
+ - The hook code changed, so Muse asks you to approve the LitMuse hooks again after the update.
13
+ - A new `ci.yml` workflow checks every push and pull request to `main`: the tests, the version, hook-digest and legacy-token checks, and the packed file list, on Node 22 and 24 with no `muse` on the runner. One `lit-adhd` test no longer fails when it runs inside a herdr pane.
14
+
3
15
  ## 0.1.4 — 2026-10-28
4
16
 
5
17
  - The interface check now starts its headless Chrome with a stand-in keychain, so macOS no longer pops up "keychain not found" while it runs.
package/README.md CHANGED
@@ -199,14 +199,22 @@ Route state lives outside the project, under `~/.local/state/litmuse/`, so no to
199
199
 
200
200
  Muse also reads personal rules and skills written for other coding tools, such as a user rules file or a personal skills folder. LitMuse leaves that on, because those rules are yours. Its session context asks Muse to use the LitMuse skill for every lit route, and `doctor` warns when a personal skill shares a name with a LitMuse skill or comes from another tool's plugin folder. For a clean run, switch off "Personal rules fallback" and "Personal skills fallback" in `/settings`; a headless run takes `muse exec --no-foreign-personal-context` (the interactive `muse` rejects that flag on 1.4.11).
201
201
 
202
+ ## A short block in Muse's own rules file
203
+
204
+ Muse keeps one user rules file of its own, `~/.config/muse/AGENTS.md` (`$XDG_CONFIG_HOME/muse/AGENTS.md` when that is set), and loads it as user-scope rules at the start of every session, trusted workspace or not. LitMuse's hooks already carry the full guidance, so the file gets only a short block of ground rules: `lit` is LitMuse's activation word, where plans and handoffs live, never push, publish, tag or bump a version without a yes, and call work done only after the tests pass and the changed surface was checked.
205
+
206
+ You decide whether it goes in. An interactive install asks once (the default is yes; a No is remembered), `--yes` adds it, and an install without a terminal and without `--yes` never writes it. `--no-agent-file` skips it for one run. `npx @litfamily/litmuse config agentFile off` removes the block at once and keeps it off; `config agentFile on` puts it back.
207
+
208
+ Only the lines between `<!-- litfamily:litmuse:start … -->` and `<!-- litfamily:litmuse:end -->` are LitMuse's. Your own text stays byte for byte: removing the block returns the file exactly as it was (a file the block created is deleted again), Windows line endings are kept, and a copy of your file goes to `~/.local/share/litmuse/backups/` the first time the block goes in. A symbolic link, a non-UTF-8 file or a damaged marker pair is left alone and reported. Muse cuts a rules file after 32,000 bytes, so on a file that long the installer writes nothing and `doctor` says the block would never be read. `doctor` and `status` show the state as `present`, `stale`, `absent`, `malformed` or `off`. If you run `/rules import` in Muse and choose to overwrite the file, the block goes with it; `install` puts it back.
209
+
202
210
  ## Safety and uninstall
203
211
 
204
- - The installer writes to `~/.local/share/litmuse/` and, through the `muse` CLI, to Muse's own plugin store and settings. It backs up `~/.config/muse/settings.json` and Muse's `installed.json` first, and it reads Muse's credential file in no step.
212
+ - The installer writes to `~/.local/share/litmuse/` and, through the `muse` CLI, to Muse's own plugin store and settings. It backs up `~/.config/muse/settings.json` and Muse's `installed.json` first, and it reads Muse's credential file in no step. The one file it edits outside those is Muse's user rules file, and only inside its own marked block (see above).
205
213
  - It refuses to replace a bundle directory that is a symbolic link, holds someone else's files, or was edited after install.
206
214
  - It writes nothing under `~/.claude` or `~/.codex`; a test pins that.
207
215
  - Hooks fail open: if one breaks, your session continues.
208
216
  - Engine caches live under `~/.local/share/litmuse/runtime/`; `npx @litfamily/litmuse runtime status` shows what is installed. `uninstall` keeps them, so delete that folder to free the space.
209
- - Remove everything with `npx @litfamily/litmuse uninstall` (add `--dry-run` to preview). Plans and `.litmuse/` folders in your projects stay where they are.
217
+ - Remove everything with `npx @litfamily/litmuse uninstall` (add `--dry-run` to preview); it takes the rules-file block out too. Plans and `.litmuse/` folders in your projects stay where they are.
210
218
 
211
219
  ## Family
212
220
 
package/README_ko-KR.md CHANGED
@@ -199,14 +199,22 @@ Muse의 `/hud`는 켜고 끄는 고정 항목 목록이라서 플러그인 글
199
199
 
200
200
  Muse는 다른 코딩 도구용으로 써 둔 개인 규칙과 개인 스킬도 함께 읽습니다(사용자 규칙 파일, 개인 스킬 폴더 등). 그 규칙은 사용자 것이므로 LitMuse는 이 동작을 그대로 둡니다. 대신 세션 컨텍스트로 lit 경로마다 LitMuse 스킬을 쓰도록 안내하고, 개인 스킬 이름이 LitMuse 스킬과 같거나 다른 도구의 플러그인 폴더에서 왔으면 `doctor`가 경고합니다. 깨끗한 실행이 필요하면 `/settings`에서 "Personal rules fallback"과 "Personal skills fallback"을 끄세요. 헤드리스 실행은 `muse exec --no-foreign-personal-context`를 쓰면 됩니다(1.4.11의 대화형 `muse`는 이 플래그를 받지 않습니다).
201
201
 
202
+ ## Muse 자체 규칙 파일의 짧은 블록
203
+
204
+ Muse에는 자기만의 사용자 규칙 파일 `~/.config/muse/AGENTS.md`(`$XDG_CONFIG_HOME`이 있으면 `$XDG_CONFIG_HOME/muse/AGENTS.md`)가 있고, 신뢰하는 작업 폴더인지와 상관없이 세션이 시작될 때마다 사용자 범위 규칙으로 읽습니다. 자세한 안내는 이미 LitMuse 훅이 싣고 있으므로 이 파일에는 기본 원칙만 짧은 블록으로 넣습니다. `lit`는 LitMuse의 활성화 단어라는 것, 계획과 핸드오프가 있는 곳, 사용자의 명시적인 동의 없이는 push·publish·tag·버전 올리기를 하지 않는다는 것, 테스트가 통과하고 바뀐 화면이나 동작을 실제로 확인한 뒤에만 끝났다고 말한다는 것입니다.
205
+
206
+ 넣을지는 사용자가 정합니다. 터미널에서 설치하면 한 번 묻고(기본값은 예, 아니요는 기억합니다), `--yes`를 주면 넣고, 터미널이 없고 `--yes`도 없는 설치는 절대 쓰지 않습니다. `--no-agent-file`은 이번 실행만 건너뜁니다. `npx @litfamily/litmuse config agentFile off`는 블록을 바로 지우고 꺼 둔 상태로 두며, `config agentFile on`은 다시 넣습니다.
207
+
208
+ `<!-- litfamily:litmuse:start … -->`와 `<!-- litfamily:litmuse:end -->` 사이의 줄만 LitMuse의 것입니다. 나머지 내용은 바이트 그대로 남습니다. 블록을 지우면 파일이 원래대로 돌아오고(블록 때문에 새로 만든 파일이면 파일도 지웁니다), Windows 줄바꿈은 유지되며, 블록을 처음 넣을 때 원본 사본이 `~/.local/share/litmuse/backups/`에 한 부 저장됩니다. 심볼릭 링크, UTF-8이 아닌 파일, 짝이 맞지 않는 마커는 건드리지 않고 알려 줍니다. Muse는 규칙 파일을 32,000바이트에서 잘라 읽으므로, 그 이상 긴 파일에는 설치기가 아무것도 쓰지 않고 `doctor`가 블록이 읽히지 않는다고 알려 줍니다. `doctor`와 `status`는 상태를 `present`, `stale`, `absent`, `malformed`, `off` 중 하나로 보여 줍니다. Muse에서 `/rules import`로 파일을 덮어쓰면 블록도 함께 사라지는데, `install`을 다시 실행하면 돌아옵니다.
209
+
202
210
  ## 안전과 제거
203
211
 
204
- - 설치기는 `~/.local/share/litmuse/`에 쓰고, `muse` CLI를 거쳐 Muse의 플러그인 저장소와 설정에 씁니다. 그 전에 `~/.config/muse/settings.json`과 Muse의 `installed.json`을 백업하며, Muse 인증 파일은 열어 보지 않습니다.
212
+ - 설치기는 `~/.local/share/litmuse/`에 쓰고, `muse` CLI를 거쳐 Muse의 플러그인 저장소와 설정에 씁니다. 그 전에 `~/.config/muse/settings.json`과 Muse의 `installed.json`을 백업하며, Muse 인증 파일은 열어 보지 않습니다. 이 밖에 손대는 파일은 Muse의 사용자 규칙 파일 하나뿐이고, 그것도 표시된 자기 블록 안에서만 씁니다(위 참고).
205
213
  - 번들 폴더가 심볼릭 링크이거나, 다른 사람의 파일이 들어 있거나, 설치 후 수정되었으면 교체를 거부합니다.
206
214
  - `~/.claude`와 `~/.codex` 아래에는 아무것도 쓰지 않으며, 테스트가 이를 고정합니다.
207
215
  - 훅은 실패해도 세션을 막지 않고 그대로 통과시킵니다.
208
216
  - 엔진 캐시는 `~/.local/share/litmuse/runtime/` 아래에 있고, 설치 상태는 `npx @litfamily/litmuse runtime status`로 봅니다. `uninstall`은 캐시를 남기므로 공간을 비우려면 그 폴더를 지우세요.
209
- - 전부 지우려면 `npx @litfamily/litmuse uninstall`(미리 보려면 `--dry-run`). 프로젝트 안의 계획과 `.litmuse/` 폴더는 그대로 남습니다.
217
+ - 전부 지우려면 `npx @litfamily/litmuse uninstall`(미리 보려면 `--dry-run`). 규칙 파일의 블록도 함께 빠집니다. 프로젝트 안의 계획과 `.litmuse/` 폴더는 그대로 남습니다.
210
218
 
211
219
  ## 패밀리
212
220
 
package/bin/litmuse.mjs CHANGED
@@ -11,6 +11,7 @@ import { paths } from '../lib/paths.mjs';
11
11
  import { installedPlugin, museVersion } from '../lib/muse.mjs';
12
12
  import { checkUpdate } from '../lib/update-check.mjs';
13
13
  import { runtime } from '../lib/runtime.mjs';
14
+ import { agentFileState, configCommand, readAgentFileSetting } from '../lib/agent-file.mjs';
14
15
  import { buildCard, renderCard } from '../lib/stage-card.mjs';
15
16
  import { styleCommand } from '../lib/style.mjs';
16
17
  import { runRewindCli } from '../plugin/lib/rewind/collect.mjs';
@@ -25,6 +26,7 @@ Commands
25
26
  status Short version of doctor plus an update check; --card shows the live LIT card for this folder
26
27
  models List the models and effort tiers your Muse catalog offers
27
28
  hud Show the recommended /hud and /theme preset
29
+ config agentFile on|off: the short LitFamily block in Muse's user rules file (acts at once)
28
30
  style eli5 | asd-ste100 | off | status: the answer style LitMuse adds to every session
29
31
  runtime status [name] | install <name|all>: the pinned engine runtimes, cached under the LitMuse data dir
30
32
  rewind Cross-session brief records [--json] [--here] [--since 7d] [--max 12] [--session <id>]
@@ -37,6 +39,7 @@ Options
37
39
  --approve Approve the hooks and agents without asking (for scripts)
38
40
  --scope <s> Muse install scope: user (default) or project
39
41
  --json Machine-readable output (doctor, models)
42
+ --no-agent-file install: leave Muse's user rules file alone for this run
40
43
  --offline Skip the npm update check
41
44
  --discard-modified uninstall: also remove a bundle edited after install
42
45
  --version Print the version`;
@@ -51,6 +54,7 @@ function parse(argv) {
51
54
  else if (a === '--approve') flags.approve = true;
52
55
  else if (a === '--json') flags.json = true;
53
56
  else if (a === '--offline') flags.offline = true;
57
+ else if (a === '--no-agent-file') flags.noAgentFile = true;
54
58
  else if (a === '--discard-modified') flags.discardModified = true;
55
59
  else if (a === '--card') flags.card = true;
56
60
  else if (a === '--scope') flags.scope = argv[++i];
@@ -61,10 +65,11 @@ function parse(argv) {
61
65
  return { command: rest[0] ?? 'help', args: rest.slice(1), flags };
62
66
  }
63
67
 
64
- async function ask(question) {
68
+ async function ask(question, { defaultYes = false } = {}) {
65
69
  const rl = createInterface({ input: process.stdin, output: process.stdout });
66
70
  try {
67
- return /^y(es)?$/i.test((await rl.question(question)).trim());
71
+ const answer = (await rl.question(question)).trim();
72
+ return defaultYes ? !/^n/i.test(answer) : /^y(es)?$/i.test(answer);
68
73
  } finally {
69
74
  rl.close();
70
75
  }
@@ -104,6 +109,8 @@ async function main() {
104
109
  case 'hud':
105
110
  print(readFileSync(join(PACKAGE_ROOT, 'docs', 'hud-preset.md'), 'utf8'));
106
111
  return 0;
112
+ case 'config':
113
+ return configCommand(args, env, print);
107
114
  case 'style':
108
115
  return styleCommand(args, env, print);
109
116
  case 'status': {
@@ -120,6 +127,7 @@ async function main() {
120
127
  const u = await checkUpdate(env);
121
128
  if (u.latest && u.latest !== packageVersion()) print(`update: ${u.latest} available (npx @litfamily/litmuse@latest install)`);
122
129
  }
130
+ print(`agent file ${readAgentFileSetting(env).mode === 'off' ? 'off' : agentFileState({ path: paths(env).museRules, version: packageVersion() })}`);
123
131
  print('Run `npx @litfamily/litmuse doctor` for every check.');
124
132
  return 0;
125
133
  }
@@ -0,0 +1,11 @@
1
+ ## LitFamily · LitMuse
2
+
3
+ LitMuse adds the lit workflows to Muse Code (lit plan, lit review, lit research, lit handoff). Its
4
+ hooks load the full rules each session, so this block only sets the ground rules.
5
+
6
+ - `lit` at the start of a prompt is LitMuse's activation word, not the Lit web library.
7
+ - Plans live in `plans/`, handoffs in `.litmuse/handoff/latest.md`, run state in `.litmuse/`. Read them
8
+ before resuming work in a repo.
9
+ - Never push, publish, tag or bump a version without the user's explicit yes for that action.
10
+ - Call work done only after its tests pass and the changed surface was checked for real.
11
+ - Turn this block off with `npx @litfamily/litmuse config agentFile off`.
@@ -0,0 +1,247 @@
1
+ import { chmodSync, lstatSync, readdirSync, readFileSync, statSync, unlinkSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { paths } from './paths.mjs';
5
+ import { packageVersion } from './bundle.mjs';
6
+ import { writeFileAtomic, copyFileAtomic } from './safe-write.mjs';
7
+ import { inspectBlock, ManagedBlockError, removeBlock, upsertBlock } from './managed-block.mjs';
8
+
9
+ // The short LitFamily block in Muse's own user rules file, `$XDG_CONFIG_HOME/muse/AGENTS.md`. Muse loads that
10
+ // file as user-scope rules in every session (probed on 1.4.14), so it is the one place a global ground rule
11
+ // can live without writing into another tool's home. The on/off choice is `agentFile` in LitMuse's own
12
+ // config.json; LITMUSE_AGENT_FILE overrides it for a run.
13
+
14
+ export const AGENT_FILE_ID = 'litmuse';
15
+ export const AGENT_FILE_MODES = Object.freeze(['on', 'off']);
16
+ // The block is read at the start of every session, so it stays small.
17
+ export const AGENT_FILE_MAX_BYTES = 1200;
18
+ // Muse cuts a rules file after this many bytes (its startup context limit), so a block past it is never read.
19
+ export const MUSE_LOAD_LIMIT = 32000;
20
+
21
+ const BLOCK_FILE = join(dirname(fileURLToPath(import.meta.url)), 'agent-file-block.md');
22
+ const CONFIG_CMD = 'npx @litfamily/litmuse config agentFile';
23
+ const QUESTION = 'Add the short LitFamily block to your Muse user rules file? Your own text stays as it is. [Y/n] ';
24
+
25
+ export const agentFileBody = () => readFileSync(BLOCK_FILE, 'utf8');
26
+
27
+ function readConfig(env) {
28
+ try {
29
+ const file = paths(env).config;
30
+ if (lstatSync(file).isSymbolicLink()) return {};
31
+ return JSON.parse(readFileSync(file, 'utf8')) ?? {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ /** `{mode, stored}`: `mode` is what acts (LITMUSE_AGENT_FILE, else the stored value, else on); `stored` is only what the user chose. */
38
+ export function readAgentFileSetting(env = process.env) {
39
+ const stored = readConfig(env).agentFile;
40
+ const choice = AGENT_FILE_MODES.includes(stored) ? stored : undefined;
41
+ const override = String(env.LITMUSE_AGENT_FILE ?? '').trim().toLowerCase();
42
+ return { mode: AGENT_FILE_MODES.includes(override) ? override : (choice ?? 'on'), stored: choice };
43
+ }
44
+
45
+ export function writeAgentFileSetting(env, mode) {
46
+ if (!AGENT_FILE_MODES.includes(mode)) throw new Error(`agentFile takes one of: ${AGENT_FILE_MODES.join(', ')}`);
47
+ writeFileAtomic(paths(env).config, `${JSON.stringify({ ...readConfig(env), agentFile: mode }, null, 2)}\n`);
48
+ }
49
+
50
+ // 'missing', 'file', 'symlink' or 'other', without following a link.
51
+ function entryKind(path) {
52
+ try {
53
+ const st = lstatSync(path);
54
+ return st.isSymbolicLink() ? 'symlink' : st.isFile() ? 'file' : 'other';
55
+ } catch (e) {
56
+ if (e.code === 'ENOENT') return 'missing';
57
+ throw e;
58
+ }
59
+ }
60
+
61
+ // The file's text, or null when it is not UTF-8 (UTF-16 from a Windows editor, Latin-1): rewriting those would
62
+ // corrupt the user's bytes outside the markers.
63
+ function readUtf8(path) {
64
+ const bytes = readFileSync(path);
65
+ if (bytes.includes(0) || (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) return null;
66
+ try {
67
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ const reaches = (text, end) => Buffer.byteLength(text.slice(0, end));
74
+
75
+ /**
76
+ * Installs or removes the block in `path`. Returns `{status, path, backup?, preview?, error?}` with status
77
+ * created | updated | unchanged | removed | absent | dry-run | refused-symlink | refused-not-file |
78
+ * refused-not-utf8 | refused-malformed | refused-too-large | refused-io. The user's file is copied into
79
+ * `backupDir` when the block first goes in, unless an identical copy is already there.
80
+ */
81
+ export function applyAgentFile({ path, id = AGENT_FILE_ID, version, body = agentFileBody(), action, dryRun = false, now = new Date(), backupDir }) {
82
+ try {
83
+ const kind = entryKind(path);
84
+ if (kind === 'symlink') return { status: 'refused-symlink', path };
85
+ if (kind === 'other') return { status: 'refused-not-file', path };
86
+ const text = kind === 'file' ? readUtf8(path) : '';
87
+ if (text === null) return { status: 'refused-not-utf8', path };
88
+ let next;
89
+ try {
90
+ next = action === 'uninstall' ? removeBlock(text, id) : upsertBlock(text, { id, version, body }, { created: kind === 'missing' });
91
+ } catch (e) {
92
+ if (e instanceof ManagedBlockError) return { status: 'refused-malformed', path };
93
+ throw e;
94
+ }
95
+ if (!next.changed) return { status: action === 'uninstall' ? 'absent' : 'unchanged', path };
96
+ if (action !== 'uninstall' && reaches(next.text, inspectBlock(next.text, id).end) > MUSE_LOAD_LIMIT) return { status: 'refused-too-large', path };
97
+ if (dryRun) return { status: 'dry-run', path, preview: next.text };
98
+ if (action === 'uninstall') {
99
+ if (next.text === '' && next.created) unlinkSync(path);
100
+ else rewrite(path, next.text);
101
+ return { status: 'removed', path };
102
+ }
103
+ let backup;
104
+ if (backupDir && kind === 'file' && inspectBlock(text, id).state === 'absent' && !alreadySaved(backupDir, text)) {
105
+ backup = join(backupDir, `AGENTS.md.${now.toISOString().replace(/[:.]/g, '-')}.bak`);
106
+ copyFileAtomic(path, backup);
107
+ }
108
+ rewrite(path, next.text);
109
+ return { status: kind === 'file' ? 'updated' : 'created', path, ...(backup ? { backup } : {}) };
110
+ } catch (e) {
111
+ return { status: 'refused-io', path, error: e?.code ?? e?.message };
112
+ }
113
+ }
114
+
115
+ // True when `backupDir` already holds a copy of exactly this text, so turning the block off and on again
116
+ // does not pile up identical copies.
117
+ function alreadySaved(backupDir, text) {
118
+ try {
119
+ return readdirSync(backupDir).some((name) => /^AGENTS\.md\..*\.bak$/u.test(name) && readUtf8(join(backupDir, name)) === text);
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ // Replaces the file through a temporary one and keeps its permissions.
126
+ function rewrite(path, text) {
127
+ let mode = null;
128
+ try {
129
+ mode = statSync(path).mode & 0o7777;
130
+ } catch {
131
+ // A new file takes the default mode.
132
+ }
133
+ writeFileAtomic(path, text);
134
+ if (mode !== null) chmodSync(path, mode);
135
+ }
136
+
137
+ /**
138
+ * `present v=<x>` (current), `stale v=<x>` (an older install's block), `truncated v=<x>` (the block sits past
139
+ * Muse's load limit), `absent`, `malformed`, or `symlink` / `not-file` / `not-utf8` for a file LitMuse will not write.
140
+ */
141
+ export function agentFileState({ path, id = AGENT_FILE_ID, version }) {
142
+ const kind = entryKind(path);
143
+ if (kind === 'missing') return 'absent';
144
+ if (kind === 'symlink') return 'symlink';
145
+ if (kind === 'other') return 'not-file';
146
+ const text = readUtf8(path);
147
+ if (text === null) return 'not-utf8';
148
+ const found = inspectBlock(text, id);
149
+ if (found.state !== 'present') return found.state;
150
+ if (reaches(text, found.end) > MUSE_LOAD_LIMIT) return `truncated v=${found.version}`;
151
+ return `${found.version === version ? 'present' : 'stale'} v=${found.version}`;
152
+ }
153
+
154
+ export const agentFileLine = (r) => `AGENT_FILE: ${r.status} ${r.path}${r.backup ? ` (backup: ${r.backup})` : ''}${r.error ? ` (${r.error})` : ''}`;
155
+
156
+ /** The install plan line for the block, shown before anything is written. */
157
+ export function agentFilePlan({ env, flags }) {
158
+ const path = paths(env).museRules;
159
+ if (flags.noAgentFile || readAgentFileSetting(env).mode === 'off') return `off (${path} left as it is)`;
160
+ return `Would add the LitFamily block to ${path} (asks first; --yes adds it, --no-agent-file skips)`;
161
+ }
162
+
163
+ /** The install step: adds or refreshes the block when the setting and the user's answer allow it. Returns the paths it wrote. */
164
+ export async function installAgentFile({ env, flags, interactive, ask, print, version, backupDir }) {
165
+ const path = paths(env).museRules;
166
+ const { mode, stored } = readAgentFileSetting(env);
167
+ if (flags.noAgentFile) {
168
+ print(`AGENT_FILE: skipped (this run only; ${path} left as it is)`);
169
+ return [];
170
+ }
171
+ if (mode === 'off') {
172
+ print(`AGENT_FILE: off (${path} left as it is; turn on: ${CONFIG_CMD} on)`);
173
+ return [];
174
+ }
175
+ const state = agentFileState({ path, version });
176
+ // A linked, non-file or non-UTF-8 rules file is never written, so asking about it would only mislead.
177
+ if (['symlink', 'not-file', 'not-utf8'].includes(state)) {
178
+ print(`AGENT_FILE: ${state} ${path}; left as it is`);
179
+ return [];
180
+ }
181
+ if (state === 'absent' && !flags.yes && stored !== 'on') {
182
+ if (!interactive || !ask) {
183
+ print(`AGENT_FILE: skipped (add the LitFamily block to ${path}: ${CONFIG_CMD} on)`);
184
+ return [];
185
+ }
186
+ if (!(await ask(QUESTION, { defaultYes: true }))) {
187
+ writeAgentFileSetting(env, 'off');
188
+ print(`AGENT_FILE: off (turn on later: ${CONFIG_CMD} on)`);
189
+ return [];
190
+ }
191
+ }
192
+ const result = applyAgentFile({ path, version, action: 'install', backupDir });
193
+ print(agentFileLine(result));
194
+ return ['created', 'updated'].includes(result.status) ? [path] : [];
195
+ }
196
+
197
+ /** The doctor row for the block. */
198
+ export function agentFileRow({ env, version }) {
199
+ const path = paths(env).museRules;
200
+ const { mode } = readAgentFileSetting(env);
201
+ const label = 'agent file';
202
+ if (mode === 'off') return { status: 'INFO', label, detail: `off (${path} left as it is)`, fix: `${CONFIG_CMD} on` };
203
+ const state = agentFileState({ path, version });
204
+ if (state.startsWith('present')) return { status: 'PASS', label, detail: `${state} (${path})` };
205
+ if (state.startsWith('stale')) return { status: 'WARN', label, detail: `${state} (${path}); run npx @litfamily/litmuse install to refresh it` };
206
+ if (state.startsWith('truncated')) return { status: 'WARN', label, detail: `${state} (${path}); Muse cuts a rules file after ${MUSE_LOAD_LIMIT} bytes, so Muse never reads the block; trim the file above it` };
207
+ if (state === 'absent') return { status: 'INFO', label, detail: `absent (${path})`, fix: `${CONFIG_CMD} on` };
208
+ return { status: 'WARN', label, detail: `${state} (${path}); LitMuse leaves the file as it is` };
209
+ }
210
+
211
+ /** `litmuse config [agentFile on|off]`; returns the exit code. */
212
+ export function configCommand(args, env = process.env, print = console.log) {
213
+ const [key, value, ...extra] = args;
214
+ const p = paths(env);
215
+ const version = packageVersion();
216
+ if (!key) {
217
+ print(`agentFile ${readAgentFileSetting(env).mode.padEnd(3)} (${AGENT_FILE_MODES.join(', ')})`);
218
+ print(`Change it with: litmuse config agentFile <${AGENT_FILE_MODES.join('|')}>. It acts at once on ${p.museRules}.`);
219
+ return 0;
220
+ }
221
+ if (key !== 'agentFile') {
222
+ print(`Unknown option: ${key}. Options: agentFile`);
223
+ return 2;
224
+ }
225
+ const mode = String(value ?? '').trim().toLowerCase();
226
+ if (!AGENT_FILE_MODES.includes(mode) || extra.length) {
227
+ print(`agentFile takes one of: ${AGENT_FILE_MODES.join(', ')}`);
228
+ return 2;
229
+ }
230
+ try {
231
+ writeAgentFileSetting(env, mode);
232
+ } catch (e) {
233
+ print(`litmuse: could not save the setting: ${e.message}`);
234
+ return 1;
235
+ }
236
+ const result = applyAgentFile({ path: p.museRules, version, action: mode === 'off' ? 'uninstall' : 'install', backupDir: p.backups });
237
+ print(`AGENT_FILE: ${mode}`);
238
+ print(agentFileLine(result));
239
+ return result.status.startsWith('refused') ? 1 : 0;
240
+ }
241
+
242
+ /** Removes the block for `uninstall`; returns the line to print. */
243
+ export function removeAgentFile({ env, version, dryRun }) {
244
+ const path = paths(env).museRules;
245
+ const result = applyAgentFile({ path, version, action: 'uninstall', dryRun });
246
+ return result.status === 'dry-run' ? `Would remove the LitFamily block from ${path} (your own text stays)` : agentFileLine(result);
247
+ }
package/lib/doctor.mjs CHANGED
@@ -8,6 +8,7 @@ import { stageState, packageVersion, PLUGIN_DIR } from './bundle.mjs';
8
8
  import { approvalStates } from './approvals.mjs';
9
9
  import { checkUpdate } from './update-check.mjs';
10
10
  import { runtimeModules, callRuntime } from './runtime.mjs';
11
+ import { agentFileRow } from './agent-file.mjs';
11
12
 
12
13
  const CATALOG_CAP = 32 * 1024;
13
14
 
@@ -120,6 +121,9 @@ export async function doctor({ env = process.env, flags = {}, print = console.lo
120
121
  }
121
122
  }
122
123
 
124
+ const rules = agentFileRow({ env, version: packageVersion() });
125
+ add(rules.status, rules.label, rules.detail, rules.fix);
126
+
123
127
  if (existsSync(p.foreignRules)) {
124
128
  add('INFO', 'foreign rules', '~/.claude/CLAUDE.md is loaded by Muse as personal rules (Muse setting "Personal rules fallback"); LitMuse leaves it on and steers lit routes through its SessionStart context');
125
129
  }
package/lib/install.mjs CHANGED
@@ -5,6 +5,7 @@ import { preflightDestinations, writeFileAtomic, copyFileAtomic } from './safe-w
5
5
  import { muse, museVersion, installedPlugin } from './muse.mjs';
6
6
  import { stageState, stageBundle, packageVersion } from './bundle.mjs';
7
7
  import { expectedApprovals, approvalStates } from './approvals.mjs';
8
+ import { agentFilePlan, installAgentFile } from './agent-file.mjs';
8
9
 
9
10
  function remedy(state, detail, p) {
10
11
  const saved = `${p.stage}.saved-${new Date().toISOString().slice(0, 10)}`;
@@ -80,6 +81,7 @@ export async function install({ env = process.env, flags = {}, interactive = fal
80
81
  print(` bundle ${p.stage}${st.state === 'owned' ? ` (replaces LitMuse ${st.version})` : ''}`);
81
82
  print(` backups ${p.backups} (Muse settings.json and installed.json, when present)`);
82
83
  print(` receipt ${p.receipt}`);
84
+ print(` rules file ${agentFilePlan({ env, flags })}`);
83
85
  print(` muse muse ${installArgs.join(' ')}`);
84
86
  print(` approvals up to ${approvals.length} capabilities may need your approval in Muse (hooks and agents):`);
85
87
  for (const a of approvals) print(` ${a.id}`);
@@ -150,6 +152,9 @@ export async function install({ env = process.env, flags = {}, interactive = fal
150
152
  }
151
153
  }
152
154
 
155
+ print('');
156
+ written.push(...await installAgentFile({ env, flags, interactive, ask, print, version, backupDir: p.backups }));
157
+
153
158
  const left = states.filter((s) => s.status !== 'trusted_enabled').length;
154
159
  writeFileAtomic(p.receipt, `${JSON.stringify({
155
160
  product: 'litmuse', version, scope, installedAt: new Date().toISOString(), host: mv,
@@ -0,0 +1,78 @@
1
+ // A LitFamily block inside a file the user owns: the lines from `<!-- litfamily:<id>:start v=<version> -->`
2
+ // to `<!-- litfamily:<id>:end -->`. Only those lines are written; everything before and after stays byte for
3
+ // byte. The start marker also records what was put in front of the block (`pre=`, hex) and whether the
4
+ // file was made for the block (`new=1`), so removal takes back exactly that and add-then-remove restores
5
+ // the original bytes under either line ending. A start without its end, or a repeated marker, is
6
+ // malformed: it is reported and refused, never repaired by guesswork.
7
+
8
+ export class ManagedBlockError extends Error {}
9
+
10
+ const startPrefix = (id) => `<!-- litfamily:${id}:start`;
11
+ const endMarker = (id) => `<!-- litfamily:${id}:end -->`;
12
+ const START_LINE = /^<!-- litfamily:[^:\s]+:start v=(\S+)((?: [a-z]+=[0-9a-f]*)*) -->/u;
13
+
14
+ function indexesOf(text, needle) {
15
+ const at = [];
16
+ for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + needle.length)) at.push(i);
17
+ return at;
18
+ }
19
+
20
+ // The block's span `[start, end)` with its recorded attributes, or null when the file has none.
21
+ function locate(text, id) {
22
+ const starts = indexesOf(text, startPrefix(id));
23
+ const ends = indexesOf(text, endMarker(id));
24
+ if (!starts.length && !ends.length) return null;
25
+ if (starts.length !== 1 || ends.length !== 1 || ends[0] < starts[0]) {
26
+ throw new ManagedBlockError(`malformed LitFamily ${id} block: expected one start and one end marker`);
27
+ }
28
+ const head = START_LINE.exec(text.slice(starts[0]));
29
+ if (!head) throw new ManagedBlockError(`malformed LitFamily ${id} block: unreadable start marker`);
30
+ const attrs = Object.fromEntries(head[2].trim().split(' ').filter(Boolean).map((pair) => pair.split('=')));
31
+ return {
32
+ start: starts[0],
33
+ end: ends[0] + endMarker(id).length,
34
+ version: head[1],
35
+ pre: attrs.pre ? Buffer.from(attrs.pre, 'hex').toString('utf8') : '',
36
+ created: attrs.new === '1',
37
+ };
38
+ }
39
+
40
+ function render({ id, version, body }, eol, { pre, created }) {
41
+ const attrs = `${pre ? ` pre=${Buffer.from(pre, 'utf8').toString('hex')}` : ''}${created ? ' new=1' : ''}`;
42
+ const lines = String(body).replace(/\r?\n$/u, '').split(/\r?\n/u);
43
+ return [`${startPrefix(id)} v=${version}${attrs} -->`, ...lines, endMarker(id)].join(eol);
44
+ }
45
+
46
+ /** `{state: 'absent' | 'present' | 'malformed', version, created, end}` for block `id` in `text`; `end` is the index just past its end marker. */
47
+ export function inspectBlock(text, id) {
48
+ try {
49
+ const span = locate(text, id);
50
+ return span ? { state: 'present', version: span.version, created: span.created, end: span.end } : { state: 'absent', version: null, created: false, end: null };
51
+ } catch {
52
+ return { state: 'malformed', version: null, created: false, end: null };
53
+ }
54
+ }
55
+
56
+ /** Adds block `block` or replaces the one there; `created` marks a file made for it. Returns `{text, changed}`. */
57
+ export function upsertBlock(text, block, { created = false } = {}) {
58
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
59
+ const span = locate(text, block.id);
60
+ if (span) {
61
+ const next = `${text.slice(0, span.start)}${render(block, eol, span)}${text.slice(span.end)}`;
62
+ return { text: next, changed: next !== text };
63
+ }
64
+ const pre = text === '' ? '' : `${text.endsWith('\n') ? '' : eol}${eol}`;
65
+ return { text: `${text}${pre}${render(block, eol, { pre, created })}${eol}`, changed: true };
66
+ }
67
+
68
+ /** Removes block `id`, the line ending after it and whatever was put before it. `created` comes from its marker. */
69
+ export function removeBlock(text, id) {
70
+ const span = locate(text, id);
71
+ if (!span) return { text, changed: false, created: false };
72
+ let before = text.slice(0, span.start);
73
+ let after = text.slice(span.end);
74
+ const eol = after.startsWith('\r\n') ? '\r\n' : after.startsWith('\n') ? '\n' : '';
75
+ after = after.slice(eol.length);
76
+ if (span.pre && before.endsWith(span.pre)) before = before.slice(0, -span.pre.length);
77
+ return { text: `${before}${after}`, changed: true, created: span.created };
78
+ }
package/lib/paths.mjs CHANGED
@@ -14,7 +14,9 @@ export function paths(env = process.env) {
14
14
  backups: join(data, 'backups'),
15
15
  receipt: join(data, 'receipt.json'),
16
16
  updateCache: join(data, 'update-check.json'),
17
+ config: join(data, 'config.json'),
17
18
  museSettings: join(configHome, 'muse', 'settings.json'),
19
+ museRules: join(configHome, 'muse', 'AGENTS.md'),
18
20
  museInstalled: join(dataHome, 'muse', 'plugins', 'installed.json'),
19
21
  museCatalogDir: join(dataHome, 'muse', 'model-catalog'),
20
22
  foreignRules: join(home, '.claude', 'CLAUDE.md'),
@@ -21,7 +21,7 @@ function realExistingAncestor(p) {
21
21
  export function preflightDestinations(p) {
22
22
  const checks = [
23
23
  [p.data, 'directory'], [p.stage, 'directory'], [p.backups, 'directory'],
24
- [p.receipt, 'file'], [p.updateCache, 'file'],
24
+ [p.receipt, 'file'], [p.updateCache, 'file'], [p.config, 'file'],
25
25
  ];
26
26
  for (const [target, kind] of checks) {
27
27
  const st = lstatOrNull(target);
package/lib/uninstall.mjs CHANGED
@@ -3,6 +3,8 @@ import { paths } from './paths.mjs';
3
3
  import { muse, installedPlugin } from './muse.mjs';
4
4
  import { stageState } from './bundle.mjs';
5
5
  import { assertSafeWritePath } from './guard.mjs';
6
+ import { removeAgentFile } from './agent-file.mjs';
7
+ import { packageVersion } from './bundle.mjs';
6
8
 
7
9
  export async function uninstall({ env = process.env, flags = {}, interactive = false, print = console.log }) {
8
10
  const p = paths(env);
@@ -18,6 +20,7 @@ export async function uninstall({ env = process.env, flags = {}, interactive = f
18
20
  if (st.state === 'modified' && !discard) print(' hint add --discard-modified to remove the edited bundle too');
19
21
  print(` kept ${p.backups} and ${p.receipt}`);
20
22
  if (flags.dryRun) {
23
+ print(removeAgentFile({ env, version: packageVersion(), dryRun: true }));
21
24
  print('Dry run: nothing removed.');
22
25
  return 0;
23
26
  }
@@ -37,6 +40,7 @@ export async function uninstall({ env = process.env, flags = {}, interactive = f
37
40
  rmSync(assertSafeWritePath(p.stage), { recursive: true, force: true });
38
41
  print(`Removed ${p.stage}.`);
39
42
  }
43
+ print(removeAgentFile({ env, version: packageVersion() }));
40
44
  print('Project files LitMuse sessions wrote (plans/, .litmuse/) stay in each project.');
41
45
  return 0;
42
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litmuse",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "name": "litmuse",
4
4
  "displayName": "LitMuse",
5
- "version": "0.1.4",
5
+ "version": "0.1.6",
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",
@@ -262,7 +262,7 @@
262
262
  "node",
263
263
  "hooks/user-prompt-submit.mjs",
264
264
  "--digest",
265
- "17cbcfe331b17df6"
265
+ "dbfd31cd0446cd80"
266
266
  ],
267
267
  "timeoutMs": 5000,
268
268
  "statusMessage": "LitMuse route check"