@slamb2k/mad-skills 2.0.10 → 2.0.12
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/.claude-plugin/plugin.json +1 -1
- package/hooks/hooks.json +7 -22
- package/hooks/lib/banner.js +27 -0
- package/hooks/lib/config.js +39 -0
- package/hooks/lib/git-checks.js +118 -0
- package/hooks/lib/output.js +47 -0
- package/hooks/lib/staleness.js +154 -0
- package/hooks/lib/state.js +52 -0
- package/hooks/lib/task-checks.js +53 -0
- package/hooks/lib/utils.js +90 -0
- package/hooks/session-guard.js +167 -0
- package/hooks/session-guard.sh +9 -10
- package/package.json +1 -1
- package/skills/manifest.json +1 -1
package/hooks/hooks.json
CHANGED
|
@@ -1,26 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
-
"SessionStart": [
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
],
|
|
14
|
-
"UserPromptSubmit": [
|
|
15
|
-
{
|
|
16
|
-
"hooks": [
|
|
17
|
-
{
|
|
18
|
-
"type": "command",
|
|
19
|
-
"command": "./hooks/session-guard-prompt.sh",
|
|
20
|
-
"timeout": 10000
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
}
|
|
24
|
-
]
|
|
3
|
+
"SessionStart": [{
|
|
4
|
+
"matcher": "startup|clear|compact",
|
|
5
|
+
"hooks": [{ "type": "command", "command": "node \"$HOME/.claude/hooks/session-guard.js\" check", "timeout": 30 }]
|
|
6
|
+
}],
|
|
7
|
+
"UserPromptSubmit": [{
|
|
8
|
+
"hooks": [{ "type": "command", "command": "node \"$HOME/.claude/hooks/session-guard.js\" remind", "timeout": 10 }]
|
|
9
|
+
}]
|
|
25
10
|
}
|
|
26
11
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('./config');
|
|
4
|
+
|
|
5
|
+
// prettier-ignore
|
|
6
|
+
const BANNER_LINES = [
|
|
7
|
+
'M"""""`\'"""`YM dP MP""""""`MM dP oo dP dP',
|
|
8
|
+
'M mm. mm. M 88 M mmmmm..M 88 88 88',
|
|
9
|
+
'M MMM MMM M .d8888b. .d888b88 M. `YM 88 .dP dP 88 88 .d8888b.',
|
|
10
|
+
'M MMM MMM M 88\' `88 88\' `88 MMMMMMM. M 88888" 88 88 88 Y8ooooo.',
|
|
11
|
+
'M MMM MMM M 88. .88 88. .88 M. .MMM\' M 88 `8b. 88 88 88 88',
|
|
12
|
+
'M MMM MMM M `88888P8 `88888P8 Mb. .dM dP `YP dP dP dP `88888P\'',
|
|
13
|
+
'MMMMMMMMMMMMMM MMMMMMMMMMM',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const SEPARATOR = '\u2500'.repeat(70);
|
|
17
|
+
|
|
18
|
+
function getBanner() {
|
|
19
|
+
return [
|
|
20
|
+
...BANNER_LINES,
|
|
21
|
+
SEPARATOR,
|
|
22
|
+
` Session Guard v${config.version}`,
|
|
23
|
+
SEPARATOR,
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { getBanner, BANNER_MARKER: 'MMMMMMMMMMMMMM' };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
version: '1.0.0',
|
|
5
|
+
|
|
6
|
+
staleness: {
|
|
7
|
+
threshold: 3,
|
|
8
|
+
age: { warn: 7, critical: 14 },
|
|
9
|
+
commits: { warn: 20, critical: 50 },
|
|
10
|
+
depDrift: { minor: 0, major: 5 },
|
|
11
|
+
topLevelFiles: 3,
|
|
12
|
+
lockFileDays: 7,
|
|
13
|
+
undocumentedDeps: 5,
|
|
14
|
+
missingDirs: { few: 0, many: 2 },
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
monorepo: {
|
|
18
|
+
markers: [
|
|
19
|
+
'pnpm-workspace.yaml', 'lerna.json', 'nx.json',
|
|
20
|
+
'turbo.json', 'rush.json',
|
|
21
|
+
],
|
|
22
|
+
dirs: ['packages', 'apps', 'services', 'libs', 'modules', 'projects'],
|
|
23
|
+
minSignals: 2,
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
configFiles: [
|
|
27
|
+
'tsconfig.json', '.env.example', 'docker-compose.yml',
|
|
28
|
+
'Dockerfile', 'Makefile', 'Cargo.toml', 'go.mod',
|
|
29
|
+
],
|
|
30
|
+
|
|
31
|
+
lockFiles: [
|
|
32
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
33
|
+
'bun.lock', 'Cargo.lock', 'poetry.lock', 'uv.lock',
|
|
34
|
+
],
|
|
35
|
+
|
|
36
|
+
pythonFiles: ['pyproject.toml', 'requirements.txt', 'setup.py'],
|
|
37
|
+
|
|
38
|
+
taskList: { minCommits: 20, minFiles: 30 },
|
|
39
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { existsSync } = require('fs');
|
|
4
|
+
const { join, dirname, basename } = require('path');
|
|
5
|
+
const config = require('./config');
|
|
6
|
+
const { git, readJson, countFiles } = require('./utils');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validate git repository state.
|
|
10
|
+
* Returns { gitRoot: string|null }.
|
|
11
|
+
*/
|
|
12
|
+
function checkGit(projectDir, output) {
|
|
13
|
+
const gitRoot = git('rev-parse --show-toplevel', projectDir);
|
|
14
|
+
|
|
15
|
+
if (gitRoot === null) {
|
|
16
|
+
output.add('[SESSION GUARD] \u26A0\uFE0F This directory is NOT tracked by Git.');
|
|
17
|
+
output.addQuestion(
|
|
18
|
+
'This directory isn\'t inside a Git repository. What would you like to do?',
|
|
19
|
+
'single_select',
|
|
20
|
+
[
|
|
21
|
+
'"Initialise Git" \u2014 run `git init` and suggest creating .gitignore',
|
|
22
|
+
'"Skip" \u2014 continue without version control',
|
|
23
|
+
],
|
|
24
|
+
);
|
|
25
|
+
return { gitRoot: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (gitRoot !== projectDir) {
|
|
29
|
+
checkNestedGit(projectDir, gitRoot, output);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { gitRoot };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkNestedGit(projectDir, gitRoot, output) {
|
|
36
|
+
// Calculate depth
|
|
37
|
+
let depth = 0;
|
|
38
|
+
let check = projectDir;
|
|
39
|
+
while (check !== gitRoot && check !== '/') {
|
|
40
|
+
check = dirname(check);
|
|
41
|
+
depth++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const signals = detectMonorepo(gitRoot);
|
|
45
|
+
const relative = projectDir.slice(gitRoot.length + 1);
|
|
46
|
+
|
|
47
|
+
if (signals.length >= config.monorepo.minSignals) {
|
|
48
|
+
output.add(`[SESSION GUARD] \u2139\uFE0F Git root is ${depth} level(s) above CWD.`);
|
|
49
|
+
output.add(` Git root: ${gitRoot}`);
|
|
50
|
+
output.add(` Working dir: ${projectDir}`);
|
|
51
|
+
output.add(` Monorepo signals: ${signals.join(', ')}`);
|
|
52
|
+
output.addQuestion(
|
|
53
|
+
`Git root is at \`${gitRoot}\` (monorepo). Working in \`${relative}\`. Correct context?`,
|
|
54
|
+
'single_select',
|
|
55
|
+
['"Yes, correct package"', '"No, switch to repo root"'],
|
|
56
|
+
'low',
|
|
57
|
+
);
|
|
58
|
+
} else {
|
|
59
|
+
const fileCount = countFiles(gitRoot);
|
|
60
|
+
output.add(`[SESSION GUARD] \u26A0\uFE0F Git root is ${depth} level(s) above CWD \u2014 does NOT look like a monorepo.`);
|
|
61
|
+
output.add(` Git root: ${gitRoot} (${fileCount} files)`);
|
|
62
|
+
output.add(` Working dir: ${projectDir}`);
|
|
63
|
+
if (signals.length > 0) {
|
|
64
|
+
output.add(` Weak signals: ${signals.join(', ')}`);
|
|
65
|
+
}
|
|
66
|
+
output.addQuestion(
|
|
67
|
+
`Git root is at \`${gitRoot}\` (${depth} levels up), which doesn't look like a monorepo. May have been created accidentally.`,
|
|
68
|
+
'single_select',
|
|
69
|
+
[
|
|
70
|
+
'"It\'s correct" \u2014 continue normally',
|
|
71
|
+
'"Initialise here instead" \u2014 run `git init` here (warn ancestor .git still exists)',
|
|
72
|
+
'"Investigate" \u2014 list git root contents and recent commits',
|
|
73
|
+
],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function detectMonorepo(gitRoot) {
|
|
79
|
+
const signals = [];
|
|
80
|
+
|
|
81
|
+
// Check workspaces in package.json
|
|
82
|
+
const pkg = readJson(join(gitRoot, 'package.json'));
|
|
83
|
+
if (pkg && pkg.workspaces) {
|
|
84
|
+
signals.push('package.json has \'workspaces\' field');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check marker files
|
|
88
|
+
for (const marker of config.monorepo.markers) {
|
|
89
|
+
if (existsSync(join(gitRoot, marker))) {
|
|
90
|
+
signals.push(`${marker} exists`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check common monorepo directories
|
|
95
|
+
for (const dir of config.monorepo.dirs) {
|
|
96
|
+
if (existsSync(join(gitRoot, dir))) {
|
|
97
|
+
signals.push(`'${dir}/' directory exists at git root`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Count package.json files (crude monorepo signal)
|
|
102
|
+
const pkgCount = git('ls-files --cached --others --exclude-standard -- "*/package.json" "package.json"', gitRoot);
|
|
103
|
+
if (pkgCount) {
|
|
104
|
+
const count = pkgCount.split('\n').filter(Boolean).length;
|
|
105
|
+
if (count > 2) signals.push(`${count} package.json files found`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Count CLAUDE.md files
|
|
109
|
+
const claudeCount = git('ls-files --cached -- "*/CLAUDE.md" "CLAUDE.md"', gitRoot);
|
|
110
|
+
if (claudeCount) {
|
|
111
|
+
const count = claudeCount.split('\n').filter(Boolean).length;
|
|
112
|
+
if (count > 1) signals.push(`${count} CLAUDE.md files found (per-package setup)`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return signals;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { checkGit };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class OutputBuilder {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.parts = [];
|
|
6
|
+
this.signals = [];
|
|
7
|
+
this.score = 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
add(line) {
|
|
11
|
+
this.parts.push(line);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
blank() {
|
|
15
|
+
this.parts.push('');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
addStaleness(message, weight = 1) {
|
|
19
|
+
this.signals.push(`\u26A0 ${message}`);
|
|
20
|
+
this.score += weight;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
addQuestion(question, type, options, priority) {
|
|
24
|
+
this.blank();
|
|
25
|
+
if (priority === 'low') {
|
|
26
|
+
this.add('Use AskUserQuestion (low priority, don\'t block):');
|
|
27
|
+
} else {
|
|
28
|
+
this.add('Use AskUserQuestion to prompt:');
|
|
29
|
+
}
|
|
30
|
+
this.add(` Question: "${question}"`);
|
|
31
|
+
this.add(` Type: ${type}`);
|
|
32
|
+
this.add(' Options:');
|
|
33
|
+
options.forEach((opt, i) => this.add(` ${i + 1}. ${opt}`));
|
|
34
|
+
this.blank();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
toJson(hookEvent = 'SessionStart') {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
hookSpecificOutput: {
|
|
40
|
+
hookEventName: hookEvent,
|
|
41
|
+
additionalContext: this.parts.join('\n'),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { OutputBuilder };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { existsSync } = require('fs');
|
|
4
|
+
const { join } = require('path');
|
|
5
|
+
const config = require('./config');
|
|
6
|
+
const { fileMtime, git, readJson, readText, getDirectories } = require('./utils');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Evaluate all staleness signals for CLAUDE.md.
|
|
10
|
+
* Mutates output.addStaleness() with weighted signals.
|
|
11
|
+
*/
|
|
12
|
+
function checkStaleness(projectDir, claudeMdPath, gitRoot, output) {
|
|
13
|
+
const now = Math.floor(Date.now() / 1000);
|
|
14
|
+
const mdMtime = fileMtime(claudeMdPath);
|
|
15
|
+
const mdAgeDays = Math.floor((now - mdMtime) / 86400);
|
|
16
|
+
|
|
17
|
+
checkAge(mdAgeDays, output);
|
|
18
|
+
checkDirectoryDrift(projectDir, claudeMdPath, output);
|
|
19
|
+
checkPackageJson(projectDir, claudeMdPath, mdMtime, output);
|
|
20
|
+
checkPythonDeps(projectDir, mdMtime, output);
|
|
21
|
+
checkConfigFiles(projectDir, mdMtime, output);
|
|
22
|
+
checkGitActivity(projectDir, gitRoot, mdMtime, output);
|
|
23
|
+
checkLockFiles(projectDir, mdMtime, output);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Individual checks ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function checkAge(ageDays, output) {
|
|
29
|
+
const { warn, critical } = config.staleness.age;
|
|
30
|
+
if (ageDays > critical) {
|
|
31
|
+
output.addStaleness(`CLAUDE.md last modified ${ageDays} days ago`, 2);
|
|
32
|
+
} else if (ageDays > warn) {
|
|
33
|
+
output.addStaleness(`CLAUDE.md last modified ${ageDays} days ago`, 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkDirectoryDrift(projectDir, claudeMdPath, output) {
|
|
38
|
+
const dirs = getDirectories(projectDir);
|
|
39
|
+
if (dirs.length === 0) return;
|
|
40
|
+
|
|
41
|
+
const claudeMd = readText(claudeMdPath);
|
|
42
|
+
if (!claudeMd) return;
|
|
43
|
+
|
|
44
|
+
const mdLower = claudeMd.toLowerCase();
|
|
45
|
+
const missing = dirs.filter(d => !mdLower.includes(d.toLowerCase()));
|
|
46
|
+
|
|
47
|
+
if (missing.length > config.staleness.missingDirs.many) {
|
|
48
|
+
output.addStaleness(`Directories not in CLAUDE.md: ${missing.join(' ')}`, 2);
|
|
49
|
+
} else if (missing.length > config.staleness.missingDirs.few) {
|
|
50
|
+
output.addStaleness(`Directories not in CLAUDE.md: ${missing.join(' ')}`, 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function checkPackageJson(projectDir, claudeMdPath, mdMtime, output) {
|
|
55
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
56
|
+
if (!existsSync(pkgPath)) return;
|
|
57
|
+
|
|
58
|
+
const pkgMtime = fileMtime(pkgPath);
|
|
59
|
+
if (pkgMtime > mdMtime) {
|
|
60
|
+
const delta = Math.floor((pkgMtime - mdMtime) / 86400);
|
|
61
|
+
output.addStaleness(`package.json modified ${delta} day(s) after CLAUDE.md`, 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pkg = readJson(pkgPath);
|
|
65
|
+
if (!pkg) return;
|
|
66
|
+
|
|
67
|
+
const depCount = Object.keys(pkg.dependencies || {}).length
|
|
68
|
+
+ Object.keys(pkg.devDependencies || {}).length;
|
|
69
|
+
|
|
70
|
+
const claudeMd = readText(claudeMdPath) || '';
|
|
71
|
+
const documented = claudeMd.match(/(\d+)\s*(dependencies|deps)/i);
|
|
72
|
+
if (documented) {
|
|
73
|
+
const docCount = parseInt(documented[1], 10);
|
|
74
|
+
const drift = Math.abs(depCount - docCount);
|
|
75
|
+
if (drift > config.staleness.depDrift.major) {
|
|
76
|
+
output.addStaleness(`Dep count drift: CLAUDE.md ~${docCount}, actual ${depCount} (\u0394${depCount - docCount})`, 2);
|
|
77
|
+
} else if (drift > config.staleness.depDrift.minor) {
|
|
78
|
+
output.addStaleness(`Dep count drift: CLAUDE.md ~${docCount}, actual ${depCount}`, 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check for undocumented production deps
|
|
83
|
+
const prodDeps = Object.keys(pkg.dependencies || {});
|
|
84
|
+
const mdLower = claudeMd.toLowerCase();
|
|
85
|
+
const undocumented = prodDeps.filter(d => !mdLower.includes(d.toLowerCase()));
|
|
86
|
+
if (undocumented.length > config.staleness.undocumentedDeps) {
|
|
87
|
+
output.addStaleness(
|
|
88
|
+
`${undocumented.length} production deps not in CLAUDE.md (e.g. ${undocumented.slice(0, 5).join(', ')})`,
|
|
89
|
+
2,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function checkPythonDeps(projectDir, mdMtime, output) {
|
|
95
|
+
for (const file of config.pythonFiles) {
|
|
96
|
+
const path = join(projectDir, file);
|
|
97
|
+
if (!existsSync(path)) continue;
|
|
98
|
+
if (fileMtime(path) > mdMtime) {
|
|
99
|
+
output.addStaleness(`${file} modified after CLAUDE.md`, 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function checkConfigFiles(projectDir, mdMtime, output) {
|
|
105
|
+
for (const file of config.configFiles) {
|
|
106
|
+
const path = join(projectDir, file);
|
|
107
|
+
if (!existsSync(path)) continue;
|
|
108
|
+
if (fileMtime(path) > mdMtime) {
|
|
109
|
+
output.addStaleness(`${file} modified after CLAUDE.md`, 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkGitActivity(projectDir, gitRoot, mdMtime, output) {
|
|
115
|
+
if (!gitRoot) return;
|
|
116
|
+
|
|
117
|
+
// Convert epoch to ISO date for git --since
|
|
118
|
+
const mdDate = new Date(mdMtime * 1000).toISOString();
|
|
119
|
+
|
|
120
|
+
const commitsSince = parseInt(
|
|
121
|
+
git(`rev-list --count --since="${mdDate}" HEAD`, projectDir) || '0',
|
|
122
|
+
10,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (commitsSince > config.staleness.commits.critical) {
|
|
126
|
+
output.addStaleness(`${commitsSince} commits since CLAUDE.md updated`, 2);
|
|
127
|
+
} else if (commitsSince > config.staleness.commits.warn) {
|
|
128
|
+
output.addStaleness(`${commitsSince} commits since CLAUDE.md updated`, 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for top-level file churn
|
|
132
|
+
const changed = git('diff --name-only --diff-filter=AD HEAD~20..HEAD', projectDir);
|
|
133
|
+
if (changed) {
|
|
134
|
+
const topLevel = changed.split('\n')
|
|
135
|
+
.filter(f => f && !f.includes('/') && !f.startsWith('.'));
|
|
136
|
+
if (topLevel.length > config.staleness.topLevelFiles) {
|
|
137
|
+
output.addStaleness(`${topLevel.length} top-level files added/removed recently`, 1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkLockFiles(projectDir, mdMtime, output) {
|
|
143
|
+
for (const file of config.lockFiles) {
|
|
144
|
+
const path = join(projectDir, file);
|
|
145
|
+
if (!existsSync(path)) continue;
|
|
146
|
+
const delta = Math.floor((fileMtime(path) - mdMtime) / 86400);
|
|
147
|
+
if (delta > config.staleness.lockFileDays) {
|
|
148
|
+
output.addStaleness(`${file} is ${delta} days newer than CLAUDE.md`, 1);
|
|
149
|
+
return; // Only flag once
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { checkStaleness };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } = require('fs');
|
|
4
|
+
const { join } = require('path');
|
|
5
|
+
const { createHash } = require('crypto');
|
|
6
|
+
const { homedir } = require('os');
|
|
7
|
+
|
|
8
|
+
const STATE_DIR = join(homedir(), '.claude', 'session-guard');
|
|
9
|
+
|
|
10
|
+
function ensureDir() {
|
|
11
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function projectKey(projectDir) {
|
|
15
|
+
return createHash('md5').update(projectDir).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function statePath(projectDir) {
|
|
19
|
+
return join(STATE_DIR, `${projectKey(projectDir)}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function save(projectDir, data) {
|
|
23
|
+
ensureDir();
|
|
24
|
+
writeFileSync(statePath(projectDir), JSON.stringify({
|
|
25
|
+
...data,
|
|
26
|
+
projectDir,
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
}, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function load(projectDir) {
|
|
32
|
+
const path = statePath(projectDir);
|
|
33
|
+
if (!existsSync(path)) return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clear(projectDir) {
|
|
42
|
+
try { unlinkSync(statePath(projectDir)); } catch { /* noop */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Dedup: true if check ran within the last `seconds`. */
|
|
46
|
+
function isRecentlyChecked(projectDir, seconds = 5) {
|
|
47
|
+
const data = load(projectDir);
|
|
48
|
+
if (!data) return false;
|
|
49
|
+
return (Date.now() - data.timestamp) < seconds * 1000;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { save, load, clear, isRecentlyChecked };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { existsSync } = require('fs');
|
|
4
|
+
const { join, basename } = require('path');
|
|
5
|
+
const { homedir } = require('os');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
const { readJson, git } = require('./utils');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if a persistent Task List ID is configured.
|
|
11
|
+
*/
|
|
12
|
+
function checkTaskList(projectDir, gitRoot, output) {
|
|
13
|
+
if (isTaskListConfigured(projectDir)) return;
|
|
14
|
+
if (!gitRoot) return;
|
|
15
|
+
|
|
16
|
+
const commitCount = parseInt(git('rev-list --count HEAD', projectDir) || '0', 10);
|
|
17
|
+
const fileOutput = git('ls-files', projectDir);
|
|
18
|
+
const fileCount = fileOutput ? fileOutput.split('\n').filter(Boolean).length : 0;
|
|
19
|
+
|
|
20
|
+
if (commitCount <= config.taskList.minCommits && fileCount <= config.taskList.minFiles) return;
|
|
21
|
+
|
|
22
|
+
const repoName = basename(gitRoot);
|
|
23
|
+
output.add('[SESSION GUARD] \u2139\uFE0F No persistent Task List ID configured.');
|
|
24
|
+
output.add(` Project: ${commitCount} commits, ${fileCount} tracked files.`);
|
|
25
|
+
output.addQuestion(
|
|
26
|
+
'No persistent Task List ID configured. For a project this size, tasks won\'t survive across sessions. Add one?',
|
|
27
|
+
'single_select',
|
|
28
|
+
[
|
|
29
|
+
`"Yes" \u2014 add {"env": {"CLAUDE_CODE_TASK_LIST_ID": "${repoName}"}} to .claude/settings.json`,
|
|
30
|
+
'"Skip" \u2014 continue without persistent tasks',
|
|
31
|
+
],
|
|
32
|
+
'low',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isTaskListConfigured(projectDir) {
|
|
37
|
+
if (process.env.CLAUDE_CODE_TASK_LIST_ID) return true;
|
|
38
|
+
|
|
39
|
+
const candidates = [
|
|
40
|
+
join(projectDir, '.claude', 'settings.json'),
|
|
41
|
+
join(homedir(), '.claude', 'settings.json'),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const path of candidates) {
|
|
45
|
+
if (!existsSync(path)) continue;
|
|
46
|
+
const settings = readJson(path);
|
|
47
|
+
if (settings?.env?.CLAUDE_CODE_TASK_LIST_ID) return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { checkTaskList };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { statSync, existsSync, readFileSync, readdirSync } = require('fs');
|
|
5
|
+
const { join } = require('path');
|
|
6
|
+
|
|
7
|
+
const IGNORE_DIRS = new Set([
|
|
8
|
+
'node_modules', '.git', '__pycache__', '.venv', 'venv',
|
|
9
|
+
'dist', 'build', '.next', '.nuxt', 'coverage', '.claude',
|
|
10
|
+
'.idea', '.vscode', 'target', '.cache', '.turbo',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/** Epoch seconds of file mtime — falls back to now on error. */
|
|
14
|
+
function fileMtime(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return Math.floor(statSync(filePath).mtimeMs / 1000);
|
|
17
|
+
} catch {
|
|
18
|
+
return Math.floor(Date.now() / 1000);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Run a git command, return trimmed stdout or null on failure. */
|
|
23
|
+
function git(args, cwd) {
|
|
24
|
+
try {
|
|
25
|
+
return execSync(`git ${args}`, {
|
|
26
|
+
cwd,
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
29
|
+
timeout: 10000,
|
|
30
|
+
}).trim();
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse a JSON file, return null on failure. */
|
|
37
|
+
function readJson(filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read file as string, return null on failure. */
|
|
46
|
+
function readText(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(filePath, 'utf-8');
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* List directories up to maxDepth (replaces `tree -L 2 -d`).
|
|
56
|
+
* Returns flat array of relative dir names.
|
|
57
|
+
*/
|
|
58
|
+
function getDirectories(dir, maxDepth = 2, _depth = 0) {
|
|
59
|
+
if (_depth >= maxDepth) return [];
|
|
60
|
+
const dirs = [];
|
|
61
|
+
try {
|
|
62
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
63
|
+
if (!entry.isDirectory()) continue;
|
|
64
|
+
if (entry.name.startsWith('.') || IGNORE_DIRS.has(entry.name)) continue;
|
|
65
|
+
dirs.push(entry.name);
|
|
66
|
+
if (_depth + 1 < maxDepth) {
|
|
67
|
+
for (const sub of getDirectories(join(dir, entry.name), maxDepth, _depth + 1)) {
|
|
68
|
+
dirs.push(`${entry.name}/${sub}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch { /* directory not readable */ }
|
|
73
|
+
return dirs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Count files matching a glob pattern in a directory (non-recursive).
|
|
78
|
+
*/
|
|
79
|
+
function countFiles(dir, pattern) {
|
|
80
|
+
try {
|
|
81
|
+
return readdirSync(dir).filter(f => {
|
|
82
|
+
if (pattern) return f.match(pattern);
|
|
83
|
+
return true;
|
|
84
|
+
}).length;
|
|
85
|
+
} catch {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { fileMtime, git, readJson, readText, getDirectories, countFiles, existsSync };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Session Guard — Claude Code project health validation
|
|
6
|
+
*
|
|
7
|
+
* Replaces the shell-based session-guard.sh + session-guard-prompt.sh with a
|
|
8
|
+
* single Node.js entry point using subcommand dispatch (modelled on claude-mem's
|
|
9
|
+
* worker-service.cjs pattern).
|
|
10
|
+
*
|
|
11
|
+
* Subcommands:
|
|
12
|
+
* check — SessionStart: validate git, CLAUDE.md, tasks, staleness
|
|
13
|
+
* remind — UserPromptSubmit: re-emit pending context on first prompt
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node session-guard.js check
|
|
17
|
+
* node session-guard.js remind
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { existsSync } = require('fs');
|
|
21
|
+
const { join } = require('path');
|
|
22
|
+
|
|
23
|
+
const config = require('./lib/config');
|
|
24
|
+
const state = require('./lib/state');
|
|
25
|
+
const { OutputBuilder } = require('./lib/output');
|
|
26
|
+
const { getBanner, BANNER_MARKER } = require('./lib/banner');
|
|
27
|
+
const { checkGit } = require('./lib/git-checks');
|
|
28
|
+
const { checkTaskList } = require('./lib/task-checks');
|
|
29
|
+
const { checkStaleness } = require('./lib/staleness');
|
|
30
|
+
|
|
31
|
+
const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
32
|
+
const CLAUDE_MD = join(PROJECT_DIR, 'CLAUDE.md');
|
|
33
|
+
|
|
34
|
+
// ─── check ─────────────────────────────────────────────────────────────
|
|
35
|
+
// Runs at SessionStart. Validates project health and emits context.
|
|
36
|
+
|
|
37
|
+
function check() {
|
|
38
|
+
// Dedup: skip if recently checked (handles dual global+project registration)
|
|
39
|
+
if (state.isRecentlyChecked(PROJECT_DIR)) {
|
|
40
|
+
console.log(JSON.stringify({}));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const output = new OutputBuilder();
|
|
45
|
+
|
|
46
|
+
// Banner — shown once per session at start
|
|
47
|
+
output.add(getBanner());
|
|
48
|
+
output.blank();
|
|
49
|
+
|
|
50
|
+
// 0) Git repository validation
|
|
51
|
+
const { gitRoot } = checkGit(PROJECT_DIR, output);
|
|
52
|
+
|
|
53
|
+
// 1) CLAUDE.md existence
|
|
54
|
+
if (!existsSync(CLAUDE_MD)) {
|
|
55
|
+
output.add('[SESSION GUARD] \u26A0\uFE0F No CLAUDE.md found in project root.');
|
|
56
|
+
output.addQuestion(
|
|
57
|
+
'No CLAUDE.md found. Want me to set up this project for Claude Code?',
|
|
58
|
+
'single_select',
|
|
59
|
+
[
|
|
60
|
+
'"Initialise" \u2014 run `/init` to scaffold CLAUDE.md',
|
|
61
|
+
'"Skip" \u2014 continue without one',
|
|
62
|
+
],
|
|
63
|
+
);
|
|
64
|
+
emit(output);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
output.add(`[SESSION GUARD] \u2705 CLAUDE.md found in: ${PROJECT_DIR}`);
|
|
69
|
+
|
|
70
|
+
// 2) Task List ID
|
|
71
|
+
checkTaskList(PROJECT_DIR, gitRoot, output);
|
|
72
|
+
|
|
73
|
+
// 3) Staleness evaluation
|
|
74
|
+
checkStaleness(PROJECT_DIR, CLAUDE_MD, gitRoot, output);
|
|
75
|
+
|
|
76
|
+
// 4) Staleness summary
|
|
77
|
+
if (output.score >= config.staleness.threshold) {
|
|
78
|
+
output.blank();
|
|
79
|
+
output.add(`[SESSION GUARD] \u26A0\uFE0F CLAUDE.md appears STALE (score: ${output.score}/${config.staleness.threshold})`);
|
|
80
|
+
output.blank();
|
|
81
|
+
output.add('Signals:');
|
|
82
|
+
output.signals.forEach(sig => output.add(` ${sig}`));
|
|
83
|
+
output.addQuestion(
|
|
84
|
+
`CLAUDE.md appears out of date (${output.signals.length} signals detected). What would you like to do?`,
|
|
85
|
+
'single_select',
|
|
86
|
+
[
|
|
87
|
+
'"Update it" \u2014 review project structure, deps, recent changes and update CLAUDE.md (preserve user-written notes)',
|
|
88
|
+
'"Show signals" \u2014 list what\'s drifted before deciding',
|
|
89
|
+
'"Skip" \u2014 continue with current CLAUDE.md',
|
|
90
|
+
],
|
|
91
|
+
);
|
|
92
|
+
} else if (output.signals.length > 0) {
|
|
93
|
+
output.blank();
|
|
94
|
+
output.add(`[SESSION GUARD] \u2139\uFE0F Minor drift (score: ${output.score}/${config.staleness.threshold}) \u2014 not flagging:`);
|
|
95
|
+
output.signals.forEach(sig => output.add(` ${sig}`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
emit(output);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── remind ────────────────────────────────────────────────────────────
|
|
102
|
+
// Runs at UserPromptSubmit. Re-emits pending context from check, once.
|
|
103
|
+
|
|
104
|
+
function remind() {
|
|
105
|
+
const pending = state.load(PROJECT_DIR);
|
|
106
|
+
|
|
107
|
+
if (!pending || !pending.context) {
|
|
108
|
+
console.log(JSON.stringify({}));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
state.clear(PROJECT_DIR);
|
|
113
|
+
|
|
114
|
+
// Skip re-emit if no warnings were found
|
|
115
|
+
if (!pending.context.includes('\u26A0\uFE0F') && !pending.context.includes('\u2139\uFE0F')) {
|
|
116
|
+
console.log(JSON.stringify({}));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Strip banner from re-emission (already shown at SessionStart)
|
|
121
|
+
const lines = pending.context.split('\n');
|
|
122
|
+
const guardIdx = lines.findIndex(l => l.startsWith('[SESSION GUARD]'));
|
|
123
|
+
const body = guardIdx >= 0 ? lines.slice(guardIdx).join('\n') : pending.context;
|
|
124
|
+
|
|
125
|
+
const wrapped = [
|
|
126
|
+
'[SESSION GUARD \u2014 FIRST PROMPT REMINDER]',
|
|
127
|
+
'The following was detected at session start. Act on these items NOW using',
|
|
128
|
+
'AskUserQuestion BEFORE proceeding with the user\'s request.',
|
|
129
|
+
'',
|
|
130
|
+
body,
|
|
131
|
+
].join('\n');
|
|
132
|
+
|
|
133
|
+
console.log(JSON.stringify({
|
|
134
|
+
hookSpecificOutput: {
|
|
135
|
+
hookEventName: 'UserPromptSubmit',
|
|
136
|
+
additionalContext: wrapped,
|
|
137
|
+
},
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── helpers ───────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function emit(output) {
|
|
144
|
+
console.log(output.toJson());
|
|
145
|
+
state.save(PROJECT_DIR, {
|
|
146
|
+
context: output.parts.join('\n'),
|
|
147
|
+
score: output.score,
|
|
148
|
+
signals: output.signals,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── dispatch ──────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const command = process.argv[2];
|
|
155
|
+
|
|
156
|
+
switch (command) {
|
|
157
|
+
case 'check':
|
|
158
|
+
check();
|
|
159
|
+
break;
|
|
160
|
+
case 'remind':
|
|
161
|
+
remind();
|
|
162
|
+
break;
|
|
163
|
+
default:
|
|
164
|
+
console.error(`Session Guard v${config.version}`);
|
|
165
|
+
console.error('Usage: node session-guard.js <check|remind>');
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
package/hooks/session-guard.sh
CHANGED
|
@@ -188,10 +188,15 @@ SETTINGS_FILE="$PROJECT_DIR/.claude/settings.json"
|
|
|
188
188
|
[[ -n "${CLAUDE_CODE_TASK_LIST_ID:-}" ]] && TASK_LIST_CONFIGURED=true
|
|
189
189
|
|
|
190
190
|
if [[ "$TASK_LIST_CONFIGURED" == false ]] && command -v jq &>/dev/null; then
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
for CFG_FILE in "$SETTINGS_FILE" "$HOME/.claude/settings.json"; do
|
|
192
|
+
if [[ -f "$CFG_FILE" ]]; then
|
|
193
|
+
TASK_ID=$(jq -r '.env.CLAUDE_CODE_TASK_LIST_ID // empty' "$CFG_FILE" 2>/dev/null) || true
|
|
194
|
+
if [[ -n "$TASK_ID" ]]; then
|
|
195
|
+
TASK_LIST_CONFIGURED=true
|
|
196
|
+
break
|
|
197
|
+
fi
|
|
198
|
+
fi
|
|
199
|
+
done
|
|
195
200
|
fi
|
|
196
201
|
|
|
197
202
|
if [[ "$TASK_LIST_CONFIGURED" == false && -n "$GIT_ROOT" ]]; then
|
|
@@ -346,12 +351,6 @@ done
|
|
|
346
351
|
# ---------------------------------------------------------------------------
|
|
347
352
|
OUTPUT_PARTS=()
|
|
348
353
|
|
|
349
|
-
# --- Welcome banner ---------------------------------------------------------
|
|
350
|
-
SKILL_COUNT=$(find "$PROJECT_DIR/skills" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ') || SKILL_COUNT=0
|
|
351
|
-
if (( SKILL_COUNT > 0 )); then
|
|
352
|
-
OUTPUT_PARTS+=("[MAD SKILLS] Active — ${SKILL_COUNT} skills loaded")
|
|
353
|
-
fi
|
|
354
|
-
|
|
355
354
|
for part in "${EARLY_CONTEXT_PARTS[@]+"${EARLY_CONTEXT_PARTS[@]}"}"; do
|
|
356
355
|
OUTPUT_PARTS+=("$part")
|
|
357
356
|
done
|
package/package.json
CHANGED
package/skills/manifest.json
CHANGED