@leonardo-marziali/ad-lfl-kit 2.4.0 → 2.5.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 +40 -16
- package/changes.js +11 -0
- package/package.json +1 -1
- package/track.js +160 -29
package/README.md
CHANGED
|
@@ -19,8 +19,9 @@ plugin can build the same loop without re-implementing it.
|
|
|
19
19
|
matches some predicate — only `.md` files, say — into a session-scoped
|
|
20
20
|
list. Edit-family tools name their file; for `Bash`, `PowerShell` and MCP
|
|
21
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
|
|
23
|
-
|
|
22
|
+
workspace and tracks whatever changed by the time the call ends — or, for
|
|
23
|
+
a command run in the background, keeps tracking its writes on later
|
|
24
|
+
events. Tool calls made inside a subagent are tracked the same way, and additionally
|
|
24
25
|
into that subagent's own list.
|
|
25
26
|
2. A `Stop`/`SubagentStop` hook reads that list — a `SubagentStop` reads only
|
|
26
27
|
its subagent's — runs a check (optionally preceded by an auto-fix pass),
|
|
@@ -95,19 +96,24 @@ const {
|
|
|
95
96
|
stateDir,
|
|
96
97
|
loopStateDir,
|
|
97
98
|
readTrackedFiles,
|
|
99
|
+
trackSessionChanges,
|
|
98
100
|
runFixLoop,
|
|
99
101
|
runBin,
|
|
100
102
|
} = require('@leonardo-marziali/ad-lfl-kit');
|
|
101
103
|
|
|
102
104
|
const input = readHookInput();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
const sessionDir = stateDir({
|
|
106
|
+
sessionId: input?.session_id,
|
|
107
|
+
namespace: 'my-plugin-session-scope',
|
|
108
|
+
});
|
|
109
|
+
// Collect what background commands wrote since the last tool call.
|
|
110
|
+
trackSessionChanges({
|
|
109
111
|
input,
|
|
112
|
+
listFile: path.join(sessionDir, 'touched-files.txt'),
|
|
113
|
+
matches: (absPath) => absPath.endsWith('.md'),
|
|
110
114
|
});
|
|
115
|
+
// A SubagentStop gets its subagent's own directory; a Stop the session's.
|
|
116
|
+
const dir = loopStateDir({ dir: sessionDir, input });
|
|
111
117
|
const stateFiles = ['touched-files.txt', 'attempts.json'];
|
|
112
118
|
const files = readTrackedFiles(path.join(dir, 'touched-files.txt'));
|
|
113
119
|
|
|
@@ -226,11 +232,15 @@ excluded rather than drop it silently.
|
|
|
226
232
|
### Tracking (`track.js`)
|
|
227
233
|
|
|
228
234
|
- **`trackSessionChanges({ input, listFile, matches })`** — the tracking
|
|
229
|
-
half; wire it to `PreToolUse`, `PostToolUse` and `PostToolUseFailure
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
`
|
|
235
|
+
half; wire it to `PreToolUse`, `PostToolUse` and `PostToolUseFailure`,
|
|
236
|
+
and call it from the `Stop`/`SubagentStop` hook too, before reading
|
|
237
|
+
`listFile`, so background writes made after the last tool call are
|
|
238
|
+
collected. Tracks every file `toolChangedFiles` reports that
|
|
239
|
+
`matches(absPath)` accepts (omit `matches` to track everything), in
|
|
240
|
+
`listFile` and — for a subagent's call — in that subagent's list. A
|
|
241
|
+
background command's later writes reach its subagent's list only while
|
|
242
|
+
that subagent is the one calling; the session's list always gets them.
|
|
243
|
+
Snapshots are kept beside `listFile`. Returns the tracked paths.
|
|
234
244
|
- **`toolChangedFiles({ input, stateDir })`** — the files one tool call
|
|
235
245
|
wrote, for hooks that act on them without tracking (a formatter). For
|
|
236
246
|
`Edit`, `Write`, `MultiEdit` and `NotebookEdit` that's the file the input
|
|
@@ -239,6 +249,18 @@ excluded rather than drop it silently.
|
|
|
239
249
|
and the `Post*` event returns what changed since and consumes it, so only
|
|
240
250
|
one script per state dir should call this. Read-only tools return `[]`
|
|
241
251
|
without scanning anything.
|
|
252
|
+
|
|
253
|
+
A backgrounded command — `run_in_background: true`, or one the user sent
|
|
254
|
+
to the background, which Claude Code reports with a `backgroundTaskId` —
|
|
255
|
+
gets its `Post*` event as soon as it's backgrounded, while it keeps
|
|
256
|
+
writing. Its snapshot then moves to `<stateDir>/background/` instead of
|
|
257
|
+
being consumed, and every later `Post*`, `Stop` and `SubagentStop` event
|
|
258
|
+
also returns what it wrote since the previous such sweep. Hooks aren't
|
|
259
|
+
told when a background command exits, so its window closes
|
|
260
|
+
`BACKGROUND_WINDOW_MS` (2 hours) after it started, with one last sweep.
|
|
261
|
+
Within the window, anything written in the scanned repos counts —
|
|
262
|
+
including edits you make in an editor meanwhile.
|
|
263
|
+
|
|
242
264
|
- **`trackTouchedFile({ input, listFile, matches })`** — the Edit-family
|
|
243
265
|
subset of `trackSessionChanges`: no-ops unless `input.tool_name` is
|
|
244
266
|
`Edit`, `Write`, `MultiEdit` or `NotebookEdit` and the file it names still
|
|
@@ -251,6 +273,8 @@ How `toolChangedFiles` answers for tools that don't name their files.
|
|
|
251
273
|
- **`snapshotWorkspace(cwd)`** — `{ startedAt, cwd, root, head }`: the time,
|
|
252
274
|
the cwd, and the git root and `HEAD` it sits in (both `null` outside a
|
|
253
275
|
repo).
|
|
276
|
+
- **`advanceSnapshot(snapshot)`** — the same snapshot moved up to now (new
|
|
277
|
+
time and `HEAD`), for watching a window that stays open.
|
|
254
278
|
- **`filesChangedSince(snapshot, { cwd })`** — absolute paths written since
|
|
255
279
|
the snapshot. In a repo: the dirty and untracked files from `git status`,
|
|
256
280
|
plus the files of commits made during the call (so `edit && git commit`
|
|
@@ -265,9 +289,9 @@ How `toolChangedFiles` answers for tools that don't name their files.
|
|
|
265
289
|
- **`MTIME_SLACK_MS`** / **`PRUNED_DIR_NAMES`** — the mtime tolerance for
|
|
266
290
|
coarse filesystems, and the directories skipped.
|
|
267
291
|
|
|
268
|
-
What it can't see: writes
|
|
269
|
-
|
|
270
|
-
|
|
292
|
+
What it can't see: writes outside both the snapshot's and the current
|
|
293
|
+
cwd's repository, and a background command's writes more than
|
|
294
|
+
`BACKGROUND_WINDOW_MS` after it started.
|
|
271
295
|
|
|
272
296
|
### Execution (`exec.js`)
|
|
273
297
|
|
package/changes.js
CHANGED
|
@@ -103,6 +103,16 @@ function snapshotWorkspace(cwd, { now = Date.now() } = {}) {
|
|
|
103
103
|
return { startedAt: now, cwd: dir, root, head: root ? headOf(root) : null };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/*
|
|
107
|
+
* The snapshot that picks up where `snapshot` leaves off: same cwd and repo,
|
|
108
|
+
* with the clock and HEAD moved to now. Take it *before* reading what
|
|
109
|
+
* changed since `snapshot`, so a write landing in between falls into both
|
|
110
|
+
* windows rather than neither.
|
|
111
|
+
*/
|
|
112
|
+
function advanceSnapshot(snapshot, { now = Date.now() } = {}) {
|
|
113
|
+
return { ...snapshot, startedAt: now, head: snapshot.root ? headOf(snapshot.root) : null };
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
/*
|
|
107
117
|
* `git status -z` entries are `XY <path>`; a rename or copy is followed by
|
|
108
118
|
* one more NUL-terminated entry holding the original path, which is skipped.
|
|
@@ -212,5 +222,6 @@ module.exports = {
|
|
|
212
222
|
MTIME_SLACK_MS,
|
|
213
223
|
PRUNED_DIR_NAMES,
|
|
214
224
|
snapshotWorkspace,
|
|
225
|
+
advanceSnapshot,
|
|
215
226
|
filesChangedSince,
|
|
216
227
|
};
|
package/package.json
CHANGED
package/track.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { appendTrackedFile, agentStateDir } = require('./state');
|
|
6
|
-
const { snapshotWorkspace, filesChangedSince } = require('./changes');
|
|
6
|
+
const { snapshotWorkspace, advanceSnapshot, filesChangedSince } = require('./changes');
|
|
7
7
|
|
|
8
8
|
/*
|
|
9
9
|
* The PostToolUse half of the loop: records the files a tool just wrote, so
|
|
@@ -23,10 +23,23 @@ const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
|
23
23
|
const SHELL_TOOL = /^(Bash|PowerShell|mcp__.+)$/;
|
|
24
24
|
|
|
25
25
|
const PENDING_DIR = 'pending';
|
|
26
|
+
const BACKGROUND_DIR = 'background';
|
|
26
27
|
|
|
27
28
|
// A Pre marker whose Post never came (a denied permission, a crash).
|
|
28
29
|
const STALE_MARKER_MS = 24 * 60 * 60 * 1000;
|
|
29
30
|
|
|
31
|
+
/*
|
|
32
|
+
* How long a background command's writes keep being attributed to the
|
|
33
|
+
* session. Claude Code doesn't tell hooks when a background command exits,
|
|
34
|
+
* so its window closes on a clock instead: long enough for a build, a
|
|
35
|
+
* codegen run or a test suite, short enough that a dev server left running
|
|
36
|
+
* doesn't make the session answer for the rest of the day's edits.
|
|
37
|
+
*/
|
|
38
|
+
const BACKGROUND_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// Events that only collect what background commands wrote since last time.
|
|
41
|
+
const STOP_EVENTS = new Set(['Stop', 'SubagentStop']);
|
|
42
|
+
|
|
30
43
|
function resolveToolPath(input) {
|
|
31
44
|
const filePath = input.tool_input?.file_path ?? input.tool_input?.notebook_path;
|
|
32
45
|
if (!filePath) return null;
|
|
@@ -40,15 +53,13 @@ function resolveToolPath(input) {
|
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
/*
|
|
43
|
-
* Appends to the session's list and, for a subagent
|
|
56
|
+
* Appends to the session's list and, for a file a subagent wrote, to that
|
|
44
57
|
* subagent's own list as well (see agentStateDir).
|
|
45
58
|
*/
|
|
46
|
-
function record(listFile, absPath,
|
|
59
|
+
function record(listFile, absPath, agentId) {
|
|
47
60
|
const lists = [listFile];
|
|
48
|
-
if (
|
|
49
|
-
lists.push(
|
|
50
|
-
path.join(agentStateDir(path.dirname(listFile), input.agent_id), path.basename(listFile)),
|
|
51
|
-
);
|
|
61
|
+
if (agentId) {
|
|
62
|
+
lists.push(path.join(agentStateDir(path.dirname(listFile), agentId), path.basename(listFile)));
|
|
52
63
|
}
|
|
53
64
|
for (const list of lists) {
|
|
54
65
|
fs.mkdirSync(path.dirname(list), { recursive: true });
|
|
@@ -72,12 +83,100 @@ function trackTouchedFile({ input, listFile, matches }) {
|
|
|
72
83
|
if (!absPath || !fs.existsSync(absPath)) return null;
|
|
73
84
|
if (typeof matches === 'function' && !matches(absPath)) return null;
|
|
74
85
|
|
|
75
|
-
record(listFile, absPath, input);
|
|
86
|
+
record(listFile, absPath, input.agent_id);
|
|
76
87
|
return absPath;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
function markerFile(stateDir, toolUseId) {
|
|
80
|
-
return path.join(stateDir,
|
|
90
|
+
function markerFile(stateDir, toolUseId, kind = PENDING_DIR) {
|
|
91
|
+
return path.join(stateDir, kind, `${String(toolUseId).replaceAll(/[^\w-]/g, '_')}.json`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
* A `run_in_background: true` call, or one the user sent to the background
|
|
96
|
+
* mid-run: its Post event fires as soon as it's backgrounded, while the
|
|
97
|
+
* command itself keeps writing.
|
|
98
|
+
*/
|
|
99
|
+
function isBackgrounded(input) {
|
|
100
|
+
return (
|
|
101
|
+
input.tool_input?.run_in_background === true || Boolean(input.tool_response?.backgroundTaskId)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Rename over the old file, so a concurrent reader never sees half of one.
|
|
106
|
+
function writeJsonAtomic(file, value) {
|
|
107
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
108
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
109
|
+
fs.writeFileSync(tmp, JSON.stringify(value));
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/*
|
|
114
|
+
* Keeps watching after a background command's Post event: its snapshot,
|
|
115
|
+
* moved up to now, is left under `<stateDir>/background/` for later events
|
|
116
|
+
* to sweep. `cwd` is where the call left the session, which filesChangedSince
|
|
117
|
+
* also scans.
|
|
118
|
+
*/
|
|
119
|
+
function openBackgroundWindow(stateDir, input, snapshot, now) {
|
|
120
|
+
writeJsonAtomic(markerFile(stateDir, input.tool_use_id, BACKGROUND_DIR), {
|
|
121
|
+
openedAt: now,
|
|
122
|
+
cwd: input.cwd,
|
|
123
|
+
agentId: input.agent_id,
|
|
124
|
+
snapshot,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A window as openBackgroundWindow wrote it, or null.
|
|
129
|
+
function readWindow(file) {
|
|
130
|
+
try {
|
|
131
|
+
const window = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
132
|
+
return typeof window?.openedAt === 'number' && window.snapshot ? window : null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/*
|
|
139
|
+
* Everything open background commands wrote since the last sweep, each
|
|
140
|
+
* window then moved up to `now` — or, once BACKGROUND_WINDOW_MS has passed,
|
|
141
|
+
* closed after this last look.
|
|
142
|
+
*/
|
|
143
|
+
function sweepBackgroundWindows(stateDir, now) {
|
|
144
|
+
const dir = path.join(stateDir, BACKGROUND_DIR);
|
|
145
|
+
let names;
|
|
146
|
+
try {
|
|
147
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const changes = [];
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
const file = path.join(dir, name);
|
|
155
|
+
const window = readWindow(file);
|
|
156
|
+
if (!window) {
|
|
157
|
+
// Swept and closed by a concurrent hook, or unreadable: fail closed.
|
|
158
|
+
fs.rmSync(file, { force: true });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const expired = now - window.openedAt >= BACKGROUND_WINDOW_MS;
|
|
163
|
+
const next = expired ? null : advanceSnapshot(window.snapshot, { now });
|
|
164
|
+
for (const changed of filesChangedSince(window.snapshot, { cwd: window.cwd })) {
|
|
165
|
+
changes.push({ file: changed, agentId: window.agentId });
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
if (expired) fs.rmSync(file, { force: true });
|
|
169
|
+
else writeJsonAtomic(file, { ...window, snapshot: next });
|
|
170
|
+
} catch {
|
|
171
|
+
// best effort
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
fs.rmdirSync(dir);
|
|
176
|
+
} catch {
|
|
177
|
+
// windows still open
|
|
178
|
+
}
|
|
179
|
+
return changes;
|
|
81
180
|
}
|
|
82
181
|
|
|
83
182
|
function pruneStaleMarkers(pendingDir, now) {
|
|
@@ -126,32 +225,51 @@ function takeMarker(file) {
|
|
|
126
225
|
* (see changes.js), consuming the snapshot. No snapshot, no files —
|
|
127
226
|
* the PreToolUse hook wasn't wired, or the call was never seen starting.
|
|
128
227
|
*
|
|
228
|
+
* A backgrounded command's Post event fires when it's sent to the
|
|
229
|
+
* background, not when it exits, so its snapshot moves to
|
|
230
|
+
* `<stateDir>/background/` instead, and every later Post* — and a Stop or
|
|
231
|
+
* SubagentStop, which only does this — also returns what open background
|
|
232
|
+
* commands wrote since the last such sweep.
|
|
233
|
+
*
|
|
129
234
|
* Each snapshot is consumed by the first Post hook that reads it, so a
|
|
130
235
|
* plugin should have one script per state dir call this.
|
|
131
236
|
*/
|
|
132
|
-
function
|
|
133
|
-
if (!input) return [];
|
|
237
|
+
function toolChangedEntries({ input, stateDir, now }) {
|
|
134
238
|
const tool = input.tool_name || '';
|
|
135
239
|
const event = input.hook_event_name || 'PostToolUse';
|
|
240
|
+
const own = (files) => files.map((file) => ({ file, agentId: input.agent_id }));
|
|
241
|
+
|
|
242
|
+
if (event === 'PreToolUse') {
|
|
243
|
+
if (SHELL_TOOL.test(tool) && input.tool_use_id) {
|
|
244
|
+
const marker = markerFile(stateDir, input.tool_use_id);
|
|
245
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
246
|
+
pruneStaleMarkers(path.dirname(marker), now);
|
|
247
|
+
fs.writeFileSync(marker, JSON.stringify(snapshotWorkspace(input.cwd, { now })));
|
|
248
|
+
}
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const changes = sweepBackgroundWindows(stateDir, now);
|
|
253
|
+
if (STOP_EVENTS.has(event)) return changes;
|
|
136
254
|
|
|
137
255
|
if (FILE_TOOLS.has(tool)) {
|
|
138
|
-
if (event === 'PreToolUse') return [];
|
|
139
256
|
const absPath = resolveToolPath(input);
|
|
140
|
-
return absPath && fs.existsSync(absPath) ? [absPath] :
|
|
257
|
+
return absPath && fs.existsSync(absPath) ? [...changes, ...own([absPath])] : changes;
|
|
141
258
|
}
|
|
142
259
|
|
|
143
|
-
if (!SHELL_TOOL.test(tool) || !input.tool_use_id) return
|
|
144
|
-
const
|
|
260
|
+
if (!SHELL_TOOL.test(tool) || !input.tool_use_id) return changes;
|
|
261
|
+
const snapshot = takeMarker(markerFile(stateDir, input.tool_use_id));
|
|
262
|
+
if (!snapshot) return changes;
|
|
145
263
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
264
|
+
const next = isBackgrounded(input) ? advanceSnapshot(snapshot, { now }) : null;
|
|
265
|
+
changes.push(...own(filesChangedSince(snapshot, { cwd: input.cwd })));
|
|
266
|
+
if (next) openBackgroundWindow(stateDir, input, next, now);
|
|
267
|
+
return changes;
|
|
268
|
+
}
|
|
152
269
|
|
|
153
|
-
|
|
154
|
-
|
|
270
|
+
function toolChangedFiles({ input, stateDir, now = Date.now() }) {
|
|
271
|
+
if (!input) return [];
|
|
272
|
+
return [...new Set(toolChangedEntries({ input, stateDir, now }).map(({ file }) => file))];
|
|
155
273
|
}
|
|
156
274
|
|
|
157
275
|
/*
|
|
@@ -159,13 +277,26 @@ function toolChangedFiles({ input, stateDir, now = Date.now() }) {
|
|
|
159
277
|
* call wrote that `matches` accepts, in the session's list and (inside a
|
|
160
278
|
* subagent) the subagent's. Snapshots live beside `listFile`. Returns the
|
|
161
279
|
* paths tracked.
|
|
280
|
+
*
|
|
281
|
+
* Call it from the Stop/SubagentStop hook too, before reading `listFile`,
|
|
282
|
+
* to collect what background commands wrote after the last tool call. A
|
|
283
|
+
* background command's file goes in its subagent's list only when that
|
|
284
|
+
* subagent is the one sweeping: one that has already stopped has no loop
|
|
285
|
+
* left to read it, and the session's list still gets it.
|
|
162
286
|
*/
|
|
163
|
-
function trackSessionChanges({ input, listFile, matches, now }) {
|
|
287
|
+
function trackSessionChanges({ input, listFile, matches, now = Date.now() }) {
|
|
164
288
|
if (!input) return [];
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
289
|
+
const tracked = [];
|
|
290
|
+
for (const { file, agentId } of toolChangedEntries({
|
|
291
|
+
input,
|
|
292
|
+
stateDir: path.dirname(listFile),
|
|
293
|
+
now,
|
|
294
|
+
})) {
|
|
295
|
+
if (typeof matches === 'function' && !matches(file)) continue;
|
|
296
|
+
record(listFile, file, agentId && agentId === input.agent_id ? agentId : undefined);
|
|
297
|
+
if (!tracked.includes(file)) tracked.push(file);
|
|
298
|
+
}
|
|
168
299
|
return tracked;
|
|
169
300
|
}
|
|
170
301
|
|
|
171
|
-
module.exports = { trackTouchedFile, toolChangedFiles, trackSessionChanges };
|
|
302
|
+
module.exports = { BACKGROUND_WINDOW_MS, trackTouchedFile, toolChangedFiles, trackSessionChanges };
|