@leonardo-marziali/ad-lfl-kit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -21
- package/changes.js +216 -0
- package/index.js +1 -0
- package/loop.js +41 -13
- package/package.json +2 -1
- package/state.js +30 -0
- package/track.js +150 -18
package/README.md
CHANGED
|
@@ -15,13 +15,18 @@ plugin can build the same loop without re-implementing it.
|
|
|
15
15
|
|
|
16
16
|
## The pattern
|
|
17
17
|
|
|
18
|
-
1. A
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
1. A tracking hook records every file the current session touches that
|
|
19
|
+
matches some predicate — only `.md` files, say — into a session-scoped
|
|
20
|
+
list. Edit-family tools name their file; for `Bash`, `PowerShell` and MCP
|
|
21
|
+
tools, which don't, the hook also runs on `PreToolUse` to snapshot the
|
|
22
|
+
workspace and tracks whatever changed by the time the call ends. Tool
|
|
23
|
+
calls made inside a subagent are tracked the same way, and additionally
|
|
24
|
+
into that subagent's own list.
|
|
25
|
+
2. A `Stop`/`SubagentStop` hook reads that list — a `SubagentStop` reads only
|
|
26
|
+
its subagent's — runs a check (optionally preceded by an auto-fix pass),
|
|
27
|
+
and:
|
|
23
28
|
- if it passes, cleans up the session state and lets the session end;
|
|
24
|
-
- if it fails, blocks the stop with `decision: "
|
|
29
|
+
- if it fails, blocks the stop with `decision: "block"` so Claude sees
|
|
25
30
|
the failures and keeps working, up to a capped number of attempts;
|
|
26
31
|
- at the cap, gives up — reporting what's left, optionally applying a
|
|
27
32
|
caller-supplied fallback — rather than blocking forever.
|
|
@@ -40,9 +45,20 @@ npm install @leonardo-marziali/ad-lfl-kit
|
|
|
40
45
|
## Quick start
|
|
41
46
|
|
|
42
47
|
Two hook scripts, wired up in the plugin's `hooks/hooks.json` the same way
|
|
43
|
-
as [`plugins/markdown/hooks/hooks.json`](https://github.com/leonardo-marziali/agentic-development-kit/blob/main/plugins/markdown/hooks/hooks.json)
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
as [`plugins/markdown/hooks/hooks.json`](https://github.com/leonardo-marziali/agentic-development-kit/blob/main/plugins/markdown/hooks/hooks.json),
|
|
49
|
+
all pointed at `${CLAUDE_PLUGIN_ROOT}`:
|
|
50
|
+
|
|
51
|
+
- `PreToolUse` and `PostToolUseFailure`, matching `Bash|PowerShell|mcp__.*`
|
|
52
|
+
→ `track-touched.js`
|
|
53
|
+
- `PostToolUse`, matching
|
|
54
|
+
`Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|mcp__.*` →
|
|
55
|
+
`track-touched.js`
|
|
56
|
+
- `Stop` and `SubagentStop` → `check-and-loop.js`
|
|
57
|
+
|
|
58
|
+
`PostToolUseFailure` matters: a command that edits files and then exits
|
|
59
|
+
non-zero (`./fix.py && pytest`) reports through it, not `PostToolUse`.
|
|
60
|
+
Subagents need nothing extra — Claude Code fires plugin hooks for their tool
|
|
61
|
+
calls too, with the parent's `session_id` and their own `agent_id`.
|
|
46
62
|
|
|
47
63
|
**`scripts/hooks/track-touched.js`**
|
|
48
64
|
|
|
@@ -51,7 +67,7 @@ pointed at `${CLAUDE_PLUGIN_ROOT}`.
|
|
|
51
67
|
'use strict';
|
|
52
68
|
|
|
53
69
|
const path = require('node:path');
|
|
54
|
-
const { readHookInput, stateDir,
|
|
70
|
+
const { readHookInput, stateDir, trackSessionChanges } = require('@leonardo-marziali/ad-lfl-kit');
|
|
55
71
|
|
|
56
72
|
const input = readHookInput();
|
|
57
73
|
if (input) {
|
|
@@ -59,7 +75,7 @@ if (input) {
|
|
|
59
75
|
sessionId: input.session_id,
|
|
60
76
|
namespace: 'my-plugin-session-scope',
|
|
61
77
|
});
|
|
62
|
-
|
|
78
|
+
trackSessionChanges({
|
|
63
79
|
input,
|
|
64
80
|
listFile: path.join(dir, 'touched-files.txt'),
|
|
65
81
|
matches: (absPath) => /\.md$/i.test(absPath),
|
|
@@ -77,15 +93,20 @@ const path = require('node:path');
|
|
|
77
93
|
const {
|
|
78
94
|
readHookInput,
|
|
79
95
|
stateDir,
|
|
96
|
+
loopStateDir,
|
|
80
97
|
readTrackedFiles,
|
|
81
98
|
runFixLoop,
|
|
82
99
|
runBin,
|
|
83
100
|
} = require('@leonardo-marziali/ad-lfl-kit');
|
|
84
101
|
|
|
85
102
|
const input = readHookInput();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
// A SubagentStop gets its subagent's own directory; a Stop the session's.
|
|
104
|
+
const dir = loopStateDir({
|
|
105
|
+
dir: stateDir({
|
|
106
|
+
sessionId: input?.session_id,
|
|
107
|
+
namespace: 'my-plugin-session-scope',
|
|
108
|
+
}),
|
|
109
|
+
input,
|
|
89
110
|
});
|
|
90
111
|
const stateFiles = ['touched-files.txt', 'attempts.json'];
|
|
91
112
|
const files = readTrackedFiles(path.join(dir, 'touched-files.txt'));
|
|
@@ -182,6 +203,14 @@ excluded rather than drop it silently.
|
|
|
182
203
|
- **`stateDir({ sessionId, namespace })`** — a directory scoped to one
|
|
183
204
|
session and one namespace, under `${CLAUDE_PLUGIN_DATA}` when set (so it
|
|
184
205
|
survives plugin updates) or the OS temp dir otherwise.
|
|
206
|
+
- **`agentStateDir(dir, agentId)`** — one subagent's share of a session
|
|
207
|
+
directory, `<dir>/agents/<agentId>`. The tracking functions append a
|
|
208
|
+
subagent's files here as well as to the session's list.
|
|
209
|
+
- **`loopStateDir({ dir, input })`** — the directory a `Stop`/`SubagentStop`
|
|
210
|
+
hook should run its loop in: `agentStateDir(dir, input.agent_id)` for a
|
|
211
|
+
`SubagentStop`, `dir` for anything else. A subagent is then held only to
|
|
212
|
+
the files it wrote, with its own attempt count, while the main `Stop`
|
|
213
|
+
still checks everything, subagents' files included.
|
|
185
214
|
- **`readTrackedFiles(listFile)`** — the de-duplicated contents of a tracked
|
|
186
215
|
file list, filtered to files that still exist on disk.
|
|
187
216
|
- **`appendTrackedFile(listFile, absPath)`** — appends a path, skipping it
|
|
@@ -196,11 +225,49 @@ excluded rather than drop it silently.
|
|
|
196
225
|
|
|
197
226
|
### Tracking (`track.js`)
|
|
198
227
|
|
|
199
|
-
- **`
|
|
200
|
-
half
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
- **`trackSessionChanges({ input, listFile, matches })`** — the tracking
|
|
229
|
+
half; wire it to `PreToolUse`, `PostToolUse` and `PostToolUseFailure`.
|
|
230
|
+
Tracks every file `toolChangedFiles` reports that `matches(absPath)`
|
|
231
|
+
accepts (omit `matches` to track everything), in `listFile` and — for a
|
|
232
|
+
subagent's call — in that subagent's list. Snapshots are kept beside
|
|
233
|
+
`listFile`. Returns the tracked paths.
|
|
234
|
+
- **`toolChangedFiles({ input, stateDir })`** — the files one tool call
|
|
235
|
+
wrote, for hooks that act on them without tracking (a formatter). For
|
|
236
|
+
`Edit`, `Write`, `MultiEdit` and `NotebookEdit` that's the file the input
|
|
237
|
+
names, if it exists. For `Bash`, `PowerShell` and `mcp__*` tools,
|
|
238
|
+
`PreToolUse` writes a snapshot to `<stateDir>/pending/<tool_use_id>.json`
|
|
239
|
+
and the `Post*` event returns what changed since and consumes it, so only
|
|
240
|
+
one script per state dir should call this. Read-only tools return `[]`
|
|
241
|
+
without scanning anything.
|
|
242
|
+
- **`trackTouchedFile({ input, listFile, matches })`** — the Edit-family
|
|
243
|
+
subset of `trackSessionChanges`: no-ops unless `input.tool_name` is
|
|
244
|
+
`Edit`, `Write`, `MultiEdit` or `NotebookEdit` and the file it names still
|
|
245
|
+
exists. Returns the tracked absolute path, or `null`.
|
|
246
|
+
|
|
247
|
+
### Change detection (`changes.js`)
|
|
248
|
+
|
|
249
|
+
How `toolChangedFiles` answers for tools that don't name their files.
|
|
250
|
+
|
|
251
|
+
- **`snapshotWorkspace(cwd)`** — `{ startedAt, cwd, root, head }`: the time,
|
|
252
|
+
the cwd, and the git root and `HEAD` it sits in (both `null` outside a
|
|
253
|
+
repo).
|
|
254
|
+
- **`filesChangedSince(snapshot, { cwd })`** — absolute paths written since
|
|
255
|
+
the snapshot. In a repo: the dirty and untracked files from `git status`,
|
|
256
|
+
plus the files of commits made during the call (so `edit && git commit`
|
|
257
|
+
isn't missed), keeping only those whose mtime falls after the snapshot.
|
|
258
|
+
Already-dirty files the call didn't touch are older than that, and a
|
|
259
|
+
`checkout` or `pull` leaves the tree clean and brings commits dated
|
|
260
|
+
before the call, so neither drags in unrelated files. Outside a repo: a
|
|
261
|
+
bounded walk of the cwd. Gitignored files and dependency directories
|
|
262
|
+
(`node_modules`, `.venv`, `__pycache__`…) are never reported. Unlike
|
|
263
|
+
`scope.js` this fails closed — an error means "nothing changed" — since
|
|
264
|
+
it decides which files a session becomes answerable for.
|
|
265
|
+
- **`MTIME_SLACK_MS`** / **`PRUNED_DIR_NAMES`** — the mtime tolerance for
|
|
266
|
+
coarse filesystems, and the directories skipped.
|
|
267
|
+
|
|
268
|
+
What it can't see: writes made after the call returns, such as by a
|
|
269
|
+
`run_in_background` command, and writes outside both the snapshot's and
|
|
270
|
+
the current cwd's repository.
|
|
204
271
|
|
|
205
272
|
### Execution (`exec.js`)
|
|
206
273
|
|
|
@@ -255,14 +322,20 @@ fix, check, onCap, buildMessage, write })`** — the `Stop`/`SubagentStop`
|
|
|
255
322
|
given; `onCap({ files, outcome, maxAttempts })` is optional and runs
|
|
256
323
|
once instead of blocking when the attempt cap is reached;
|
|
257
324
|
`buildMessage({ attempt, maxAttempts, outcome, files })` returns
|
|
258
|
-
`{ stopReason?, systemMessage }` for the block payload
|
|
325
|
+
`{ stopReason?, systemMessage }` for the block payload, which is printed
|
|
326
|
+
as `{ "decision": "block", "reason": systemMessage, "systemMessage":
|
|
327
|
+
stopReason }`: `reason` is what Claude is told to fix (cut to
|
|
328
|
+
`MAX_REASON_CHARS`, since Claude Code would otherwise swap a longer one
|
|
329
|
+
for a short preview), `systemMessage` a notice shown only to the user. `maxAttempts`
|
|
259
330
|
defaults to `DEFAULT_MAX_ATTEMPTS` (3). Returns
|
|
260
331
|
`{ blocked, reason, ... }` so a caller or its tests can assert on which
|
|
261
332
|
path was taken (`'no-input'`, `'no-files'`, `'clean'`, `'cap'`,
|
|
262
333
|
`'blocked'`, or `'error'` if `fix`/`check` threw — in which case the
|
|
263
334
|
session's state is left untouched so the next `Stop` retries, rather than
|
|
264
335
|
either blocking on a broken toolchain or silently discarding progress).
|
|
265
|
-
- **`
|
|
336
|
+
- **`blockPayload({ stopReason, systemMessage })`** — that payload, for
|
|
337
|
+
hooks that block a stop outside `runFixLoop`.
|
|
338
|
+
- **`DEFAULT_MAX_ATTEMPTS`** — `3`. **`MAX_REASON_CHARS`** — `9500`.
|
|
266
339
|
|
|
267
340
|
## Design notes
|
|
268
341
|
|
|
@@ -275,6 +348,10 @@ fix, check, onCap, buildMessage, write })`** — the `Stop`/`SubagentStop`
|
|
|
275
348
|
the file names it's given; two hooks sharing a session's state directory
|
|
276
349
|
(a tracking hook and, say, this repo's own test-verification hook) can't
|
|
277
350
|
clobber each other.
|
|
351
|
+
- **Block with top-level `decision: "block"`.** It is the only shape Claude
|
|
352
|
+
Code honours for `Stop`/`SubagentStop`. Versions of this kit before it
|
|
353
|
+
printed `hookSpecificOutput.decision: "continue"`, which Claude Code
|
|
354
|
+
ignores, so the stop went through and Claude never saw the report.
|
|
278
355
|
- **A failing `check`/`fix` is not the same as a failing check.** If either
|
|
279
356
|
throws, `runFixLoop` neither blocks the session nor discards its state —
|
|
280
357
|
a broken toolchain shouldn't trap a session in a loop, and it shouldn't
|
package/changes.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Finds the files a tool call wrote when the tool doesn't say which ones.
|
|
5
|
+
*
|
|
6
|
+
* Edit and Write name their target in `tool_input.file_path`, but a Bash or
|
|
7
|
+
* PowerShell command (`sed -i`, a Python script, a code generator) or an MCP
|
|
8
|
+
* tool can write anywhere and reports nothing. So the PreToolUse hook takes a
|
|
9
|
+
* cheap snapshot — a timestamp, the cwd and each repo's HEAD — and the
|
|
10
|
+
* PostToolUse hook asks what changed since:
|
|
11
|
+
*
|
|
12
|
+
* - in a git repo, the dirty and untracked files (`git status`), plus the
|
|
13
|
+
* files of any commit made during the call, so `edit && git commit` in one
|
|
14
|
+
* command isn't missed;
|
|
15
|
+
* - outside a repo, a bounded walk of the cwd;
|
|
16
|
+
*
|
|
17
|
+
* and in both cases keeps only files whose mtime falls inside the call's
|
|
18
|
+
* window. Pre-existing dirty files the call didn't touch are older than the
|
|
19
|
+
* snapshot, and a `git checkout` / `git pull` leaves the tree clean and brings
|
|
20
|
+
* in commits made before the call, so neither drags unrelated files in.
|
|
21
|
+
*
|
|
22
|
+
* Everything here fails CLOSED, unlike scope.js: a snapshot or a git call
|
|
23
|
+
* that fails resolves to "nothing changed". This module decides which files
|
|
24
|
+
* a session becomes answerable for, and guessing wrong in the other
|
|
25
|
+
* direction would hand Claude somebody else's code.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const { runBin } = require('./exec');
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Some filesystems store mtimes at a coarse resolution (FAT and SMB at 2s,
|
|
34
|
+
* some network mounts at 1s), which can round a write made just after the
|
|
35
|
+
* snapshot to just before it.
|
|
36
|
+
*/
|
|
37
|
+
const MTIME_SLACK_MS = 2000;
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* Directories that are never a session's own source even when a command
|
|
41
|
+
* writes into them — and that a fresh project often hasn't gitignored yet,
|
|
42
|
+
* so `git status` would list every file in them after a `uv sync` or a
|
|
43
|
+
* `pnpm install`.
|
|
44
|
+
*/
|
|
45
|
+
const PRUNED_DIR_NAMES = new Set([
|
|
46
|
+
'.git',
|
|
47
|
+
'node_modules',
|
|
48
|
+
'.venv',
|
|
49
|
+
'venv',
|
|
50
|
+
'__pycache__',
|
|
51
|
+
'.mypy_cache',
|
|
52
|
+
'.pytest_cache',
|
|
53
|
+
'.ruff_cache',
|
|
54
|
+
'.tox',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// Upper bound on entries visited by the no-git fallback walk.
|
|
58
|
+
const MAX_WALK_ENTRIES = 20000;
|
|
59
|
+
|
|
60
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
function git(args, cwd) {
|
|
63
|
+
try {
|
|
64
|
+
const result = runBin('git', args, { cwd, encoding: 'utf8', maxBuffer: MAX_BUFFER });
|
|
65
|
+
return !result.error && result.status === 0 ? result.stdout : null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function repoRoot(dir) {
|
|
72
|
+
const out = git(['rev-parse', '--show-toplevel'], dir);
|
|
73
|
+
return out ? out.trim() || null : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function headOf(root) {
|
|
77
|
+
const out = git(['rev-parse', '--verify', '--quiet', 'HEAD'], root);
|
|
78
|
+
return out ? out.trim() || null : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isPruned(absPath) {
|
|
82
|
+
return path
|
|
83
|
+
.dirname(absPath)
|
|
84
|
+
.split(path.sep)
|
|
85
|
+
.some((segment) => PRUNED_DIR_NAMES.has(segment));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isDirectory(dir) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.statSync(dir).isDirectory();
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* Taken by the PreToolUse hook. JSON-serialisable on purpose: it is written
|
|
98
|
+
* to the session's state dir and read back by a different process.
|
|
99
|
+
*/
|
|
100
|
+
function snapshotWorkspace(cwd, { now = Date.now() } = {}) {
|
|
101
|
+
const dir = path.resolve(cwd || process.cwd());
|
|
102
|
+
const root = isDirectory(dir) ? repoRoot(dir) : null;
|
|
103
|
+
return { startedAt: now, cwd: dir, root, head: root ? headOf(root) : null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
* `git status -z` entries are `XY <path>`; a rename or copy is followed by
|
|
108
|
+
* one more NUL-terminated entry holding the original path, which is skipped.
|
|
109
|
+
*/
|
|
110
|
+
function parseStatus(out, root) {
|
|
111
|
+
const paths = [];
|
|
112
|
+
const entries = out.split('\0');
|
|
113
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
114
|
+
const entry = entries[i];
|
|
115
|
+
if (entry.length < 4) continue;
|
|
116
|
+
if (/[RC]/.test(entry.slice(0, 2))) i += 1;
|
|
117
|
+
paths.push(path.resolve(root, entry.slice(3)));
|
|
118
|
+
}
|
|
119
|
+
return paths;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function dirtyFiles(root) {
|
|
123
|
+
const out = git(['status', '--porcelain=v1', '-z', '--untracked-files=all'], root);
|
|
124
|
+
return out === null ? [] : parseStatus(out, root);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* Files touched by commits the call itself made. `--since` drops commits
|
|
129
|
+
* that merely arrived during the call (a pull, a checkout of another
|
|
130
|
+
* branch), which carry their original, older committer dates.
|
|
131
|
+
*/
|
|
132
|
+
function committedFiles(root, fromHead, startedAt) {
|
|
133
|
+
const head = headOf(root);
|
|
134
|
+
if (!head || head === fromHead) return [];
|
|
135
|
+
|
|
136
|
+
const range = fromHead ? [`${fromHead}..${head}`] : [head];
|
|
137
|
+
const since = new Date(startedAt - MTIME_SLACK_MS).toISOString();
|
|
138
|
+
const out = git(['log', '--format=', '--name-only', '-z', `--since=${since}`, ...range], root);
|
|
139
|
+
if (out === null) return [];
|
|
140
|
+
return out
|
|
141
|
+
.split(/[\0\n]/)
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.map((rel) => path.resolve(root, rel));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function walkFiles(dir) {
|
|
147
|
+
const files = [];
|
|
148
|
+
const stack = [dir];
|
|
149
|
+
let visited = 0;
|
|
150
|
+
|
|
151
|
+
while (stack.length > 0 && visited < MAX_WALK_ENTRIES) {
|
|
152
|
+
const current = stack.pop();
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
visited += 1;
|
|
161
|
+
const full = path.join(current, entry.name);
|
|
162
|
+
if (entry.isDirectory()) {
|
|
163
|
+
if (!PRUNED_DIR_NAMES.has(entry.name)) stack.push(full);
|
|
164
|
+
} else if (entry.isFile()) {
|
|
165
|
+
files.push(full);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return files;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function modifiedSince(absPath, since) {
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.statSync(absPath);
|
|
175
|
+
return stat.isFile() && stat.mtimeMs >= since;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/*
|
|
182
|
+
* The PostToolUse half: absolute paths of the files written between the
|
|
183
|
+
* snapshot and now. `cwd` is where the session is now, which a `cd` inside
|
|
184
|
+
* the command may have moved away from the snapshot's.
|
|
185
|
+
*/
|
|
186
|
+
function filesChangedSince(snapshot, { cwd } = {}) {
|
|
187
|
+
if (!snapshot || typeof snapshot.startedAt !== 'number') return [];
|
|
188
|
+
const since = snapshot.startedAt - MTIME_SLACK_MS;
|
|
189
|
+
|
|
190
|
+
const candidates = [];
|
|
191
|
+
const roots = new Map();
|
|
192
|
+
if (snapshot.root) roots.set(snapshot.root, snapshot.head);
|
|
193
|
+
|
|
194
|
+
const nowDir = cwd ? path.resolve(cwd) : null;
|
|
195
|
+
const nowRoot =
|
|
196
|
+
nowDir && nowDir !== snapshot.cwd && isDirectory(nowDir) ? repoRoot(nowDir) : null;
|
|
197
|
+
// A repo entered mid-command has no recorded HEAD, so its commits are skipped.
|
|
198
|
+
if (nowRoot && !roots.has(nowRoot)) roots.set(nowRoot, undefined);
|
|
199
|
+
|
|
200
|
+
for (const [root, head] of roots) {
|
|
201
|
+
candidates.push(...dirtyFiles(root));
|
|
202
|
+
if (head !== undefined) candidates.push(...committedFiles(root, head, snapshot.startedAt));
|
|
203
|
+
}
|
|
204
|
+
if (!snapshot.root && snapshot.cwd && isDirectory(snapshot.cwd)) {
|
|
205
|
+
candidates.push(...walkFiles(snapshot.cwd));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return [...new Set(candidates)].filter((file) => !isPruned(file) && modifiedSince(file, since));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
MTIME_SLACK_MS,
|
|
213
|
+
PRUNED_DIR_NAMES,
|
|
214
|
+
snapshotWorkspace,
|
|
215
|
+
filesChangedSince,
|
|
216
|
+
};
|
package/index.js
CHANGED
package/loop.js
CHANGED
|
@@ -5,9 +5,44 @@ const { readAttempts, writeAttempts, cleanup } = require('./state');
|
|
|
5
5
|
|
|
6
6
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
7
7
|
|
|
8
|
+
/*
|
|
9
|
+
* Claude Code caps a hook's `reason` at 10,000 characters; past that it
|
|
10
|
+
* saves the text to a file and hands Claude only a 2,000-character preview,
|
|
11
|
+
* without asking it to read the rest. A long lint report is better cut here,
|
|
12
|
+
* with a note that it was, than silently swapped for a preview.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_REASON_CHARS = 9500;
|
|
15
|
+
|
|
16
|
+
function clampReason(text) {
|
|
17
|
+
if (text.length <= MAX_REASON_CHARS) return text;
|
|
18
|
+
const omitted = text.length - MAX_REASON_CHARS;
|
|
19
|
+
return (
|
|
20
|
+
`${text.slice(0, MAX_REASON_CHARS)}\n\n[… ${omitted} more characters omitted. ` +
|
|
21
|
+
'Fix the findings above, then stop again to see the rest.]'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* The only shape Claude Code honours for blocking a Stop or SubagentStop is
|
|
27
|
+
* a top-level `decision: "block"` with a `reason`, which becomes Claude's
|
|
28
|
+
* next instruction. Anything nested under hookSpecificOutput (other than
|
|
29
|
+
* additionalContext) is ignored and the stop simply goes through, which is
|
|
30
|
+
* how earlier versions of this kit, emitting `decision: "continue"` there,
|
|
31
|
+
* never actually blocked. `systemMessage` is shown to the user only.
|
|
32
|
+
*/
|
|
33
|
+
function blockPayload({ stopReason, systemMessage }) {
|
|
34
|
+
return {
|
|
35
|
+
decision: 'block',
|
|
36
|
+
reason: clampReason(
|
|
37
|
+
systemMessage || stopReason || 'Checks failed on files this session touched.',
|
|
38
|
+
),
|
|
39
|
+
...(stopReason ? { systemMessage: stopReason } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
8
43
|
/*
|
|
9
44
|
* The Stop/SubagentStop half of the loop: check the files this session
|
|
10
|
-
* touched, and if they don't pass, block the stop with decision:"
|
|
45
|
+
* touched, and if they don't pass, block the stop with decision:"block" so
|
|
11
46
|
* Claude is handed the failures and keeps working.
|
|
12
47
|
*
|
|
13
48
|
* The loop is capped so it can't run forever. Hitting the cap is not success —
|
|
@@ -24,7 +59,9 @@ const DEFAULT_MAX_ATTEMPTS = 3;
|
|
|
24
59
|
* fix — optional, mechanically fixes what it can before checking
|
|
25
60
|
* check — returns { failed, report }
|
|
26
61
|
* onCap — optional, called instead of blocking once attempts run out
|
|
27
|
-
* buildMessage— returns { stopReason?, systemMessage } for the block payload
|
|
62
|
+
* buildMessage— returns { stopReason?, systemMessage } for the block payload:
|
|
63
|
+
* systemMessage is what Claude is told to fix, stopReason an
|
|
64
|
+
* optional one-line notice shown to the user
|
|
28
65
|
*
|
|
29
66
|
* Returns { blocked, reason } describing which path was taken, so hooks and
|
|
30
67
|
* their tests can assert on the outcome rather than on stdout alone.
|
|
@@ -80,18 +117,9 @@ function runFixLoop({
|
|
|
80
117
|
|
|
81
118
|
const { stopReason, systemMessage } = buildMessage({ attempt, maxAttempts, outcome, files });
|
|
82
119
|
|
|
83
|
-
write(
|
|
84
|
-
JSON.stringify({
|
|
85
|
-
hookSpecificOutput: {
|
|
86
|
-
hookEventName: input.hook_event_name || 'Stop',
|
|
87
|
-
decision: 'continue',
|
|
88
|
-
...(stopReason ? { stopReason } : {}),
|
|
89
|
-
systemMessage,
|
|
90
|
-
},
|
|
91
|
-
}),
|
|
92
|
-
);
|
|
120
|
+
write(JSON.stringify(blockPayload({ stopReason, systemMessage })));
|
|
93
121
|
|
|
94
122
|
return { blocked: true, reason: 'blocked', attempt, outcome };
|
|
95
123
|
}
|
|
96
124
|
|
|
97
|
-
module.exports = { runFixLoop, DEFAULT_MAX_ATTEMPTS };
|
|
125
|
+
module.exports = { runFixLoop, blockPayload, DEFAULT_MAX_ATTEMPTS, MAX_REASON_CHARS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leonardo-marziali/ad-lfl-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Agentic development lint-fix-loop kit: session-scoped file tracking and a capped Stop-hook fix loop for Claude Code plugins.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"exec.js",
|
|
28
28
|
"hook-input.js",
|
|
29
29
|
"state.js",
|
|
30
|
+
"changes.js",
|
|
30
31
|
"track.js",
|
|
31
32
|
"scope.js",
|
|
32
33
|
"loop.js"
|
package/state.js
CHANGED
|
@@ -19,6 +19,32 @@ function stateDir({ sessionId, namespace }) {
|
|
|
19
19
|
return path.join(base, sessionId || 'unknown-session');
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/*
|
|
23
|
+
* Where one subagent's share of a session's state lives, inside the
|
|
24
|
+
* session's own directory. Subagent tool calls arrive with the parent's
|
|
25
|
+
* session_id plus their own agent_id, so a file a subagent writes is
|
|
26
|
+
* tracked twice: in the session's list, which the main Stop checks, and in
|
|
27
|
+
* this one, which that subagent's SubagentStop checks.
|
|
28
|
+
*/
|
|
29
|
+
function agentStateDir(dir, agentId) {
|
|
30
|
+
// agent_id is a hex token today; never let it name a path outside `dir`.
|
|
31
|
+
return path.join(dir, 'agents', String(agentId).replaceAll(/[^\w-]/g, '_'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* The directory a Stop/SubagentStop hook should run its loop in. A
|
|
36
|
+
* SubagentStop gets the subagent's own directory, so a subagent is only
|
|
37
|
+
* held to the files it wrote — not to whatever the main conversation or a
|
|
38
|
+
* sibling subagent left unfinished — and keeps an attempt count of its own.
|
|
39
|
+
* Every other event gets the session directory unchanged.
|
|
40
|
+
*/
|
|
41
|
+
function loopStateDir({ dir, input }) {
|
|
42
|
+
if (input?.hook_event_name === 'SubagentStop' && input.agent_id) {
|
|
43
|
+
return agentStateDir(dir, input.agent_id);
|
|
44
|
+
}
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
/*
|
|
23
49
|
* The tracked-file list is append-only newline-delimited text. Entries can go
|
|
24
50
|
* stale within a session — a file gets tracked, then deleted or renamed — so
|
|
@@ -69,6 +95,8 @@ function cleanup(dir, ownStateFiles) {
|
|
|
69
95
|
}
|
|
70
96
|
try {
|
|
71
97
|
fs.rmdirSync(dir);
|
|
98
|
+
// A subagent's dir (see agentStateDir) leaves an empty `agents` behind.
|
|
99
|
+
if (path.basename(path.dirname(dir)) === 'agents') fs.rmdirSync(path.dirname(dir));
|
|
72
100
|
} catch {
|
|
73
101
|
// not empty (another hook's state still pending) or already gone
|
|
74
102
|
}
|
|
@@ -76,6 +104,8 @@ function cleanup(dir, ownStateFiles) {
|
|
|
76
104
|
|
|
77
105
|
module.exports = {
|
|
78
106
|
stateDir,
|
|
107
|
+
agentStateDir,
|
|
108
|
+
loopStateDir,
|
|
79
109
|
readTrackedFiles,
|
|
80
110
|
appendTrackedFile,
|
|
81
111
|
readAttempts,
|
package/track.js
CHANGED
|
@@ -2,23 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
-
const { appendTrackedFile } = require('./state');
|
|
5
|
+
const { appendTrackedFile, agentStateDir } = require('./state');
|
|
6
|
+
const { snapshotWorkspace, filesChangedSince } = require('./changes');
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
|
-
* The PostToolUse half of the loop: records the
|
|
9
|
-
* Stop hook only ever inspects files this session actually touched —
|
|
10
|
-
* pre-existing content the user didn't ask us to police.
|
|
11
|
-
*
|
|
12
|
-
* `matches` decides what's in scope for the calling plugin and receives the
|
|
13
|
-
* resolved absolute path. Returns the path that was tracked, or null when the
|
|
14
|
-
* event wasn't one we care about — callers generally ignore the return and
|
|
15
|
-
* just let the hook exit.
|
|
9
|
+
* The PostToolUse half of the loop: records the files a tool just wrote, so
|
|
10
|
+
* the Stop hook only ever inspects files this session actually touched —
|
|
11
|
+
* never pre-existing content the user didn't ask us to police.
|
|
16
12
|
*/
|
|
17
|
-
function trackTouchedFile({ input, listFile, matches }) {
|
|
18
|
-
if (!input) return null;
|
|
19
|
-
if (!/^(Edit|Write)$/.test(input.tool_name || '')) return null;
|
|
20
13
|
|
|
21
|
-
|
|
14
|
+
// Tools that name the one file they write in their input.
|
|
15
|
+
const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Tools that can write files without saying which: shells and MCP servers.
|
|
19
|
+
* Read-only built-ins (Read, Grep, Glob, WebFetch…) are left out so their
|
|
20
|
+
* hooks don't pay for a workspace scan on every call. A subagent's own tool
|
|
21
|
+
* calls fire these hooks themselves, so Agent/Task needs no entry.
|
|
22
|
+
*/
|
|
23
|
+
const SHELL_TOOL = /^(Bash|PowerShell|mcp__.+)$/;
|
|
24
|
+
|
|
25
|
+
const PENDING_DIR = 'pending';
|
|
26
|
+
|
|
27
|
+
// A Pre marker whose Post never came (a denied permission, a crash).
|
|
28
|
+
const STALE_MARKER_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
function resolveToolPath(input) {
|
|
31
|
+
const filePath = input.tool_input?.file_path ?? input.tool_input?.notebook_path;
|
|
22
32
|
if (!filePath) return null;
|
|
23
33
|
|
|
24
34
|
/*
|
|
@@ -26,14 +36,136 @@ function trackTouchedFile({ input, listFile, matches }) {
|
|
|
26
36
|
* cwd, not this hook process's, which can differ.
|
|
27
37
|
*/
|
|
28
38
|
const cwd = input.cwd || process.cwd();
|
|
29
|
-
|
|
39
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* Appends to the session's list and, for a subagent's tool call, to that
|
|
44
|
+
* subagent's own list as well (see agentStateDir).
|
|
45
|
+
*/
|
|
46
|
+
function record(listFile, absPath, input) {
|
|
47
|
+
const lists = [listFile];
|
|
48
|
+
if (input.agent_id) {
|
|
49
|
+
lists.push(
|
|
50
|
+
path.join(agentStateDir(path.dirname(listFile), input.agent_id), path.basename(listFile)),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
for (const list of lists) {
|
|
54
|
+
fs.mkdirSync(path.dirname(list), { recursive: true });
|
|
55
|
+
appendTrackedFile(list, absPath);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
30
58
|
|
|
31
|
-
|
|
59
|
+
/*
|
|
60
|
+
* Tracks the file an Edit, Write, MultiEdit or NotebookEdit call wrote.
|
|
61
|
+
*
|
|
62
|
+
* `matches` decides what's in scope for the calling plugin and receives the
|
|
63
|
+
* resolved absolute path. Returns the path that was tracked, or null when the
|
|
64
|
+
* event wasn't one we care about — callers generally ignore the return and
|
|
65
|
+
* just let the hook exit.
|
|
66
|
+
*/
|
|
67
|
+
function trackTouchedFile({ input, listFile, matches }) {
|
|
68
|
+
if (!input) return null;
|
|
69
|
+
if (!FILE_TOOLS.has(input.tool_name || '')) return null;
|
|
70
|
+
|
|
71
|
+
const absPath = resolveToolPath(input);
|
|
72
|
+
if (!absPath || !fs.existsSync(absPath)) return null;
|
|
32
73
|
if (typeof matches === 'function' && !matches(absPath)) return null;
|
|
33
74
|
|
|
34
|
-
|
|
35
|
-
appendTrackedFile(listFile, absPath);
|
|
75
|
+
record(listFile, absPath, input);
|
|
36
76
|
return absPath;
|
|
37
77
|
}
|
|
38
78
|
|
|
39
|
-
|
|
79
|
+
function markerFile(stateDir, toolUseId) {
|
|
80
|
+
return path.join(stateDir, PENDING_DIR, `${String(toolUseId).replaceAll(/[^\w-]/g, '_')}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pruneStaleMarkers(pendingDir, now) {
|
|
84
|
+
let names;
|
|
85
|
+
try {
|
|
86
|
+
names = fs.readdirSync(pendingDir);
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const full = path.join(pendingDir, name);
|
|
92
|
+
try {
|
|
93
|
+
if (now - fs.statSync(full).mtimeMs > STALE_MARKER_MS) fs.rmSync(full, { force: true });
|
|
94
|
+
} catch {
|
|
95
|
+
// best effort
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function takeMarker(file) {
|
|
101
|
+
try {
|
|
102
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
103
|
+
fs.rmSync(file, { force: true });
|
|
104
|
+
try {
|
|
105
|
+
fs.rmdirSync(path.dirname(file));
|
|
106
|
+
} catch {
|
|
107
|
+
// other calls still pending
|
|
108
|
+
}
|
|
109
|
+
return snapshot;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* The files one tool call wrote, whatever the tool.
|
|
117
|
+
*
|
|
118
|
+
* Wire it to PreToolUse, PostToolUse and PostToolUseFailure — a shell
|
|
119
|
+
* command that edits files and then exits non-zero (`./fix.py && pytest`)
|
|
120
|
+
* reports through PostToolUseFailure, not PostToolUse:
|
|
121
|
+
*
|
|
122
|
+
* - PreToolUse on a shell or MCP tool: records a snapshot under
|
|
123
|
+
* `<stateDir>/pending/<tool_use_id>.json` and returns [].
|
|
124
|
+
* - Post* on an Edit-family tool: the one file it names, if it exists.
|
|
125
|
+
* - Post* on a shell or MCP tool: every file changed since the snapshot
|
|
126
|
+
* (see changes.js), consuming the snapshot. No snapshot, no files —
|
|
127
|
+
* the PreToolUse hook wasn't wired, or the call was never seen starting.
|
|
128
|
+
*
|
|
129
|
+
* Each snapshot is consumed by the first Post hook that reads it, so a
|
|
130
|
+
* plugin should have one script per state dir call this.
|
|
131
|
+
*/
|
|
132
|
+
function toolChangedFiles({ input, stateDir, now = Date.now() }) {
|
|
133
|
+
if (!input) return [];
|
|
134
|
+
const tool = input.tool_name || '';
|
|
135
|
+
const event = input.hook_event_name || 'PostToolUse';
|
|
136
|
+
|
|
137
|
+
if (FILE_TOOLS.has(tool)) {
|
|
138
|
+
if (event === 'PreToolUse') return [];
|
|
139
|
+
const absPath = resolveToolPath(input);
|
|
140
|
+
return absPath && fs.existsSync(absPath) ? [absPath] : [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!SHELL_TOOL.test(tool) || !input.tool_use_id) return [];
|
|
144
|
+
const marker = markerFile(stateDir, input.tool_use_id);
|
|
145
|
+
|
|
146
|
+
if (event === 'PreToolUse') {
|
|
147
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
148
|
+
pruneStaleMarkers(path.dirname(marker), now);
|
|
149
|
+
fs.writeFileSync(marker, JSON.stringify(snapshotWorkspace(input.cwd, { now })));
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const snapshot = takeMarker(marker);
|
|
154
|
+
return snapshot ? filesChangedSince(snapshot, { cwd: input.cwd }) : [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* toolChangedFiles + trackTouchedFile's bookkeeping: tracks every file the
|
|
159
|
+
* call wrote that `matches` accepts, in the session's list and (inside a
|
|
160
|
+
* subagent) the subagent's. Snapshots live beside `listFile`. Returns the
|
|
161
|
+
* paths tracked.
|
|
162
|
+
*/
|
|
163
|
+
function trackSessionChanges({ input, listFile, matches, now }) {
|
|
164
|
+
if (!input) return [];
|
|
165
|
+
const files = toolChangedFiles({ input, stateDir: path.dirname(listFile), now });
|
|
166
|
+
const tracked = files.filter((file) => typeof matches !== 'function' || matches(file));
|
|
167
|
+
for (const file of tracked) record(listFile, file, input);
|
|
168
|
+
return tracked;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { trackTouchedFile, toolChangedFiles, trackSessionChanges };
|