@litfamily/litclaude 1.0.26 → 1.0.27
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 +7 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/bin/install-ownership.mjs +19 -1
- package/bin/litclaude-ai.js +2 -2
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +2 -1
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-hook.js +12 -0
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/hooks.json +13 -0
- package/plugins/litclaude/lib/start-work-lifecycle.mjs +34 -0
- package/plugins/litclaude/lib/workflow-stage.mjs +124 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.27 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- `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.
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
3
10
|
## 1.0.26 - 2026-10-13
|
|
4
11
|
|
|
5
12
|
- 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.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/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.
|
|
8
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
7
|
+
<img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/assets/readme/badge-version.svg" alt="1.0.27" />
|
|
8
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/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.
|
|
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.27/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.
|
|
77
|
-
- [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
78
|
-
- [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
79
|
-
- [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
76
|
+
- [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/privacy.md)
|
|
77
|
+
- [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/migration.md)
|
|
78
|
+
- [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/CHANGELOG.md)
|
|
79
|
+
- [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/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.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/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.
|
|
8
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
7
|
+
<img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/assets/readme/badge-version.svg" alt="1.0.27" />
|
|
8
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/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.
|
|
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.27/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.
|
|
75
|
-
- [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
76
|
-
- [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
77
|
-
- [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
74
|
+
- [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/privacy.md)
|
|
75
|
+
- [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/docs/migration.md)
|
|
76
|
+
- [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/CHANGELOG.md)
|
|
77
|
+
- [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.27/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
|
|
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
|
}
|
package/bin/litclaude-ai.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.27"><title>release: 1.0.27</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.27</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.
|
|
3
|
+
"version": "1.0.27",
|
|
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": {
|
|
@@ -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();
|
|
@@ -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": [
|
|
@@ -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,11 @@ 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
|
+
}
|
|
961
993
|
}
|
|
962
994
|
if (head.text === "node") return classifyNodeScriptWords(args, cwd);
|
|
963
995
|
return null;
|
|
@@ -970,6 +1002,8 @@ const classifyBash = (command, cwd) => {
|
|
|
970
1002
|
if (forbiddenShellPattern.test(command)) return { denied: "forbidden authority action in Bash command" };
|
|
971
1003
|
const trimmed = command.trim();
|
|
972
1004
|
if (shellRelocationPattern.test(trimmed)) return { denied: "Bash relocation flags are not authorized" };
|
|
1005
|
+
const git = classifyReadOnlyGit(trimmed, cwd);
|
|
1006
|
+
if (git) return git;
|
|
973
1007
|
const words = shellWords(trimmed);
|
|
974
1008
|
const worded = words ? classifyBashWords(words, cwd) : null;
|
|
975
1009
|
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
|
|
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
|
|
178
|
-
// run, the evidence recorded. Each move happens only
|
|
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
|
-
|
|
255
|
-
|
|
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);
|