@leonardo-marziali/ad-lfl-kit 0.0.0-development → 0.0.1-development
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 +207 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @leonardo-marziali/ad-lfl-kit
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@leonardo-marziali/ad-lfl-kit)
|
|
4
|
+
[](https://codecov.io/gh/leonardo-marziali/agentic-development-kit)
|
|
5
|
+
|
|
6
|
+
**A**gentic **d**evelopment **l**int-**f**ix-**l**oop kit: the "track what
|
|
7
|
+
this session touched, then check it before the session ends" pattern used by
|
|
8
|
+
[Claude Code](https://claude.com/claude-code) plugin hooks, as a small
|
|
9
|
+
zero-dependency package instead of copy-pasted hook source.
|
|
10
|
+
|
|
11
|
+
It backs the session-scoped fix loops in this repository's
|
|
12
|
+
[`markdown`](https://github.com/leonardo-marziali/agentic-development-kit/tree/main/plugins/markdown)
|
|
13
|
+
and `sonarqube` plugins, and is published standalone so any Claude Code
|
|
14
|
+
plugin can build the same loop without re-implementing it.
|
|
15
|
+
|
|
16
|
+
## The pattern
|
|
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:
|
|
23
|
+
- 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
|
|
25
|
+
the failures and keeps working, up to a capped number of attempts;
|
|
26
|
+
- at the cap, gives up — reporting what's left, optionally applying a
|
|
27
|
+
caller-supplied fallback — rather than blocking forever.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @leonardo-marziali/ad-lfl-kit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> **If you're shipping this inside a Claude Code plugin**, see
|
|
36
|
+
> [the parent repo's README](https://github.com/leonardo-marziali/agentic-development-kit#shared-packages-and-plugin-dependencies)
|
|
37
|
+
> for why the plugin needs its own committed `package-lock.json` for this to
|
|
38
|
+
> actually install where Claude Code runs the hook.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
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}`.
|
|
46
|
+
|
|
47
|
+
**`scripts/hooks/track-touched.js`**
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
#!/usr/bin/env node
|
|
51
|
+
'use strict';
|
|
52
|
+
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
const { readHookInput, stateDir, trackTouchedFile } = require('@leonardo-marziali/ad-lfl-kit');
|
|
55
|
+
|
|
56
|
+
const input = readHookInput();
|
|
57
|
+
if (input) {
|
|
58
|
+
const dir = stateDir({
|
|
59
|
+
sessionId: input.session_id,
|
|
60
|
+
namespace: 'my-plugin-session-scope',
|
|
61
|
+
});
|
|
62
|
+
trackTouchedFile({
|
|
63
|
+
input,
|
|
64
|
+
listFile: path.join(dir, 'touched-files.txt'),
|
|
65
|
+
matches: (absPath) => /\.md$/i.test(absPath),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**`scripts/hooks/check-and-loop.js`**
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
#!/usr/bin/env node
|
|
74
|
+
'use strict';
|
|
75
|
+
|
|
76
|
+
const path = require('node:path');
|
|
77
|
+
const {
|
|
78
|
+
readHookInput,
|
|
79
|
+
stateDir,
|
|
80
|
+
readTrackedFiles,
|
|
81
|
+
runFixLoop,
|
|
82
|
+
runBin,
|
|
83
|
+
} = require('@leonardo-marziali/ad-lfl-kit');
|
|
84
|
+
|
|
85
|
+
const input = readHookInput();
|
|
86
|
+
const dir = stateDir({
|
|
87
|
+
sessionId: input?.session_id,
|
|
88
|
+
namespace: 'my-plugin-session-scope',
|
|
89
|
+
});
|
|
90
|
+
const stateFiles = ['touched-files.txt', 'attempts.json'];
|
|
91
|
+
const files = readTrackedFiles(path.join(dir, 'touched-files.txt'));
|
|
92
|
+
|
|
93
|
+
runFixLoop({
|
|
94
|
+
input,
|
|
95
|
+
dir,
|
|
96
|
+
stateFiles,
|
|
97
|
+
files,
|
|
98
|
+
fix: (files) => runBin('npx', ['-y', 'markdownlint-cli', '--fix', ...files]),
|
|
99
|
+
check: (files) => {
|
|
100
|
+
const result = runBin('npx', ['-y', 'markdownlint-cli', ...files], {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
});
|
|
103
|
+
const report = ((result.stdout || '') + (result.stderr || '')).trim();
|
|
104
|
+
return { failed: result.status !== 0, report };
|
|
105
|
+
},
|
|
106
|
+
onCap: ({ outcome }) => {
|
|
107
|
+
process.stderr.write(`still failing after the attempt cap:\n${outcome.report}\n`);
|
|
108
|
+
},
|
|
109
|
+
buildMessage: ({ attempt, maxAttempts, outcome }) => ({
|
|
110
|
+
stopReason: 'Files touched this session still have lint violations.',
|
|
111
|
+
systemMessage: `Lint found issues (attempt ${attempt}/${maxAttempts}):\n\n${outcome.report}`,
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
That's a complete loop: nothing tracked → no-op; clean → allow the stop;
|
|
117
|
+
failing → block with a message Claude can act on, up to 3 attempts by
|
|
118
|
+
default; still failing at the cap → `onCap` runs once, then the stop is
|
|
119
|
+
allowed anyway.
|
|
120
|
+
|
|
121
|
+
## API
|
|
122
|
+
|
|
123
|
+
### Hook I/O (`hook-input.js`)
|
|
124
|
+
|
|
125
|
+
- **`readStdin()`** — reads file descriptor 0, returning `''` if nothing's
|
|
126
|
+
attached (a hook invoked without stdin, some test harnesses) instead of
|
|
127
|
+
throwing.
|
|
128
|
+
- **`readHookInput()`** — parses the hook payload Claude Code sends on
|
|
129
|
+
stdin. Returns `null` for empty or malformed input, never throws.
|
|
130
|
+
|
|
131
|
+
### Session state (`state.js`)
|
|
132
|
+
|
|
133
|
+
- **`stateDir({ sessionId, namespace })`** — a directory scoped to one
|
|
134
|
+
session and one namespace, under `${CLAUDE_PLUGIN_DATA}` when set (so it
|
|
135
|
+
survives plugin updates) or the OS temp dir otherwise.
|
|
136
|
+
- **`readTrackedFiles(listFile)`** — the de-duplicated contents of a tracked
|
|
137
|
+
file list, filtered to files that still exist on disk.
|
|
138
|
+
- **`appendTrackedFile(listFile, absPath)`** — appends a path, skipping it
|
|
139
|
+
if already present.
|
|
140
|
+
- **`readAttempts(attemptsFile)`** / **`writeAttempts(attemptsFile, count)`**
|
|
141
|
+
— the attempt counter `runFixLoop` uses internally; exposed for callers
|
|
142
|
+
that need to inspect or reset it directly.
|
|
143
|
+
- **`cleanup(dir, ownStateFiles)`** — removes only the named files, then
|
|
144
|
+
removes `dir` if that leaves it empty. Deliberately not a recursive
|
|
145
|
+
delete: a session dir can hold another hook's still-pending state, and a
|
|
146
|
+
blind `rm -rf` would destroy it.
|
|
147
|
+
|
|
148
|
+
### Tracking (`track.js`)
|
|
149
|
+
|
|
150
|
+
- **`trackTouchedFile({ input, listFile, matches })`** — the `PostToolUse`
|
|
151
|
+
half. No-ops unless `input.tool_name` is `Edit` or `Write`, the tool's
|
|
152
|
+
`file_path` still exists on disk (resolved against `input.cwd`), and
|
|
153
|
+
`matches(absPath)` returns true (omit `matches` to track everything).
|
|
154
|
+
Returns the tracked absolute path, or `null`.
|
|
155
|
+
|
|
156
|
+
### Execution (`exec.js`)
|
|
157
|
+
|
|
158
|
+
- **`resolveBin(name, { extraDirs })`** — finds a CLI binary across
|
|
159
|
+
platforms. Checks beside `process.execPath` first (where `npx`/`npm`
|
|
160
|
+
live), then each of `extraDirs` (for binaries installed elsewhere — a
|
|
161
|
+
Homebrew or `mise` shim, say), trying the Windows-appropriate suffix
|
|
162
|
+
(`.cmd`/`.exe`) before falling back to the bare name for PATH lookup.
|
|
163
|
+
- **`quoteForCmd(arg)`** — quotes one argument for `cmd.exe`, which
|
|
164
|
+
`spawnSync` doesn't do for you when `shell: true`.
|
|
165
|
+
- **`runBin(name, args, options)`** — resolves and runs a binary, routing
|
|
166
|
+
through a shell only on Windows (where the shim is a `.cmd` batch file
|
|
167
|
+
Node won't exec directly), with arguments quoted by hand there so no
|
|
168
|
+
shell ever parses a file path on any other platform. `options` also
|
|
169
|
+
accepts `extraDirs`, forwarded to `resolveBin`.
|
|
170
|
+
|
|
171
|
+
### The loop (`loop.js`)
|
|
172
|
+
|
|
173
|
+
- **`runFixLoop({ input, dir, stateFiles, attemptsFile, maxAttempts, files,
|
|
174
|
+
fix, check, onCap, buildMessage, write })`** — the `Stop`/`SubagentStop`
|
|
175
|
+
half described above. `check(files)` must
|
|
176
|
+
return `{ failed, report }`; `fix(files)` is optional and runs first when
|
|
177
|
+
given; `onCap({ files, outcome, maxAttempts })` is optional and runs
|
|
178
|
+
once instead of blocking when the attempt cap is reached;
|
|
179
|
+
`buildMessage({ attempt, maxAttempts, outcome, files })` returns
|
|
180
|
+
`{ stopReason?, systemMessage }` for the block payload. `maxAttempts`
|
|
181
|
+
defaults to `DEFAULT_MAX_ATTEMPTS` (3). Returns
|
|
182
|
+
`{ blocked, reason, ... }` so a caller or its tests can assert on which
|
|
183
|
+
path was taken (`'no-input'`, `'no-files'`, `'clean'`, `'cap'`,
|
|
184
|
+
`'blocked'`, or `'error'` if `fix`/`check` threw — in which case the
|
|
185
|
+
session's state is left untouched so the next `Stop` retries, rather than
|
|
186
|
+
either blocking on a broken toolchain or silently discarding progress).
|
|
187
|
+
- **`DEFAULT_MAX_ATTEMPTS`** — `3`.
|
|
188
|
+
|
|
189
|
+
## Design notes
|
|
190
|
+
|
|
191
|
+
- **Every module is plain CommonJS with zero runtime dependencies** and no
|
|
192
|
+
build step. Claude Code installs a plugin's dependencies by running
|
|
193
|
+
`npm ci --ignore-scripts` against its own `package-lock.json`, capped at
|
|
194
|
+
60 seconds, when it caches the plugin — a transitive dependency or a
|
|
195
|
+
compile step would risk that budget.
|
|
196
|
+
- **State cleanup is additive, never destructive.** `cleanup()` only removes
|
|
197
|
+
the file names it's given; two hooks sharing a session's state directory
|
|
198
|
+
(a tracking hook and, say, this repo's own test-verification hook) can't
|
|
199
|
+
clobber each other.
|
|
200
|
+
- **A failing `check`/`fix` is not the same as a failing check.** If either
|
|
201
|
+
throws, `runFixLoop` neither blocks the session nor discards its state —
|
|
202
|
+
a broken toolchain shouldn't trap a session in a loop, and it shouldn't
|
|
203
|
+
look like a clean pass either.
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leonardo-marziali/ad-lfl-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.1-development",
|
|
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",
|