@leonardo-marziali/ad-lfl-kit 0.0.0-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/exec.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawnSync } = require('node:child_process');
6
+
7
+ /*
8
+ * Locates a CLI shim across platforms.
9
+ *
10
+ * Hooks don't inherit a login shell, so PATH can be missing the toolchain
11
+ * entirely; looking beside process.execPath is the reliable first guess for
12
+ * anything installed alongside node (npx, npm). But the shim is only a bare
13
+ * name on POSIX — on Windows it's `<name>.cmd` — and it isn't always installed
14
+ * next to the node binary (system package managers, some version managers).
15
+ * So probe the real candidates, and fall back to a bare name for PATH lookup.
16
+ *
17
+ * `extraDirs` covers binaries that are never node-adjacent — a CLI installed
18
+ * by Homebrew or mise, say. Callers that pass nothing behave exactly as they
19
+ * did before the option existed.
20
+ */
21
+ function resolveBin(name, options = {}) {
22
+ const { extraDirs = [] } = options;
23
+ const dirs = [path.dirname(process.execPath), ...extraDirs];
24
+ const candidates = process.platform === 'win32' ? [`${name}.cmd`, `${name}.exe`, name] : [name];
25
+
26
+ for (const dir of dirs) {
27
+ if (!dir) continue;
28
+ for (const candidate of candidates) {
29
+ const full = path.join(dir, candidate);
30
+ if (fs.existsSync(full)) return full;
31
+ }
32
+ }
33
+ return candidates[0];
34
+ }
35
+
36
+ /*
37
+ * cmd.exe consumes the argument string itself, and spawnSync only space-joins
38
+ * argv when shell:true — so quote each argument here. Runs of backslashes
39
+ * before a quote (and at the very end) are doubled first, since cmd.exe would
40
+ * otherwise treat them as escaping the quote.
41
+ */
42
+ function quoteForCmd(arg) {
43
+ const value = String(arg)
44
+ .replace(/(\\*)"/g, '$1$1""')
45
+ .replace(/(\\+)$/, '$1$1');
46
+ return `"${value}"`;
47
+ }
48
+
49
+ /*
50
+ * Runs a CLI shim. On Windows the shim is a .cmd batch file, which Node
51
+ * refuses to spawn without a shell, so route through one there (with
52
+ * arguments quoted by hand); everywhere else spawn argv directly, so no shell
53
+ * ever parses a file path.
54
+ */
55
+ function runBin(name, args, options = {}) {
56
+ const { extraDirs, ...spawnOptions } = options;
57
+ const bin = resolveBin(name, { extraDirs });
58
+
59
+ if (process.platform === 'win32') {
60
+ return spawnSync(quoteForCmd(bin), args.map(quoteForCmd), {
61
+ ...spawnOptions,
62
+ shell: true,
63
+ windowsVerbatimArguments: true,
64
+ });
65
+ }
66
+ return spawnSync(bin, args, spawnOptions);
67
+ }
68
+
69
+ module.exports = { resolveBin, quoteForCmd, runBin };
package/hook-input.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+
5
+ /*
6
+ * Claude Code delivers the hook payload on stdin. Reading fd 0 throws when
7
+ * there's nothing attached (a hook invoked by hand, some test harnesses), and
8
+ * a hook that crashes on startup is worse than one that does nothing, so
9
+ * failure reads as "no input".
10
+ */
11
+ function readStdin() {
12
+ try {
13
+ return fs.readFileSync(0, 'utf8');
14
+ } catch {
15
+ return '';
16
+ }
17
+ }
18
+
19
+ /*
20
+ * Returns the parsed hook payload, or null when stdin held nothing parseable.
21
+ * Callers treat null as "not enough information to act" and return quietly.
22
+ */
23
+ function readHookInput() {
24
+ try {
25
+ return JSON.parse(readStdin() || '{}');
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ module.exports = { readStdin, readHookInput };
package/index.js ADDED
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Agentic development lint-fix-loop kit: the session-scoped "track what was
5
+ * touched, then check it before the session ends" pattern, shared by the
6
+ * plugins in this marketplace.
7
+ */
8
+
9
+ module.exports = {
10
+ ...require('./exec'),
11
+ ...require('./hook-input'),
12
+ ...require('./state'),
13
+ ...require('./track'),
14
+ ...require('./loop'),
15
+ };
package/loop.js ADDED
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { readAttempts, writeAttempts, cleanup } = require('./state');
5
+
6
+ const DEFAULT_MAX_ATTEMPTS = 3;
7
+
8
+ /*
9
+ * 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
11
+ * Claude is handed the failures and keeps working.
12
+ *
13
+ * The loop is capped so it can't run forever. Hitting the cap is not success —
14
+ * `onCap` gets the last report so the caller can say what it's giving up on
15
+ * (and, if it has a safe mechanical fallback, apply it) before the stop is
16
+ * allowed through.
17
+ *
18
+ * Every exit path either cleans up its state or persists an incremented
19
+ * attempt count; leaving both undone would strand the session in a loop that
20
+ * re-runs the same check with the same counter forever.
21
+ *
22
+ * Callers supply:
23
+ * files — absolute paths to check (already filtered to their scope)
24
+ * fix — optional, mechanically fixes what it can before checking
25
+ * check — returns { failed, report }
26
+ * onCap — optional, called instead of blocking once attempts run out
27
+ * buildMessage— returns { stopReason?, systemMessage } for the block payload
28
+ *
29
+ * Returns { blocked, reason } describing which path was taken, so hooks and
30
+ * their tests can assert on the outcome rather than on stdout alone.
31
+ */
32
+ function runFixLoop({
33
+ input,
34
+ dir,
35
+ stateFiles,
36
+ attemptsFile = path.join(dir, 'attempts.json'),
37
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
38
+ files,
39
+ fix,
40
+ check,
41
+ onCap,
42
+ buildMessage,
43
+ write = (text) => process.stdout.write(text),
44
+ }) {
45
+ if (!input) return { blocked: false, reason: 'no-input' };
46
+
47
+ if (!files || files.length === 0) {
48
+ cleanup(dir, stateFiles);
49
+ return { blocked: false, reason: 'no-files' };
50
+ }
51
+
52
+ const attempts = readAttempts(attemptsFile);
53
+
54
+ let outcome;
55
+ try {
56
+ if (fix) fix(files);
57
+ outcome = check(files);
58
+ } catch (error) {
59
+ /*
60
+ * A check that can't run at all is not a check that failed. Leave the
61
+ * state in place so the next stop retries, and let the caller report it —
62
+ * blocking on a broken toolchain would trap the session.
63
+ */
64
+ return { blocked: false, reason: 'error', error };
65
+ }
66
+
67
+ if (!outcome.failed) {
68
+ cleanup(dir, stateFiles);
69
+ return { blocked: false, reason: 'clean' };
70
+ }
71
+
72
+ if (attempts >= maxAttempts) {
73
+ if (onCap) onCap({ files, outcome, maxAttempts });
74
+ cleanup(dir, stateFiles);
75
+ return { blocked: false, reason: 'cap', outcome };
76
+ }
77
+
78
+ const attempt = attempts + 1;
79
+ writeAttempts(attemptsFile, attempt);
80
+
81
+ const { stopReason, systemMessage } = buildMessage({ attempt, maxAttempts, outcome, files });
82
+
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
+ );
93
+
94
+ return { blocked: true, reason: 'blocked', attempt, outcome };
95
+ }
96
+
97
+ module.exports = { runFixLoop, DEFAULT_MAX_ATTEMPTS };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@leonardo-marziali/ad-lfl-kit",
3
+ "version": "0.0.0-development",
4
+ "description": "Agentic development lint-fix-loop kit: session-scoped file tracking and a capped Stop-hook fix loop for Claude Code plugins.",
5
+ "keywords": [
6
+ "claude-code",
7
+ "claude-code-plugin",
8
+ "hooks",
9
+ "lint",
10
+ "fix-loop"
11
+ ],
12
+ "author": {
13
+ "name": "Leonardo Marziali",
14
+ "url": "https://github.com/leonardo-marziali"
15
+ },
16
+ "homepage": "https://github.com/leonardo-marziali/agentic-development-kit",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/leonardo-marziali/agentic-development-kit.git",
20
+ "directory": "packages/ad-lfl-kit"
21
+ },
22
+ "license": "Apache-2.0",
23
+ "type": "commonjs",
24
+ "main": "index.js",
25
+ "files": [
26
+ "index.js",
27
+ "exec.js",
28
+ "hook-input.js",
29
+ "state.js",
30
+ "track.js",
31
+ "loop.js"
32
+ ],
33
+ "engines": {
34
+ "node": ">=24.20.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "test": "node --test \"__tests__/*.test.js\"",
41
+ "test:coverage": "mkdir -p coverage && node --test --experimental-test-coverage --test-coverage-exclude=\"**/__tests__/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info \"__tests__/*.test.js\""
42
+ }
43
+ }
package/state.js ADDED
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+
7
+ /*
8
+ * Session-scoped directory for a hook's own bookkeeping.
9
+ *
10
+ * Prefers the plugin's own data dir, which persists across plugin updates;
11
+ * falls back to tmp so hooks still work when CLAUDE_PLUGIN_DATA isn't set
12
+ * (a repo-local hook, or an older Claude Code). The namespace keeps two
13
+ * plugins' state apart under a shared base.
14
+ */
15
+ function stateDir({ sessionId, namespace }) {
16
+ const base = process.env.CLAUDE_PLUGIN_DATA
17
+ ? path.join(process.env.CLAUDE_PLUGIN_DATA, namespace)
18
+ : path.join(os.tmpdir(), namespace);
19
+ return path.join(base, sessionId || 'unknown-session');
20
+ }
21
+
22
+ /*
23
+ * The tracked-file list is append-only newline-delimited text. Entries can go
24
+ * stale within a session — a file gets tracked, then deleted or renamed — so
25
+ * filter to what still exists on every read rather than trusting the list.
26
+ */
27
+ function readTrackedFiles(listFile) {
28
+ if (!fs.existsSync(listFile)) return [];
29
+ return [...new Set(fs.readFileSync(listFile, 'utf8').split('\n').filter(Boolean))].filter(
30
+ (file) => fs.existsSync(file),
31
+ );
32
+ }
33
+
34
+ function appendTrackedFile(listFile, absPath) {
35
+ const existing = fs.existsSync(listFile)
36
+ ? fs.readFileSync(listFile, 'utf8').split('\n').filter(Boolean)
37
+ : [];
38
+ if (!existing.includes(absPath)) {
39
+ fs.appendFileSync(listFile, absPath + '\n');
40
+ }
41
+ }
42
+
43
+ function readAttempts(attemptsFile) {
44
+ if (!fs.existsSync(attemptsFile)) return 0;
45
+ try {
46
+ return JSON.parse(fs.readFileSync(attemptsFile, 'utf8')).count || 0;
47
+ } catch {
48
+ return 0;
49
+ }
50
+ }
51
+
52
+ function writeAttempts(attemptsFile, count) {
53
+ fs.mkdirSync(path.dirname(attemptsFile), { recursive: true });
54
+ fs.writeFileSync(attemptsFile, JSON.stringify({ count }));
55
+ }
56
+
57
+ /*
58
+ * Removes only the files this hook owns, then drops the directory if that
59
+ * leaves it empty — a blind recursive rmSync would also wipe state another
60
+ * hook in the same session still has pending.
61
+ */
62
+ function cleanup(dir, ownStateFiles) {
63
+ for (const name of ownStateFiles) {
64
+ try {
65
+ fs.rmSync(path.join(dir, name), { force: true });
66
+ } catch {
67
+ // best effort
68
+ }
69
+ }
70
+ try {
71
+ fs.rmdirSync(dir);
72
+ } catch {
73
+ // not empty (another hook's state still pending) or already gone
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ stateDir,
79
+ readTrackedFiles,
80
+ appendTrackedFile,
81
+ readAttempts,
82
+ writeAttempts,
83
+ cleanup,
84
+ };
package/track.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { appendTrackedFile } = require('./state');
6
+
7
+ /*
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.
16
+ */
17
+ function trackTouchedFile({ input, listFile, matches }) {
18
+ if (!input) return null;
19
+ if (!/^(Edit|Write)$/.test(input.tool_name || '')) return null;
20
+
21
+ const filePath = input.tool_input?.file_path;
22
+ if (!filePath) return null;
23
+
24
+ /*
25
+ * Tool inputs may carry a relative path; it resolves against the session's
26
+ * cwd, not this hook process's, which can differ.
27
+ */
28
+ const cwd = input.cwd || process.cwd();
29
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
30
+
31
+ if (!fs.existsSync(absPath)) return null;
32
+ if (typeof matches === 'function' && !matches(absPath)) return null;
33
+
34
+ fs.mkdirSync(path.dirname(listFile), { recursive: true });
35
+ appendTrackedFile(listFile, absPath);
36
+ return absPath;
37
+ }
38
+
39
+ module.exports = { trackTouchedFile };