@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 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 `PostToolUse` hook (matching `Edit`/`Write`) records every file the
19
- current session touches that matches some predicate — only `.md` files,
20
- say — into a session-scoped list.
21
- 2. A `Stop`/`SubagentStop` hook reads that list, runs a check (optionally
22
- preceded by an auto-fix pass), and:
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: "continue"` so Claude sees
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
- a `PostToolUse` hook on `Edit|Write`, and `Stop`/`SubagentStop` hooks, both
45
- pointed at `${CLAUDE_PLUGIN_ROOT}`.
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, trackTouchedFile } = require('@leonardo-marziali/ad-lfl-kit');
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
- trackTouchedFile({
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
- const dir = stateDir({
87
- sessionId: input?.session_id,
88
- namespace: 'my-plugin-session-scope',
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
- - **`trackTouchedFile({ input, listFile, matches })`** — the `PostToolUse`
200
- half. No-ops unless `input.tool_name` is `Edit` or `Write`, the tool's
201
- `file_path` still exists on disk (resolved against `input.cwd`), and
202
- `matches(absPath)` returns true (omit `matches` to track everything).
203
- Returns the tracked absolute path, or `null`.
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. `maxAttempts`
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
- - **`DEFAULT_MAX_ATTEMPTS`** — `3`.
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
@@ -10,6 +10,7 @@ module.exports = {
10
10
  ...require('./exec'),
11
11
  ...require('./hook-input'),
12
12
  ...require('./state'),
13
+ ...require('./changes'),
13
14
  ...require('./track'),
14
15
  ...require('./scope'),
15
16
  ...require('./loop'),
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:"continue" so
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.2.0",
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 file a tool just wrote, so the
9
- * Stop hook only ever inspects files this session actually touched — never
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
- const filePath = input.tool_input?.file_path;
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
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
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
- if (!fs.existsSync(absPath)) return null;
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
- fs.mkdirSync(path.dirname(listFile), { recursive: true });
35
- appendTrackedFile(listFile, absPath);
75
+ record(listFile, absPath, input);
36
76
  return absPath;
37
77
  }
38
78
 
39
- module.exports = { trackTouchedFile };
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 };