@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 +69 -0
- package/hook-input.js +31 -0
- package/index.js +15 -0
- package/loop.js +97 -0
- package/package.json +43 -0
- package/state.js +84 -0
- package/track.js +39 -0
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 };
|