@litfamily/litclaude 1.0.26 → 1.0.28

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.28 - 2026-10-14
4
+
5
+ - A new lit-loop no longer shows every stage of the card's workflow map as done when an older goal in the project had already finished. The finished goal now ends the map only when it finished after the current stage was recorded, so the map follows the work you are doing now; a goal that really finishes still shows done.
6
+ - `litclaude-ai` is now on the Bash PATH after a plugin install, so the documented `litclaude-ai litgoal`, `start-work`, `workflow-stage` and similar commands run without the npm package. Other subcommands point you at the npm package.
7
+ - The start-work gate lets its own `start-work pause` through when it names an outside target root. The pause records a pending boundary and grants nothing; forbidden actions are still refused.
8
+
9
+ ## 1.0.27 - 2026-10-13
10
+
11
+ - The lit-loop map now moves when the work happens in the shell. Writing a test with a shell command (`cat > test/…`, `>>`, `tee` or `sed -i`) turns the map to RED, changing a source file the same way turns it to GREEN, and the next test run turns it to VERIFY. A failing test run while the criterion is still pinned also counts as RED, including a bare `npm test` that exits with an error. The card keeps only the stage, never the command or the file names.
12
+ - `litclaude-ai install` no longer stops with `INSTALL_OWNERSHIP_CONFLICT` when the Claude desktop app has marked the installed plugin as in use. The app's `.in_use` folder, with its process-numbered entries, is left in place; anything else inside it is still reported.
13
+ - The start-work gate lets read-only git through during active work: `git status`, `git diff`, `git log` and `git show`, with any paths they name still checked against the work's grants. Its own lifecycle commands, `litclaude-ai start-work progress`, `complete` and `cancel`, pass too. `node -e` stays blocked, because its script cannot be read as read-only from the command alone.
14
+ - The README demo film is retaken: the card's workflow map turns RED on the failing test, then GREEN and VERIFY when the tests pass, and the live step names the running tool and its time.
15
+
3
16
  ## 1.0.26 - 2026-10-13
4
17
 
5
18
  - The LIT card now shows where you are in the LIT workflow. A map row lights the current stage of the running family (interview, plan, work, review, loop, research), and a detail line under it says what that stage is doing: the interview round and ambiguity, whether the plan awaits approval, how many tasks are checked and which comes next, each review lane's verdict. On the right of that line the live step names the tool the main loop is running and for how long, or `thinking` between tool calls, and never shows its arguments.
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/litclaude@1.0.26/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitClaude robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitClaude robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  <h1 align="center">LitClaude</h1>
4
4
  <p align="center"><strong>Keep the work lit.</strong></p>
5
5
  <p align="center">A Claude Code plugin that helps every session plan the work, build it, check it, and leave a note the next session can pick up.</p>
6
6
  <p align="center">
7
- <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/assets/readme/badge-version.svg" alt="1.0.26" />
8
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/readme/badge-version.svg" alt="1.0.28" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
9
9
  </p>
10
10
  <p align="center">
11
- <a href="https://github.com/wjgoarxiv/litclaude#readme">Full guide and skills gallery on GitHub</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/README_ko-KR.md">한국어</a>
11
+ <a href="https://github.com/wjgoarxiv/litclaude#readme">Full guide and skills gallery on GitHub</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/README_ko-KR.md">한국어</a>
12
12
  </p>
13
13
 
14
14
  Add `lit` to a Claude Code prompt and Claude pins the goal, writes a failing test first,
@@ -73,7 +73,7 @@ the `/plugin` menu.
73
73
  ## More
74
74
 
75
75
  - [Full guide and skills gallery on GitHub](https://github.com/wjgoarxiv/litclaude#readme)
76
- - [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/privacy.md)
77
- - [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/migration.md)
78
- - [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/CHANGELOG.md)
79
- - [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/LICENSE)
76
+ - [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/privacy.md)
77
+ - [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/migration.md)
78
+ - [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/CHANGELOG.md)
79
+ - [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/LICENSE)
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/litclaude@1.0.26/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitClaude 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitClaude 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  <h1 align="center">LitClaude</h1>
4
4
  <p align="center"><strong>Keep the work lit.</strong></p>
5
5
  <p align="center">Claude Code에서 세션마다 계획하고, 만들고, 확인한 뒤 다음 세션이 이어받을 기록을 남기게 돕는 플러그인입니다.</p>
6
6
  <p align="center">
7
- <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/assets/readme/badge-version.svg" alt="1.0.26" />
8
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/readme/badge-version.svg" alt="1.0.28" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
9
9
  </p>
10
10
  <p align="center">
11
- <a href="https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md">전체 안내와 스킬 갤러리는 GitHub에서</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/README.md">English</a>
11
+ <a href="https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md">전체 안내와 스킬 갤러리는 GitHub에서</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/README.md">English</a>
12
12
  </p>
13
13
 
14
14
  Claude Code 프롬프트에 `lit`을 붙이면 Claude가 목표를 고정하고, 실패하는 테스트를 먼저 쓰고,
@@ -71,7 +71,7 @@ LitClaude는 프로젝트 상태를 `.litclaude/`에 두고, 그 안의 `.gitign
71
71
  ## 더 보기
72
72
 
73
73
  - [전체 안내와 스킬 갤러리 (GitHub)](https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md)
74
- - [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/privacy.md)
75
- - [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/docs/migration.md)
76
- - [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/CHANGELOG.md)
77
- - [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.26/LICENSE)
74
+ - [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/privacy.md)
75
+ - [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/docs/migration.md)
76
+ - [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/CHANGELOG.md)
77
+ - [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.28/LICENSE)
@@ -23,6 +23,11 @@ const legacyVendorPathMap = [
23
23
  // Python writes bytecode next to the scripts it runs. That cache is generated, never user data, so ownership
24
24
  // checks skip it. A symlink or a plain file named __pycache__ is not a cache and is still reported.
25
25
  const isBytecodeCache = (name, value) => (value.isDirectory() && name === "__pycache__") || (value.isFile() && name.endsWith(".pyc"));
26
+ // Claude Code and the desktop app mark a cache version they have loaded with `.in_use/`, empty or
27
+ // holding one pid-named entry per process. That marker is the host's: ownership checks skip it and
28
+ // never remove it. Any other name inside it, or a symlink, is still reported.
29
+ const isHostUseMarker = (path, name, value) => name === hostUseMarker && value.isDirectory()
30
+ && readdirSync(path, { withFileTypes: true }).every((entry) => /^\d+$/u.test(entry.name) && (entry.isDirectory() || entry.isFile()));
26
31
  const conflict = (path, detail = "") => { throw new Error(`INSTALL_OWNERSHIP_CONFLICT: ${path}; use a compatible CLI or preserve this modified, foreign, or unsupported state before retrying${detail ? `. ${detail}` : ""}`); };
27
32
  const stat = (path) => {
28
33
  try { return lstatSync(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
@@ -49,7 +54,7 @@ function treeHash(root) {
49
54
  if (!prefix && name === receiptName) continue;
50
55
  const next = join(path, name), rel = prefix ? `${prefix}/${name}` : name, value = lstatSync(next);
51
56
  if (isBytecodeCache(name, value)) continue;
52
- if (!prefix && name === hostUseMarker && value.isDirectory() && readdirSync(next).length === 0) continue;
57
+ if (!prefix && isHostUseMarker(next, name, value)) continue;
53
58
  if (value.isDirectory()) { entries.push([rel, "directory"]); walk(next, rel); }
54
59
  else if (value.isFile() && value.nlink === 1) entries.push([rel, "file", createHash("sha256").update(readFileSync(next)).digest("hex")]);
55
60
  else conflict(next);
@@ -69,6 +74,7 @@ function detailedTree(root, { receipt = false } = {}) {
69
74
  rows.push([prefix, "directory", value.mode & 0o777]);
70
75
  for (const name of readdirSync(path).sort()) {
71
76
  if (!prefix && !receipt && name === receiptName) continue;
77
+ if (!prefix && isHostUseMarker(join(path, name), name, lstatSync(join(path, name)))) continue;
72
78
  walk(join(path, name), prefix ? `${prefix}/${name}` : name);
73
79
  }
74
80
  return;
@@ -270,6 +276,18 @@ export function assertCurrentPointer(home, current, target, version, { expectedV
270
276
  return currentVersion;
271
277
  }
272
278
 
279
+ // Empties an owned tree before a fresh copy, leaving the host's `.in_use` marker in place.
280
+ export function clearOwnedTree(path) {
281
+ const value = stat(path);
282
+ if (!value) return;
283
+ if (!value.isDirectory() || value.isSymbolicLink()) { rmSync(path, { recursive: true, force: true }); return; }
284
+ for (const name of readdirSync(path)) {
285
+ const next = join(path, name);
286
+ if (isHostUseMarker(next, name, lstatSync(next))) continue;
287
+ rmSync(next, { recursive: true, force: true });
288
+ }
289
+ }
290
+
273
291
  export function removeEmptyDirectory(path) {
274
292
  try { rmdirSync(path); } catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error; }
275
293
  }
@@ -46,7 +46,7 @@ import { jevStatusLine } from "../plugins/litclaude/lib/jev-skill-hint.mjs";
46
46
  import { autoHandoffDoctorLines } from "../plugins/litclaude/lib/auto-handoff.mjs";
47
47
  import { resolveProjectStateRoot } from "../plugins/litclaude/lib/project-state-root.mjs";
48
48
  import { runUpdateNotifier } from "./update-notifier.mjs";
49
- import { assertInstallPath, assertOwnedTree, assertCurrentPointer, assertOwnedRegistrations, migrateLegacyVendorPaths, planModifiedLegacySkill, preserveModifiedLegacySkill, registeredPluginInstallation, writeOwnershipReceipt, removeEmptyDirectory } from "./install-ownership.mjs";
49
+ import { assertInstallPath, assertOwnedTree, assertCurrentPointer, assertOwnedRegistrations, migrateLegacyVendorPaths, planModifiedLegacySkill, preserveModifiedLegacySkill, registeredPluginInstallation, writeOwnershipReceipt, removeEmptyDirectory, clearOwnedTree } from "./install-ownership.mjs";
50
50
  import { banner, lockup, colorMode, supportsBlocks, terminalRows } from "./litfamily-banner.mjs";
51
51
  import {
52
52
  ICON_FONT,
@@ -1393,7 +1393,7 @@ const install = async ({ dryRun, rest }) => {
1393
1393
  target: targetPlugin,
1394
1394
  },
1395
1395
  async () => {
1396
- await rm(targetPlugin, { recursive: true, force: true });
1396
+ clearOwnedTree(targetPlugin);
1397
1397
  await mkdir(dirname(targetPlugin), { recursive: true });
1398
1398
  await cp(sourcePlugin, targetPlugin, { recursive: true });
1399
1399
  writeOwnershipReceipt(targetPlugin, "plugin", version);
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.26"><title>release: 1.0.26</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.26</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.28"><title>release: 1.0.28</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.28</text></g></svg>
package/docs/hooks.md CHANGED
@@ -12,7 +12,7 @@ surface: Claude Code plugin hooks
12
12
  runtime: plugins/litclaude/bin/litclaude-hook.js
13
13
  registration: plugins/litclaude/hooks/hooks.json
14
14
  settings_activation: global Claude settings.json enabledPlugins entry for litclaude@litclaude-ai
15
- events: [SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop, SessionEnd]
15
+ events: [SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Stop, SubagentStart, SubagentStop, SessionEnd]
16
16
  verdicts: [PASS, FAIL, BLOCKED]
17
17
  ```
18
18
 
@@ -106,6 +106,7 @@ silent no-op for callers that still invoke it directly.
106
106
  | `UserPromptSubmit` | `plugins/litclaude/bin/litclaude-hook.js user-prompt-submit` | Detects prompt routes and injects workflow context; independently delivers static rules not already recorded for this session; with `LITCLAUDE_JEV=1` and `TYPESAFE_API_KEY` set, adds at most one Jev skill hint line to an unrouted turn; answers the exact prompts `lit-handoff auto on <percent>`, `lit-handoff auto off` and `lit-handoff auto status` by changing or reporting the automatic handoff setting. |
107
107
  | `PreToolUse` | `plugins/litclaude/bin/litclaude-hook.js pre-tool-use` | Enforces semantic action/root grants before Write, Edit, MultiEdit, NotebookEdit, Bash, Agent, and bounded read tools execute; denies supported reader-facing text writes with a humanizer block-tier finding. |
108
108
  | `PostToolUse` | `plugins/litclaude/bin/litclaude-hook.js post-tool-use` | Moves the LIT card's stage record on from what the tool did (plan saved, task checked, lit-loop step, a reviewer's verdict from an `Agent` result). Names the post-edit checks the edit actually earned, injects any glob-scoped rule matching the edited paths, and checks successfully created DOCX/PPTX/PDF files for humanizer findings, including paths reported by Bash output. |
109
+ | `PostToolUseFailure` | `plugins/litclaude/bin/litclaude-hook.js post-tool-use-failure` | For a Bash command that exits non-zero, such as a bare failing `npm test`, moves the LIT card's lit-loop map from what the command wrote and from its failing test run, which never counts as VERIFY. Nothing else runs on a failed tool. |
109
110
  | `Stop` | `plugins/litclaude/bin/litclaude-hook.js stop` | Holds a lit-plan turn with no persisted plan and a lit workflow turn that edited interface files without a clean interface-probe run (each at most twice per turn), emits bounded start-work continuation on new progress, otherwise applies the opt-in litgoal autoloop gate, and last of all, with automatic handoff on, blocks once per crossing of the chosen percent to have the handoff written. |
110
111
  | `SubagentStart` | `plugins/litclaude/bin/litclaude-hook.js subagent-start` | Registers child-lane and Claude-owned worktree identity. |
111
112
  | `SubagentStop` | `plugins/litclaude/bin/litclaude-hook.js subagent-stop` | Finalizes the lane without child continuation, and gives a background review-work reviewer's verdict to the LIT card's stage record. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litclaude",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Claude Code plugin: add lit to a prompt and Claude plans, tests, checks and reviews the work, then leaves a handoff for the next session.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "litclaude",
3
3
  "description": "Claude Code-native workflow plugin with a bounded-authority start-work lifecycle.",
4
- "version": "1.0.26",
4
+ "version": "1.0.28",
5
5
  "author": {
6
6
  "name": "LitClaude contributors"
7
7
  },
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // The `litclaude-ai` the skills and commands name, as the installed plugin serves it. Claude Code
3
+ // puts this bin/ directory on the Bash tool's PATH; the npm CLI (bin/litclaude-ai.js in the
4
+ // package) is usually not there. This entry runs the in-session subcommands from the plugin's own
5
+ // lib/ and sends everything else (install, doctor, update…) to the user's terminal.
6
+ //
7
+ // No file extension, so the bare name resolves on PATH; the code avoids `import`, `require`,
8
+ // `import.meta` and `module` so it runs whether Node loads it as CommonJS or as an ES module.
9
+
10
+ const SUBCOMMANDS = {
11
+ litgoal: async (load, rest) => (await load("../lib/litgoal/cli.mjs")).runLitgoalCli(rest),
12
+ "start-work": async (load, rest) => (await load("../lib/start-work-cli.mjs")).runStartWorkCli(rest, undefined, process.cwd()),
13
+ "start-work-next": async (load, rest) => {
14
+ const { resolveProjectStateRoot } = await load("../lib/project-state-root.mjs");
15
+ return (await load("../lib/start-work-continuation.mjs")).runStartWorkContinuationCli(resolveProjectStateRoot(process.cwd()), rest);
16
+ },
17
+ "workflow-stage": async (load, rest) => (await load("../lib/workflow-stage.mjs")).runWorkflowStageCli(rest, process.cwd()),
18
+ wikify: async (load, rest) => (await load("../lib/wikify-knowledge-cli.mjs")).runWikifyKnowledgeCli(rest, undefined, process.cwd()),
19
+ "motion-runtime": async (load, rest) => (await load("../skills/lit-typographic-motion/scripts/motion-runtime.mjs")).main(rest),
20
+ };
21
+
22
+ const main = async () => {
23
+ const [command, ...rest] = process.argv.slice(2);
24
+ const run = Object.hasOwn(SUBCOMMANDS, command ?? "") ? SUBCOMMANDS[command] : null;
25
+ if (!run) {
26
+ process.stderr.write(
27
+ `litclaude-ai (plugin): ${command ? `"${command}" is not served from the plugin` : "no command given"}. `
28
+ + `In-session commands: ${Object.keys(SUBCOMMANDS).join(", ")}. `
29
+ + `For install, update, doctor and the rest, ask the user to run in their own terminal: npx @litfamily/litclaude ${command ?? "--help"}\n`,
30
+ );
31
+ return 64;
32
+ }
33
+ const { realpathSync } = await import("node:fs");
34
+ const { dirname, join } = await import("node:path");
35
+ const { pathToFileURL } = await import("node:url");
36
+ const here = dirname(realpathSync(process.argv[1]));
37
+ return run((relative) => import(pathToFileURL(join(here, relative)).href), rest);
38
+ };
39
+
40
+ main().then(
41
+ (code) => {
42
+ process.exitCode = Number.isInteger(code) ? code : 0;
43
+ },
44
+ (error) => {
45
+ process.stderr.write(`litclaude-ai (plugin): ${error?.message ?? error}\n`);
46
+ process.exitCode = 1;
47
+ },
48
+ );
@@ -124,6 +124,7 @@ const hookEventNames = {
124
124
  "user-prompt-submit": "UserPromptSubmit",
125
125
  "pre-tool-use": "PreToolUse",
126
126
  "post-tool-use": "PostToolUse",
127
+ "post-tool-use-failure": "PostToolUseFailure",
127
128
  "stop": "Stop",
128
129
  "subagent-start": "SubagentStart",
129
130
  "subagent-stop": "SubagentStop",
@@ -1548,6 +1549,17 @@ switch (eventName) {
1548
1549
  // dispatch silent; compact re-injection is delivered by SessionStart source=compact.
1549
1550
  break;
1550
1551
  }
1552
+ case "post-tool-use-failure": {
1553
+ // A Bash command that exits non-zero (a bare failing `npm test`) never reaches PostToolUse. Its
1554
+ // writes and its failing run still move the LIT card's lit-loop map; nothing else runs here.
1555
+ try {
1556
+ const cwd = typeof input.cwd === "string" ? input.cwd : process.cwd();
1557
+ observeWorkflowPostTool(cwd, input, { lifecycleRoot: lifecycleRootFor(cwd), failed: true });
1558
+ } catch {
1559
+ // The stage record only feeds the card.
1560
+ }
1561
+ break;
1562
+ }
1551
1563
  case "subagent-start": {
1552
1564
  try {
1553
1565
  const cwd = typeof input.cwd === "string" ? input.cwd : process.cwd();
@@ -9,7 +9,7 @@ import {
9
9
  } from "../lib/wikify-knowledge.mjs";
10
10
 
11
11
  const protocolVersion = "2024-11-05";
12
- const serverVersion = "1.0.26";
12
+ const serverVersion = "1.0.28";
13
13
 
14
14
  const publicSourceReadTool = {
15
15
  name: "public_source_read",
@@ -51,6 +51,19 @@
51
51
  ]
52
52
  }
53
53
  ],
54
+ "PostToolUseFailure": [
55
+ {
56
+ "matcher": "^Bash$",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/litclaude-hook.js\" post-tool-use-failure",
61
+ "timeout": 10,
62
+ "statusMessage": "moving the LIT card's workflow map"
63
+ }
64
+ ]
65
+ }
66
+ ],
54
67
  "Stop": [
55
68
  {
56
69
  "hooks": [
@@ -233,6 +233,10 @@ export const parseGoals = (state, sessionId) => {
233
233
  .filter(Boolean),
234
234
  lastCheckpoint: str(Array.isArray(state.checkpoints) ? state.checkpoints.at(-1)?.status : null, 40),
235
235
  createdAt: time(state.createdAt),
236
+ // When the goal was finished: its last `complete` checkpoint, else its last update.
237
+ completedAt: state.status === "complete"
238
+ ? time((Array.isArray(state.checkpoints) ? state.checkpoints : []).findLast((entry) => entry?.status === "complete")?.createdAt) ?? time(state.updatedAt)
239
+ : null,
236
240
  };
237
241
  };
238
242
 
@@ -111,6 +111,10 @@ const entryOf = ({ snapshot, skills, now }) => {
111
111
  return candidates.sort((a, b) => b.at - a.at || (a.from === "record" ? -1 : 1))[0] ?? null;
112
112
  };
113
113
 
114
+ // A finished goal ends the map unless a stage record, skill or ignition came after it: that is a
115
+ // new request, and the old finish says nothing about it.
116
+ const goalFinished = (goal, entry) => goal?.status === "complete" && !(Number.isFinite(goal.completedAt) && Number.isFinite(entry?.at) && entry.at > goal.completedAt);
117
+
114
118
  const goalIsOpen = (goal) => Boolean(goal && ["active", "blocked", "review_blocked", "needs_user_decision"].includes(goal.status));
115
119
 
116
120
  // Without a discipline or a record, the durable state that says work is under way.
@@ -173,7 +177,7 @@ export const buildWorkflow = ({ snapshot, skills = [], completion = null, now })
173
177
  const planSaved = stage === "plan" && record?.stage === "plan" && record.done;
174
178
  if (planSaved) stage = "work";
175
179
  const lanes = record?.stage === "review" ? laneVerdicts(record.verdicts) : null;
176
- if (completion || goal?.status === "complete" || stage === "done") stage = "done";
180
+ if (completion || goalFinished(goal, entry) || stage === "done") stage = "done";
177
181
  else if (stage === "work" && !work && !goalIsOpen(goal) && snapshot.completedWorkIds.length > 0) stage = "review";
178
182
  else if (stage === "review" && lanes?.every(({ state }) => state === "pass")) stage = "done";
179
183
  if (planSaved && stage === "work" && !work) items.push("plan saved");
@@ -226,7 +230,7 @@ export const buildWorkflow = ({ snapshot, skills = [], completion = null, now })
226
230
  }
227
231
 
228
232
  const order = WORKFLOW_FAMILIES[family];
229
- const finished = family === "main" ? stage === "done" : completion !== null || (family === "loop" && snapshot.goal?.status === "complete");
233
+ const finished = family === "main" ? stage === "done" : completion !== null || (family === "loop" && goalFinished(snapshot.goal, entry));
230
234
  const current = finished ? -1 : order.indexOf(stage);
231
235
  const wave = sources.research?.wave ?? 0;
232
236
  const stages = order.map((id, index) => {
@@ -936,6 +936,33 @@ const classifyNodeScriptWords = (args, cwd) => {
936
936
  };
937
937
 
938
938
  const LITCLAUDE_CLI_NAMES = new Set(["litclaude-ai", "litclaude"]);
939
+ const START_WORK_SELF_MOVES = new Set(["progress", "complete", "cancel"]);
940
+
941
+ // Read-only git: status, diff, log and show over plain words. A flag that writes a file or runs an
942
+ // external program is refused. An operand after `--`, one holding a `/` or one naming an existing
943
+ // file is a path, checked as a read under the grants; other operands (HEAD~1, a commit, main..topic)
944
+ // are data. Anything else, including a quote, an operator or `node -e`, meets the strict checks.
945
+ const GIT_READ_SUBCOMMANDS = new Set(["status", "diff", "log", "show"]);
946
+ const GIT_WORD = /^[\w.\/@^:=+,-][\w.\/@^~:=+,-]*$/u;
947
+ const classifyReadOnlyGit = (command, cwd) => {
948
+ const words = command.split(/[ \t]+/u);
949
+ if (words[0] !== "git" || !GIT_READ_SUBCOMMANDS.has(words[1])) return null;
950
+ const paths = [];
951
+ let operands = false;
952
+ for (const word of words.slice(2)) {
953
+ if (!GIT_WORD.test(word)) return pathArgumentDenied;
954
+ if (!operands && word === "--") {
955
+ operands = true;
956
+ continue;
957
+ }
958
+ if (!operands && word.startsWith("-")) {
959
+ if (/^--(?:output|ext-diff|textconv)(?:=|$)/u.test(word) || word.includes("/")) return pathArgumentDenied;
960
+ continue;
961
+ }
962
+ if (operands || word.includes("/") || existsSync(resolve(cwd, word))) paths.push(word);
963
+ }
964
+ return canonicalBashTargets(cwd, [cwd, ...paths], "read");
965
+ };
939
966
 
940
967
  const classifyBashWords = (words, cwd) => {
941
968
  const [head, ...args] = words;
@@ -958,6 +985,19 @@ const classifyBashWords = (words, cwd) => {
958
985
  if (rest[0] === "workflow-stage" && rest.every((word) => !word.includes("/"))) {
959
986
  return canonicalBashTargets(cwd, [cwd, ...cli], "write");
960
987
  }
988
+ // The lifecycle's own moves for the active work write its state under the project's .litclaude
989
+ // directory. `init` declares grants and `--root` points at another lifecycle, so neither passes.
990
+ if (rest[0] === "start-work" && START_WORK_SELF_MOVES.has(rest[1]) && !rest.includes("--root") && rest.every((word) => !word.includes("/"))) {
991
+ return canonicalBashTargets(cwd, [cwd, ...cli], "write");
992
+ }
993
+ // `pause` is how the work asks the user for a new boundary: it records the pending boundary
994
+ // and grants nothing, so the root it names may lie outside the grants, and the lifecycle
995
+ // refuses a forbidden action itself. Only that `--target-root` value may hold a `/`, and only
996
+ // the bare CLI name passes: a `node <path>/litclaude-ai.js` file is checked by name, not content.
997
+ if (cli.length === 0 && rest[0] === "start-work" && rest[1] === "pause" && !rest.includes("--root")
998
+ && rest.every((word, index) => !word.includes("/") || rest[index - 1] === "--target-root")) {
999
+ return canonicalBashTargets(cwd, [cwd, ...cli], "write");
1000
+ }
961
1001
  }
962
1002
  if (head.text === "node") return classifyNodeScriptWords(args, cwd);
963
1003
  return null;
@@ -970,6 +1010,8 @@ const classifyBash = (command, cwd) => {
970
1010
  if (forbiddenShellPattern.test(command)) return { denied: "forbidden authority action in Bash command" };
971
1011
  const trimmed = command.trim();
972
1012
  if (shellRelocationPattern.test(trimmed)) return { denied: "Bash relocation flags are not authorized" };
1013
+ const git = classifyReadOnlyGit(trimmed, cwd);
1014
+ if (git) return git;
973
1015
  const words = shellWords(trimmed);
974
1016
  const worded = words ? classifyBashWords(words, cwd) : null;
975
1017
  if (worded) return worded;
@@ -5,14 +5,14 @@
5
5
  // see, with no help from the model. The prompt and PreToolUse hooks start it when one of
6
6
  // STAGE_RECORD_SKILLS starts and clear it for any other discipline; PreToolUse marks a review
7
7
  // lane running when its reviewer is spawned; PostToolUse moves it when the plan file is saved, a
8
- // task is checked in the active plan, a lit-loop test is written, made to pass, run or recorded,
9
- // or a foreground reviewer reports; SubagentStop takes a background reviewer's verdict.
8
+ // task is checked in the active plan, a lit-loop test is written (with Write or Edit or through
9
+ // the shell), made to pass, run or recorded, or a foreground reviewer reports; SubagentStop takes a background reviewer's verdict.
10
10
  // `litclaude-ai workflow-stage` stays as the model's optional refinement. The card
11
11
  // (lib/mod/state.mjs) reads the record back. One small file, written whole, its fields checked
12
12
  // against the same tables the card draws from (lib/mod/workflow.mjs). The hooks keep only which
13
13
  // stage, which lane and how many; never a path, a command or a prompt.
14
14
 
15
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
+ import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
16
16
  import { join, relative, resolve } from "node:path";
17
17
 
18
18
  import { DISCIPLINE_ENTRY, STAGE_UNITS, VERDICT_STATES, WORKFLOW_FAMILIES, WORKFLOW_FRESH_MS, normalizeStage, reviewLaneOf } from "./mod/workflow.mjs";
@@ -174,10 +174,12 @@ const TEST_RUN = /(?:^|[\s;&|(])(?:npm\s+(?:run\s+)?test|pnpm\s+(?:run\s+)?test|
174
174
  const PLAN_SCAFFOLD = /scaffold-plan\.mjs/u;
175
175
  const LITGOAL_EVIDENCE = /\blitgoal\s+record-evidence\b/u;
176
176
 
177
- // lit-loop's steps as the hooks see them: a test written, the code that makes it pass, the test
178
- // run, the evidence recorded. Each move happens only from the step before it.
177
+ // lit-loop's steps as the hooks see them: a test written (or a failing run while the criterion is
178
+ // pinned), the code that makes it pass, the test run, the evidence recorded. Each move happens only
179
+ // from the step before it.
179
180
  const LOOP_MOVES = Object.freeze([
180
181
  Object.freeze({ on: "test-edit", from: Object.freeze(["PIN", "RECORD"]), to: "RED" }),
182
+ Object.freeze({ on: "test-fail", from: Object.freeze(["PIN"]), to: "RED" }),
181
183
  Object.freeze({ on: "source-edit", from: Object.freeze(["RED"]), to: "GREEN" }),
182
184
  Object.freeze({ on: "test-run", from: Object.freeze(["GREEN"]), to: "VERIFY" }),
183
185
  Object.freeze({ on: "evidence", from: Object.freeze(WORKFLOW_FAMILIES.loop), to: "RECORD" }),
@@ -189,6 +191,94 @@ const moveLoop = (cwd, sessionId, previous, signal) => {
189
191
  return move ? moveStage(cwd, sessionId, previous, { family: "loop", stage: move.to }) : null;
190
192
  };
191
193
 
194
+ // A test run's report of at least one failure: TAP (`not ok 2`, `# fail 1`) or a runner's summary
195
+ // (`1 failed`, `2 failing`).
196
+ const TEST_FAILED = /^\s*not ok \d|^# fail [1-9]|\b[1-9]\d* (?:failed|failing)\b/mu;
197
+ const shellOutput = (response) => (typeof response === "string" ? response
198
+ : [response?.stdout, response?.stderr, resultText(response)].filter((text) => typeof text === "string").join("\n")).slice(0, 65_536);
199
+
200
+ // A heredoc's body is data, so the command is read without it; the line that opens it stays.
201
+ const withoutHeredocBodies = (command) => {
202
+ const opener = /<<-?\s*(["']?)([A-Za-z_][\w-]*)\1/u;
203
+ let rest = command;
204
+ let kept = "";
205
+ for (let match = opener.exec(rest); match; match = opener.exec(rest)) {
206
+ const lineEnd = rest.indexOf("\n", match.index);
207
+ if (lineEnd < 0) break;
208
+ kept += rest.slice(0, lineEnd + 1);
209
+ const body = rest.slice(lineEnd + 1);
210
+ const close = new RegExp(`^[\\t ]*${match[2]}[\\t ]*$`, "mu").exec(body);
211
+ rest = close ? body.slice(close.index + close[0].length) : "";
212
+ }
213
+ return kept + rest;
214
+ };
215
+
216
+ /**
217
+ * The files a shell command may write: `>` and `>>` targets, `tee` arguments and the files of an
218
+ * in-place `sed`. Words are read with their quotes and heredoc bodies are skipped; the caller keeps
219
+ * only targets that exist as files in the project, so a stray word never moves the map.
220
+ */
221
+ export const shellWriteTargets = (command) => {
222
+ const text = withoutHeredocBodies(typeof command === "string" ? command.slice(0, 65_536) : "");
223
+ const segments = [[]];
224
+ const targets = [];
225
+ let word = null;
226
+ let quote = null;
227
+ let next = "word";
228
+ const push = () => {
229
+ if (word === null) return;
230
+ if (next === "target") targets.push(word);
231
+ else if (next === "word") segments.at(-1).push(word);
232
+ next = "word";
233
+ word = null;
234
+ };
235
+ for (let index = 0; index < text.length; index += 1) {
236
+ const char = text[index];
237
+ if (quote) {
238
+ if (char === quote) quote = null;
239
+ else word += char;
240
+ } else if (char === "'" || char === '"') {
241
+ quote = char;
242
+ word ??= "";
243
+ } else if (char === ">") {
244
+ if (/^(?:\d|&)?$/u.test(word ?? "")) word = null;
245
+ push();
246
+ if (text[index + 1] === ">") index += 1;
247
+ // `>&2` duplicates a descriptor; it names no file.
248
+ next = text[index + 1] === "&" ? "skip" : "target";
249
+ if (next === "skip") index += 1;
250
+ } else if (char === "<") {
251
+ push();
252
+ next = "skip";
253
+ } else if (/\s/u.test(char) || ";|&()".includes(char)) {
254
+ push();
255
+ if (!/[ \t]/u.test(char)) segments.push([]);
256
+ } else {
257
+ word = (word ?? "") + char;
258
+ }
259
+ }
260
+ push();
261
+ for (const words of segments) {
262
+ const args = words.filter((item) => !/^\w+=/u.test(item));
263
+ const [name, ...rest] = args;
264
+ const files = rest.filter((item) => item && !item.startsWith("-"));
265
+ if (name === "tee") targets.push(...files);
266
+ if (name === "sed" && rest.some((item) => /^-[A-Za-z]*i|^--in-place/u.test(item))) targets.push(...files);
267
+ }
268
+ return targets.filter((target) => target && target !== "/dev/null");
269
+ };
270
+
271
+ // A shell write counts as code only for a source file; a log or report the command writes is not the fix.
272
+ const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|lua|sh|zig|ex|exs|dart|scala)$/u;
273
+
274
+ const isFile = (path) => {
275
+ try {
276
+ return statSync(path).isFile();
277
+ } catch {
278
+ return false;
279
+ }
280
+ };
281
+
192
282
  const realOrResolved = (path) => {
193
283
  try {
194
284
  return realpathSync(path);
@@ -234,10 +324,13 @@ export const observeWorkflowPreTool = (cwd, input) => {
234
324
  /**
235
325
  * PostToolUse: the plan saved, a task checked in the active plan, a lit-loop step, a foreground
236
326
  * reviewer's verdict. `filePaths` are the paths the tool wrote (lib/mutated-file-paths.mjs).
327
+ * With `failed` (PostToolUseFailure) only a Bash command's lit-loop moves count: its writes, and its
328
+ * test run as a failing one, which never verifies.
237
329
  */
238
- export const observeWorkflowPostTool = (cwd, input, { filePaths = [], lifecycleRoot = cwd } = {}) => {
330
+ export const observeWorkflowPostTool = (cwd, input, { filePaths = [], lifecycleRoot = cwd, failed = false } = {}) => {
239
331
  const sessionId = typeof input?.session_id === "string" ? input.session_id : null;
240
332
  if (!sessionId) return null;
333
+ if (failed && (input.tool_name !== "Bash" || input.is_interrupt === true)) return null;
241
334
  const previous = currentRecord(cwd, sessionId);
242
335
  const tool = input.tool_name;
243
336
  const planOpen = previous?.family === "main" && previous.stage === "plan" && previous.done !== true;
@@ -247,12 +340,33 @@ export const observeWorkflowPostTool = (cwd, input, { filePaths = [], lifecycleR
247
340
  }
248
341
  if (tool === "Bash") {
249
342
  const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
250
- if (planOpen && PLAN_SCAFFOLD.test(command) && !/--check\b|--draft-only\b/u.test(command)) {
343
+ if (!failed && planOpen && PLAN_SCAFFOLD.test(command) && !/--check\b|--draft-only\b/u.test(command)) {
251
344
  return moveStage(cwd, sessionId, previous, { family: "main", stage: "plan", done: true });
252
345
  }
253
- if (LITGOAL_EVIDENCE.test(command)) return moveLoop(cwd, sessionId, previous, "evidence");
254
- if (TEST_RUN.test(command)) return moveLoop(cwd, sessionId, previous, "test-run");
255
- return null;
346
+ if (!failed && LITGOAL_EVIDENCE.test(command)) return moveLoop(cwd, sessionId, previous, "evidence");
347
+ // Files the command wrote move the loop as a Write or Edit would; then its test run, where a
348
+ // failing run while the criterion is pinned counts as the test that fails first.
349
+ let current = previous;
350
+ let moved = null;
351
+ const step = (signal) => {
352
+ const record = moveLoop(cwd, sessionId, current, signal);
353
+ if (record) current = moved = record;
354
+ };
355
+ if (previous?.family === "loop") {
356
+ const root = realOrResolved(resolveProjectStateRoot(cwd));
357
+ for (const target of shellWriteTargets(command).slice(0, 20)) {
358
+ const absolute = realOrResolved(resolve(cwd, target));
359
+ const inProject = relative(root, absolute).split("\\").join("/");
360
+ if (!isFile(absolute) || inProject.startsWith("..") || /^(?:\.litclaude|plans)\//u.test(inProject)) continue;
361
+ if (TEST_PATH.test(inProject)) step("test-edit");
362
+ else if (SOURCE_FILE.test(inProject)) step("source-edit");
363
+ }
364
+ }
365
+ if (TEST_RUN.test(command)) {
366
+ if (failed) step("test-fail");
367
+ else step(current?.stage === "PIN" && TEST_FAILED.test(shellOutput(input.tool_response)) ? "test-fail" : "test-run");
368
+ }
369
+ return moved;
256
370
  }
257
371
  if (!filePaths.length) return null;
258
372
  const root = resolveProjectStateRoot(cwd);