@leonardo-marziali/ad-lfl-kit 2.3.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.
Files changed (4) hide show
  1. package/README.md +40 -16
  2. package/changes.js +11 -0
  3. package/package.json +1 -1
  4. 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. Tool
23
- calls made inside a subagent are tracked the same way, and additionally
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
- // 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
- }),
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
- 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.
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 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leonardo-marziali/ad-lfl-kit",
3
- "version": "2.3.0",
3
+ "version": "2.5.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",
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's tool call, to that
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, input) {
59
+ function record(listFile, absPath, agentId) {
47
60
  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
- );
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, PENDING_DIR, `${String(toolUseId).replaceAll(/[^\w-]/g, '_')}.json`);
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 toolChangedFiles({ input, stateDir, now = Date.now() }) {
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 marker = markerFile(stateDir, input.tool_use_id);
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
- 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
- }
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
- const snapshot = takeMarker(marker);
154
- return snapshot ? filesChangedSince(snapshot, { cwd: input.cwd }) : [];
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 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);
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 };