@slamb2k/mad-skills 2.0.11 → 2.0.13

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mad-skills",
3
3
  "description": "AI-assisted planning, development and governance tools",
4
- "version": "2.0.11",
4
+ "version": "2.0.13",
5
5
  "author": {
6
6
  "name": "slamb2k",
7
7
  "url": "https://github.com/slamb2k"
package/hooks/hooks.json CHANGED
@@ -1,26 +1,11 @@
1
1
  {
2
2
  "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "./hooks/session-guard.sh",
9
- "timeout": 30000
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 ./hooks/session-guard.cjs check", "timeout": 30 }]
6
+ }],
7
+ "UserPromptSubmit": [{
8
+ "hooks": [{ "type": "command", "command": "node ./hooks/session-guard.cjs remind", "timeout": 10 }]
9
+ }]
25
10
  }
26
11
  }
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ const config = require('./config.cjs');
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,56 @@
1
+ 'use strict';
2
+
3
+ const { readFileSync } = require('fs');
4
+ const { join, dirname } = require('path');
5
+
6
+ function getVersion() {
7
+ // Walk up from lib/ to find package.json (mad-skills plugin root)
8
+ const hooksDir = dirname(__dirname);
9
+ for (const candidate of [
10
+ join(hooksDir, '..', 'package.json'),
11
+ join(hooksDir, '..', '.claude-plugin', 'plugin.json'),
12
+ ]) {
13
+ try {
14
+ return JSON.parse(readFileSync(candidate, 'utf8')).version;
15
+ } catch {}
16
+ }
17
+ return '0.0.0-dev';
18
+ }
19
+
20
+ module.exports = {
21
+ version: getVersion(),
22
+
23
+ staleness: {
24
+ threshold: 3,
25
+ age: { warn: 7, critical: 14 },
26
+ commits: { warn: 20, critical: 50 },
27
+ depDrift: { minor: 0, major: 5 },
28
+ topLevelFiles: 3,
29
+ lockFileDays: 7,
30
+ undocumentedDeps: 5,
31
+ missingDirs: { few: 0, many: 2 },
32
+ },
33
+
34
+ monorepo: {
35
+ markers: [
36
+ 'pnpm-workspace.yaml', 'lerna.json', 'nx.json',
37
+ 'turbo.json', 'rush.json',
38
+ ],
39
+ dirs: ['packages', 'apps', 'services', 'libs', 'modules', 'projects'],
40
+ minSignals: 2,
41
+ },
42
+
43
+ configFiles: [
44
+ 'tsconfig.json', '.env.example', 'docker-compose.yml',
45
+ 'Dockerfile', 'Makefile', 'Cargo.toml', 'go.mod',
46
+ ],
47
+
48
+ lockFiles: [
49
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
50
+ 'bun.lock', 'Cargo.lock', 'poetry.lock', 'uv.lock',
51
+ ],
52
+
53
+ pythonFiles: ['pyproject.toml', 'requirements.txt', 'setup.py'],
54
+
55
+ taskList: { minCommits: 20, minFiles: 30 },
56
+ };
@@ -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.cjs');
6
+ const { git, readJson, countFiles } = require('./utils.cjs');
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.cjs');
6
+ const { fileMtime, git, readJson, readText, getDirectories } = require('./utils.cjs');
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.cjs');
7
+ const { readJson, git } = require('./utils.cjs');
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.cjs');
24
+ const state = require('./lib/state.cjs');
25
+ const { OutputBuilder } = require('./lib/output.cjs');
26
+ const { getBanner, BANNER_MARKER } = require('./lib/banner.cjs');
27
+ const { checkGit } = require('./lib/git-checks.cjs');
28
+ const { checkTaskList } = require('./lib/task-checks.cjs');
29
+ const { checkStaleness } = require('./lib/staleness.cjs');
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slamb2k/mad-skills",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "Claude Code skills collection — planning, development and governance tools",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "generated": "2026-03-08T22:56:33.887Z",
2
+ "generated": "2026-03-09T14:07:03.881Z",
3
3
  "count": 8,
4
4
  "skills": [
5
5
  {
@@ -1,54 +0,0 @@
1
- #!/usr/bin/env bash
2
- # session-guard-prompt.sh — UserPromptSubmit companion hook for session-guard
3
- #
4
- # Checks if session-guard.sh left a pending context file from SessionStart.
5
- # If found, re-emits the context as additionalContext on the first user prompt,
6
- # then deletes the flag file so it only fires once.
7
- #
8
- # This works around the known limitation where SessionStart hook output is
9
- # silently injected and may not surface until after the first prompt is
10
- # already processed (see anthropics/claude-code#10808).
11
- #
12
- # Install globally in ~/.claude/settings.json:
13
- # "command": "\"$HOME\"/.claude/hooks/session-guard-prompt.sh"
14
- #
15
- # Or per-project in .claude/settings.json:
16
- # "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-guard-prompt.sh"
17
-
18
- set -uo pipefail
19
-
20
- PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
21
- PENDING_DIR="${TMPDIR:-/tmp}/claude-session-guard"
22
- GUARD_KEY=$(echo "$PROJECT_DIR" | md5sum 2>/dev/null | cut -d' ' -f1 || echo "default")
23
- PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
24
-
25
- # No pending file — fast exit
26
- if [[ ! -f "$PENDING_FILE" ]]; then
27
- jq -n '{}'
28
- exit 0
29
- fi
30
-
31
- CONTEXT=$(cat "$PENDING_FILE")
32
- rm -f "$PENDING_FILE"
33
-
34
- # Also clean up the dedup lock from session-guard.sh
35
- rm -f "$PENDING_DIR/$GUARD_KEY.lock"
36
-
37
- # Skip re-emit if session-guard found no issues (just the ✅ line, no warnings)
38
- if [[ $(echo "$CONTEXT" | grep -c '⚠️\|ℹ️') -eq 0 ]]; then
39
- jq -n '{}'
40
- exit 0
41
- fi
42
-
43
- # Output context as plain stdout to avoid the cosmetic "error" label
44
- # that Claude Code renders when additionalContext is in the JSON output.
45
- # Plain stdout is picked up as hook output without the error severity.
46
- cat <<EOF
47
- [SESSION GUARD — FIRST PROMPT REMINDER]
48
- The following was detected at session start. Act on these items NOW using
49
- AskUserQuestion BEFORE proceeding with the user's request.
50
-
51
- $CONTEXT
52
- EOF
53
-
54
- exit 0
@@ -1,401 +0,0 @@
1
- #!/usr/bin/env bash
2
- # session-guard.sh — Claude Code SessionStart hook
3
- # Validates Git repo, CLAUDE.md existence/freshness, Task List ID config,
4
- # and checks for staleness.
5
- #
6
- # All user-facing questions instruct Claude to use the AskUserQuestion tool.
7
- #
8
- # Install globally in ~/.claude/settings.json:
9
- # "command": "\"$HOME\"/.claude/hooks/session-guard.sh"
10
- #
11
- # Or per-project in .claude/settings.json:
12
- # "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-guard.sh"
13
- #
14
- # TIMING NOTE: SessionStart hook output is silently injected and only surfaces
15
- # on the user's first prompt. Pair with session-guard-prompt.sh on
16
- # UserPromptSubmit for immediate feedback (see companion hook).
17
-
18
- # NOTE: We intentionally avoid `set -e` here. Many commands (grep, git, find,
19
- # jq, stat) return non-zero for perfectly normal reasons (no matches, not a
20
- # repo, missing files). Letting them fail gracefully is simpler and more
21
- # reliable than wrapping every line in `|| true`.
22
- set -uo pipefail
23
-
24
- # ---------------------------------------------------------------------------
25
- # Config
26
- # ---------------------------------------------------------------------------
27
- PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
28
- CLAUDE_MD="$PROJECT_DIR/CLAUDE.md"
29
- NOW=$(date +%s)
30
- STALENESS_THRESHOLD=3 # Accumulated score >= this triggers user prompt
31
- PENDING_DIR="${TMPDIR:-/tmp}/claude-session-guard"
32
-
33
- # ---------------------------------------------------------------------------
34
- # State
35
- # ---------------------------------------------------------------------------
36
- EARLY_CONTEXT_PARTS=()
37
- STALENESS_SIGNALS=()
38
- STALENESS_SCORE=0
39
-
40
- add_staleness() {
41
- local msg="$1"
42
- local weight="${2:-1}"
43
- STALENESS_SIGNALS+=("⚠ $msg")
44
- STALENESS_SCORE=$((STALENESS_SCORE + weight))
45
- }
46
-
47
- # Portable stat wrapper: returns mtime as epoch seconds
48
- file_mtime() {
49
- stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "$NOW"
50
- }
51
-
52
- # ---------------------------------------------------------------------------
53
- # Dedup: prevent double-firing if configured at both global and project level
54
- # ---------------------------------------------------------------------------
55
- mkdir -p "$PENDING_DIR"
56
- GUARD_KEY=$(echo "$PROJECT_DIR" | md5sum 2>/dev/null | cut -d' ' -f1 || echo "default")
57
- LOCK_FILE="$PENDING_DIR/$GUARD_KEY.lock"
58
-
59
- if [[ -f "$LOCK_FILE" ]]; then
60
- LOCK_AGE=$(( NOW - $(file_mtime "$LOCK_FILE") ))
61
- if (( LOCK_AGE < 5 )); then
62
- jq -n '{}'
63
- exit 0
64
- fi
65
- fi
66
- touch "$LOCK_FILE"
67
-
68
- # ---------------------------------------------------------------------------
69
- # 0) Git repository check
70
- # ---------------------------------------------------------------------------
71
- GIT_ROOT=""
72
-
73
- if command -v git &>/dev/null; then
74
- GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null) || true
75
-
76
- if [[ -z "$GIT_ROOT" ]]; then
77
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ This directory is NOT tracked by Git.")
78
- EARLY_CONTEXT_PARTS+=("")
79
- EARLY_CONTEXT_PARTS+=("Use AskUserQuestion to prompt:")
80
- EARLY_CONTEXT_PARTS+=(" Question: \"This directory isn't inside a Git repository. What would you like to do?\"")
81
- EARLY_CONTEXT_PARTS+=(" Type: single_select")
82
- EARLY_CONTEXT_PARTS+=(" Options:")
83
- EARLY_CONTEXT_PARTS+=(" 1. \"Initialise Git\" — run \`git init\` and suggest creating .gitignore")
84
- EARLY_CONTEXT_PARTS+=(" 2. \"Skip\" — continue without version control")
85
- EARLY_CONTEXT_PARTS+=("")
86
-
87
- elif [[ "$GIT_ROOT" != "$PROJECT_DIR" ]]; then
88
- DEPTH=0
89
- CHECK_DIR="$PROJECT_DIR"
90
- while [[ "$CHECK_DIR" != "$GIT_ROOT" && "$CHECK_DIR" != "/" ]]; do
91
- CHECK_DIR=$(dirname "$CHECK_DIR")
92
- DEPTH=$((DEPTH + 1))
93
- done
94
-
95
- MONOREPO_SIGNALS=()
96
-
97
- if [[ -f "$GIT_ROOT/package.json" ]] && command -v jq &>/dev/null; then
98
- jq -e '.workspaces // empty' "$GIT_ROOT/package.json" &>/dev/null && \
99
- MONOREPO_SIGNALS+=("package.json has 'workspaces' field")
100
- fi
101
- [[ -f "$GIT_ROOT/pnpm-workspace.yaml" ]] && MONOREPO_SIGNALS+=("pnpm-workspace.yaml exists")
102
- [[ -f "$GIT_ROOT/lerna.json" ]] && MONOREPO_SIGNALS+=("lerna.json exists")
103
- [[ -f "$GIT_ROOT/nx.json" ]] && MONOREPO_SIGNALS+=("nx.json exists")
104
- [[ -f "$GIT_ROOT/turbo.json" ]] && MONOREPO_SIGNALS+=("turbo.json exists")
105
- [[ -f "$GIT_ROOT/rush.json" ]] && MONOREPO_SIGNALS+=("rush.json exists")
106
-
107
- for d in packages apps services libs modules projects; do
108
- [[ -d "$GIT_ROOT/$d" ]] && MONOREPO_SIGNALS+=("'$d/' directory exists at git root")
109
- done
110
-
111
- PKG_COUNT=$(find "$GIT_ROOT" -maxdepth 3 -name "package.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l | tr -d ' ')
112
- (( PKG_COUNT > 2 )) && MONOREPO_SIGNALS+=("${PKG_COUNT} package.json files found")
113
-
114
- CLAUDE_MD_COUNT=$(find "$GIT_ROOT" -maxdepth 3 -name "CLAUDE.md" 2>/dev/null | wc -l | tr -d ' ')
115
- (( CLAUDE_MD_COUNT > 1 )) && MONOREPO_SIGNALS+=("${CLAUDE_MD_COUNT} CLAUDE.md files found (per-package setup)")
116
-
117
- RELATIVE_PATH="${PROJECT_DIR#"$GIT_ROOT/"}"
118
-
119
- if (( ${#MONOREPO_SIGNALS[@]} >= 2 )); then
120
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ℹ️ Git root is ${DEPTH} level(s) above CWD.")
121
- EARLY_CONTEXT_PARTS+=(" Git root: $GIT_ROOT")
122
- EARLY_CONTEXT_PARTS+=(" Working dir: $PROJECT_DIR")
123
- EARLY_CONTEXT_PARTS+=(" Monorepo signals: ${MONOREPO_SIGNALS[*]}")
124
- EARLY_CONTEXT_PARTS+=("")
125
- EARLY_CONTEXT_PARTS+=("Use AskUserQuestion (low priority, don't block):")
126
- EARLY_CONTEXT_PARTS+=(" Question: \"Git root is at \`$GIT_ROOT\` (monorepo). Working in \`${RELATIVE_PATH}\`. Correct context?\"")
127
- EARLY_CONTEXT_PARTS+=(" Type: single_select")
128
- EARLY_CONTEXT_PARTS+=(" Options:")
129
- EARLY_CONTEXT_PARTS+=(" 1. \"Yes, correct package\"")
130
- EARLY_CONTEXT_PARTS+=(" 2. \"No, switch to repo root\"")
131
- EARLY_CONTEXT_PARTS+=("")
132
-
133
- else
134
- GIT_ROOT_FILE_COUNT=$(find "$GIT_ROOT" -maxdepth 1 -not -name '.*' 2>/dev/null | wc -l | tr -d ' ')
135
-
136
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ Git root is ${DEPTH} level(s) above CWD — does NOT look like a monorepo.")
137
- EARLY_CONTEXT_PARTS+=(" Git root: $GIT_ROOT (${GIT_ROOT_FILE_COUNT} files)")
138
- EARLY_CONTEXT_PARTS+=(" Working dir: $PROJECT_DIR")
139
- if (( ${#MONOREPO_SIGNALS[@]} > 0 )); then
140
- EARLY_CONTEXT_PARTS+=(" Weak signals: ${MONOREPO_SIGNALS[*]}")
141
- fi
142
- EARLY_CONTEXT_PARTS+=("")
143
- EARLY_CONTEXT_PARTS+=("Use AskUserQuestion to resolve:")
144
- EARLY_CONTEXT_PARTS+=(" Question: \"Git root is at \`$GIT_ROOT\` (${DEPTH} levels up), which doesn't look like a monorepo. May have been created accidentally.\"")
145
- EARLY_CONTEXT_PARTS+=(" Type: single_select")
146
- EARLY_CONTEXT_PARTS+=(" Options:")
147
- EARLY_CONTEXT_PARTS+=(" 1. \"It's correct\" — continue normally")
148
- EARLY_CONTEXT_PARTS+=(" 2. \"Initialise here instead\" — run \`git init\` here (warn ancestor .git still exists)")
149
- EARLY_CONTEXT_PARTS+=(" 3. \"Investigate\" — list git root contents and recent commits")
150
- EARLY_CONTEXT_PARTS+=("")
151
- fi
152
- fi
153
- fi
154
-
155
- # ---------------------------------------------------------------------------
156
- # 1) CLAUDE.md existence check
157
- # ---------------------------------------------------------------------------
158
- if [[ ! -f "$CLAUDE_MD" ]]; then
159
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ No CLAUDE.md found in project root.")
160
- EARLY_CONTEXT_PARTS+=("")
161
- EARLY_CONTEXT_PARTS+=("Use AskUserQuestion to prompt:")
162
- EARLY_CONTEXT_PARTS+=(" Question: \"No CLAUDE.md found. Want me to set up this project for Claude Code?\"")
163
- EARLY_CONTEXT_PARTS+=(" Type: single_select")
164
- EARLY_CONTEXT_PARTS+=(" Options:")
165
- EARLY_CONTEXT_PARTS+=(" 1. \"Initialise\" — run \`/init\` to scaffold CLAUDE.md")
166
- EARLY_CONTEXT_PARTS+=(" 2. \"Skip\" — continue without one")
167
- EARLY_CONTEXT_PARTS+=("")
168
-
169
- # Emit and write pending flag, then exit early
170
- CONTEXT=$(printf '%s\n' "${EARLY_CONTEXT_PARTS[@]}")
171
- jq -n --arg ctx "$CONTEXT" '{
172
- hookSpecificOutput: {
173
- hookEventName: "SessionStart",
174
- additionalContext: $ctx
175
- }
176
- }'
177
- PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
178
- echo "$CONTEXT" > "$PENDING_FILE"
179
- exit 0
180
- fi
181
-
182
- # ---------------------------------------------------------------------------
183
- # 2) Task List ID check
184
- # ---------------------------------------------------------------------------
185
- TASK_LIST_CONFIGURED=false
186
- SETTINGS_FILE="$PROJECT_DIR/.claude/settings.json"
187
-
188
- [[ -n "${CLAUDE_CODE_TASK_LIST_ID:-}" ]] && TASK_LIST_CONFIGURED=true
189
-
190
- if [[ "$TASK_LIST_CONFIGURED" == false ]] && command -v jq &>/dev/null; then
191
- if [[ -f "$SETTINGS_FILE" ]]; then
192
- TASK_ID=$(jq -r '.env.CLAUDE_CODE_TASK_LIST_ID // empty' "$SETTINGS_FILE" 2>/dev/null) || true
193
- [[ -n "$TASK_ID" ]] && TASK_LIST_CONFIGURED=true
194
- fi
195
- fi
196
-
197
- if [[ "$TASK_LIST_CONFIGURED" == false && -n "$GIT_ROOT" ]]; then
198
- COMMIT_COUNT=$(git -C "$PROJECT_DIR" rev-list --count HEAD 2>/dev/null) || COMMIT_COUNT=0
199
- FILE_COUNT=$(git -C "$PROJECT_DIR" ls-files 2>/dev/null | wc -l | tr -d ' ') || FILE_COUNT=0
200
-
201
- if (( COMMIT_COUNT > 20 || FILE_COUNT > 30 )); then
202
- REPO_NAME=$(basename "$GIT_ROOT")
203
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ℹ️ No persistent Task List ID configured.")
204
- EARLY_CONTEXT_PARTS+=(" Project: ${COMMIT_COUNT} commits, ${FILE_COUNT} tracked files.")
205
- EARLY_CONTEXT_PARTS+=("")
206
- EARLY_CONTEXT_PARTS+=("Use AskUserQuestion (low priority, don't block):")
207
- EARLY_CONTEXT_PARTS+=(" Question: \"No persistent Task List ID configured. For a project this size, tasks won't survive across sessions. Add one?\"")
208
- EARLY_CONTEXT_PARTS+=(" Type: single_select")
209
- EARLY_CONTEXT_PARTS+=(" Options:")
210
- EARLY_CONTEXT_PARTS+=(" 1. \"Yes\" — add {\"env\": {\"CLAUDE_CODE_TASK_LIST_ID\": \"${REPO_NAME}\"}} to .claude/settings.json")
211
- EARLY_CONTEXT_PARTS+=(" 2. \"Skip\" — continue without persistent tasks")
212
- EARLY_CONTEXT_PARTS+=("")
213
- fi
214
- fi
215
-
216
- # ---------------------------------------------------------------------------
217
- # 3) Staleness evaluation
218
- # ---------------------------------------------------------------------------
219
- CLAUDE_MD_MTIME=$(file_mtime "$CLAUDE_MD")
220
- CLAUDE_MD_AGE_DAYS=$(( (NOW - CLAUDE_MD_MTIME) / 86400 ))
221
-
222
- # --- Age-based --------------------------------------------------------------
223
- if (( CLAUDE_MD_AGE_DAYS > 14 )); then
224
- add_staleness "CLAUDE.md last modified ${CLAUDE_MD_AGE_DAYS} days ago" 2
225
- elif (( CLAUDE_MD_AGE_DAYS > 7 )); then
226
- add_staleness "CLAUDE.md last modified ${CLAUDE_MD_AGE_DAYS} days ago" 1
227
- fi
228
-
229
- # --- Directory structure drift ----------------------------------------------
230
- if command -v tree &>/dev/null; then
231
- CURRENT_TREE=$(tree -L 2 -d -I 'node_modules|.git|__pycache__|.venv|venv|dist|build|.next|.nuxt|coverage|.claude' --noreport "$PROJECT_DIR" 2>/dev/null) || true
232
- if [[ -n "$CURRENT_TREE" ]]; then
233
- TREE_DIRS=$(echo "$CURRENT_TREE" | tail -n +2 \
234
- | sed 's/[│├└─┬┤┼┐┘┌┏┗┓┛]//g; s/[|`]//g; s/--*//g' \
235
- | sed 's/^[[:space:]]*//' | { grep -v '^$' || true; } | sort -u)
236
- MISSING_DIRS=()
237
- while IFS= read -r dir; do
238
- [[ -z "$dir" ]] && continue
239
- grep -qi "$dir" "$CLAUDE_MD" 2>/dev/null || MISSING_DIRS+=("$dir")
240
- done <<< "$TREE_DIRS"
241
- if (( ${#MISSING_DIRS[@]} > 2 )); then
242
- add_staleness "Directories not in CLAUDE.md: ${MISSING_DIRS[*]}" 2
243
- elif (( ${#MISSING_DIRS[@]} > 0 )); then
244
- add_staleness "Directories not in CLAUDE.md: ${MISSING_DIRS[*]}" 1
245
- fi
246
- fi
247
- fi
248
-
249
- # --- package.json drift -----------------------------------------------------
250
- if [[ -f "$PROJECT_DIR/package.json" ]]; then
251
- PKG_MTIME=$(file_mtime "$PROJECT_DIR/package.json")
252
-
253
- if (( PKG_MTIME > CLAUDE_MD_MTIME )); then
254
- PKG_DELTA=$(( (PKG_MTIME - CLAUDE_MD_MTIME) / 86400 ))
255
- add_staleness "package.json modified ${PKG_DELTA} day(s) after CLAUDE.md" 1
256
- fi
257
-
258
- if command -v jq &>/dev/null; then
259
- DEP_COUNT=$(jq '[(.dependencies // {} | length), (.devDependencies // {} | length)] | add' "$PROJECT_DIR/package.json" 2>/dev/null) || DEP_COUNT="0"
260
- DOCUMENTED_COUNT=$(grep -oiP '\d+\s*(dependencies|deps)' "$CLAUDE_MD" 2>/dev/null | head -1 | grep -oP '\d+') || true
261
- if [[ -n "$DOCUMENTED_COUNT" && -n "$DEP_COUNT" ]]; then
262
- DRIFT=$(( DEP_COUNT - DOCUMENTED_COUNT ))
263
- DRIFT_ABS=${DRIFT#-}
264
- if (( DRIFT_ABS > 5 )); then
265
- add_staleness "Dep count drift: CLAUDE.md ~${DOCUMENTED_COUNT}, actual ${DEP_COUNT} (Δ${DRIFT})" 2
266
- elif (( DRIFT_ABS > 0 )); then
267
- add_staleness "Dep count drift: CLAUDE.md ~${DOCUMENTED_COUNT}, actual ${DEP_COUNT}" 1
268
- fi
269
- fi
270
-
271
- KEY_DEPS=$(jq -r '(.dependencies // {}) | keys[]' "$PROJECT_DIR/package.json" 2>/dev/null) || true
272
- UNDOCUMENTED=()
273
- while IFS= read -r dep; do
274
- [[ -z "$dep" ]] && continue
275
- grep -qi "$dep" "$CLAUDE_MD" 2>/dev/null || UNDOCUMENTED+=("$dep")
276
- done <<< "$KEY_DEPS"
277
- if (( ${#UNDOCUMENTED[@]} > 5 )); then
278
- add_staleness "${#UNDOCUMENTED[@]} production deps not in CLAUDE.md (e.g. ${UNDOCUMENTED[*]:0:5})" 2
279
- fi
280
- fi
281
- fi
282
-
283
- # --- Python dependency drift ------------------------------------------------
284
- for PYFILE in "$PROJECT_DIR/pyproject.toml" "$PROJECT_DIR/requirements.txt" "$PROJECT_DIR/setup.py"; do
285
- if [[ -f "$PYFILE" ]]; then
286
- PY_MTIME=$(file_mtime "$PYFILE")
287
- (( PY_MTIME > CLAUDE_MD_MTIME )) && add_staleness "$(basename "$PYFILE") modified after CLAUDE.md" 1
288
- fi
289
- done
290
-
291
- # --- Key config files -------------------------------------------------------
292
- for CFG in "$PROJECT_DIR/tsconfig.json" \
293
- "$PROJECT_DIR/.env.example" \
294
- "$PROJECT_DIR/docker-compose.yml" \
295
- "$PROJECT_DIR/Dockerfile" \
296
- "$PROJECT_DIR/Makefile" \
297
- "$PROJECT_DIR/Cargo.toml" \
298
- "$PROJECT_DIR/go.mod"; do
299
- if [[ -f "$CFG" ]]; then
300
- CFG_MTIME=$(file_mtime "$CFG")
301
- (( CFG_MTIME > CLAUDE_MD_MTIME )) && add_staleness "$(basename "$CFG") modified after CLAUDE.md" 1
302
- fi
303
- done
304
-
305
- # --- Git-based checks -------------------------------------------------------
306
- if command -v git &>/dev/null && [[ -n "$GIT_ROOT" ]]; then
307
- CLAUDE_MD_DATE=$(date -d "@$CLAUDE_MD_MTIME" --iso-8601=seconds 2>/dev/null) \
308
- || CLAUDE_MD_DATE=$(date -r "$CLAUDE_MD_MTIME" +%Y-%m-%dT%H:%M:%S 2>/dev/null) \
309
- || CLAUDE_MD_DATE=""
310
-
311
- if [[ -n "$CLAUDE_MD_DATE" ]]; then
312
- COMMITS_SINCE=$(git -C "$PROJECT_DIR" rev-list --count --since="$CLAUDE_MD_DATE" HEAD 2>/dev/null) || COMMITS_SINCE="0"
313
- if (( COMMITS_SINCE > 50 )); then
314
- add_staleness "${COMMITS_SINCE} commits since CLAUDE.md updated" 2
315
- elif (( COMMITS_SINCE > 20 )); then
316
- add_staleness "${COMMITS_SINCE} commits since CLAUDE.md updated" 1
317
- fi
318
-
319
- CHANGED_FILES=$(git -C "$PROJECT_DIR" diff --name-only --diff-filter=AD HEAD~20..HEAD 2>/dev/null | head -20) || true
320
- NEW_TOP_LEVEL=$(echo "$CHANGED_FILES" | { grep -v '/' || true; } | { grep -v '^\.' || true; } | sort -u)
321
- if [[ -n "$NEW_TOP_LEVEL" ]]; then
322
- NEW_COUNT=$(echo "$NEW_TOP_LEVEL" | wc -l | tr -d ' ')
323
- (( NEW_COUNT > 3 )) && add_staleness "${NEW_COUNT} top-level files added/removed recently" 1
324
- fi
325
- fi
326
- fi
327
-
328
- # --- Lock file drift --------------------------------------------------------
329
- for LOCK in "$PROJECT_DIR/package-lock.json" \
330
- "$PROJECT_DIR/yarn.lock" \
331
- "$PROJECT_DIR/pnpm-lock.yaml" \
332
- "$PROJECT_DIR/Cargo.lock" \
333
- "$PROJECT_DIR/poetry.lock"; do
334
- if [[ -f "$LOCK" ]]; then
335
- LOCK_MTIME=$(file_mtime "$LOCK")
336
- LOCK_DELTA=$(( (LOCK_MTIME - CLAUDE_MD_MTIME) / 86400 ))
337
- if (( LOCK_DELTA > 7 )); then
338
- add_staleness "$(basename "$LOCK") is ${LOCK_DELTA} days newer than CLAUDE.md" 1
339
- break
340
- fi
341
- fi
342
- done
343
-
344
- # ---------------------------------------------------------------------------
345
- # 4) Produce output
346
- # ---------------------------------------------------------------------------
347
- OUTPUT_PARTS=()
348
-
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
- for part in "${EARLY_CONTEXT_PARTS[@]+"${EARLY_CONTEXT_PARTS[@]}"}"; do
356
- OUTPUT_PARTS+=("$part")
357
- done
358
-
359
- OUTPUT_PARTS+=("[SESSION GUARD] ✅ CLAUDE.md found in: $PROJECT_DIR")
360
-
361
- if (( STALENESS_SCORE >= STALENESS_THRESHOLD )); then
362
- OUTPUT_PARTS+=("")
363
- OUTPUT_PARTS+=("[SESSION GUARD] ⚠️ CLAUDE.md appears STALE (score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD})")
364
- OUTPUT_PARTS+=("")
365
- OUTPUT_PARTS+=("Signals:")
366
- for sig in "${STALENESS_SIGNALS[@]}"; do
367
- OUTPUT_PARTS+=(" $sig")
368
- done
369
- OUTPUT_PARTS+=("")
370
- OUTPUT_PARTS+=("Use AskUserQuestion to prompt:")
371
- OUTPUT_PARTS+=(" Question: \"CLAUDE.md appears out of date (${#STALENESS_SIGNALS[@]} signals detected). What would you like to do?\"")
372
- OUTPUT_PARTS+=(" Type: single_select")
373
- OUTPUT_PARTS+=(" Options:")
374
- OUTPUT_PARTS+=(" 1. \"Update it\" — review project structure, deps, recent changes and update CLAUDE.md (preserve user-written notes)")
375
- OUTPUT_PARTS+=(" 2. \"Show signals\" — list what's drifted before deciding")
376
- OUTPUT_PARTS+=(" 3. \"Skip\" — continue with current CLAUDE.md")
377
- OUTPUT_PARTS+=("")
378
- elif (( ${#STALENESS_SIGNALS[@]} > 0 )); then
379
- OUTPUT_PARTS+=("")
380
- OUTPUT_PARTS+=("[SESSION GUARD] ℹ️ Minor drift (score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD}) — not flagging:")
381
- for sig in "${STALENESS_SIGNALS[@]}"; do
382
- OUTPUT_PARTS+=(" $sig")
383
- done
384
- fi
385
-
386
- CONTEXT=$(printf '%s\n' "${OUTPUT_PARTS[@]}")
387
-
388
- jq -n --arg ctx "$CONTEXT" '{
389
- hookSpecificOutput: {
390
- hookEventName: "SessionStart",
391
- additionalContext: $ctx
392
- }
393
- }'
394
-
395
- # ---------------------------------------------------------------------------
396
- # 5) Write pending flag for UserPromptSubmit companion hook
397
- # ---------------------------------------------------------------------------
398
- PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
399
- echo "$CONTEXT" > "$PENDING_FILE"
400
-
401
- exit 0