@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.
@@ -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.10",
4
+ "version": "2.0.12",
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 \"$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
+ }
@@ -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
- 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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slamb2k/mad-skills",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
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:36:03.798Z",
2
+ "generated": "2026-03-09T13:01:19.482Z",
3
3
  "count": 8,
4
4
  "skills": [
5
5
  {