Skip to content

Commit 8501ba4

Browse files
authored
fix: cross-platform hook validation + sweep runtime support (#15)
CRLF-normalize frontmatter regex helpers, migrate the SessionStart hook to a node entrypoint (Windows-safe), and wire 'sweep' into VALID_MODES + the guide map. Adds CRLF and sweep unit tests. Co-authored-by: Eleven-Mouse <3331379576@qq.com>
1 parent 0e92503 commit 8501ba4

7 files changed

Lines changed: 135 additions & 71 deletions

File tree

hooks/hooks.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"hooks": [
77
{
88
"type": "command",
9-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start\"",
9+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
1010
"async": false
1111
}
1212
]

hooks/session-start

Lines changed: 4 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,6 @@
1-
#!/usr/bin/env bash
2-
# SessionStart hook for brooks-lint plugin
3-
# Injects lightweight awareness of brooks-lint into every Claude session.
1+
#!/usr/bin/env sh
42

5-
set -euo pipefail
3+
set -eu
64

7-
# Auto-install short-form commands to ~/.claude/commands/
8-
# Plugin skills register as /brooks-lint:brooks-review etc.
9-
# These wrappers enable /brooks-review (no namespace prefix).
10-
# Versioned sentinel ensures files refresh on plugin upgrade.
11-
cmd_dir="$HOME/.claude/commands"
12-
plugin_dir="$(cd "$(dirname "$0")/.." && pwd)"
13-
version="$(sed -n 's/.*"version": "\(.*\)".*/\1/p' "$plugin_dir/package.json" | head -n 1)"
14-
if [ -z "$version" ]; then
15-
echo "Failed to read version from package.json" >&2
16-
exit 1
17-
fi
18-
sentinel="$cmd_dir/.brooks-lint-v${version}"
19-
20-
if [ ! -f "$sentinel" ]; then
21-
mkdir -p "$cmd_dir"
22-
cp "$plugin_dir"/commands/brooks-*.md "$cmd_dir/"
23-
# Clean up old sentinel files and write current version
24-
rm -f "$cmd_dir"/.brooks-lint-v* "$cmd_dir"/.brooks-lint-installed
25-
touch "$sentinel"
26-
fi
27-
28-
# The context injected must be SHORT (<150 words).
29-
# Do NOT inject the full SKILL.md — it loads on demand via the Skill tool.
30-
context="You have the brooks-lint plugin installed. It provides six independent skills — load the relevant one via the Skill tool:
31-
brooks-lint:brooks-review → PR code review
32-
brooks-lint:brooks-audit → Architecture audit
33-
brooks-lint:brooks-debt → Tech debt assessment
34-
brooks-lint:brooks-test → Test quality review
35-
brooks-lint:brooks-health → Codebase health dashboard
36-
brooks-lint:brooks-sweep → Full sweep: analyse all dimensions and auto-fix findings
37-
38-
Triggers when the user asks to review code, discuss architecture, assess tech debt, or discuss test quality. Also triggers when the user mentions: Brooks's Law / Mythical Man-Month / conceptual integrity / second system effect / Hyrum's Law / deep modules / tactical programming / code smells / refactoring / clean architecture / DDD."
39-
40-
# Escape for JSON embedding
41-
escape_for_json() {
42-
local s="$1"
43-
s="${s//\\/\\\\}"
44-
s="${s//\"/\\\"}"
45-
s="${s//$'\n'/\\n}"
46-
s="${s//$'\r'/\\r}"
47-
s="${s//$'\t'/\\t}"
48-
printf '%s' "$s"
49-
}
50-
51-
context_escaped=$(escape_for_json "$context")
52-
53-
# Output format differs by platform
54-
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
55-
printf '{\n "additional_context": "%s"\n}\n' "$context_escaped"
56-
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
57-
printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$context_escaped"
58-
else
59-
printf '{\n "additional_context": "%s"\n}\n' "$context_escaped"
60-
fi
61-
62-
exit 0
5+
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6+
exec node "$script_dir/session-start.mjs" "$@"

hooks/session-start.mjs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env node
2+
3+
import {
4+
copyFileSync,
5+
mkdirSync,
6+
readFileSync,
7+
readdirSync,
8+
rmSync,
9+
writeFileSync,
10+
} from "node:fs";
11+
import os from "node:os";
12+
import path from "node:path";
13+
import { fileURLToPath } from "node:url";
14+
15+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
16+
const pluginDir = path.resolve(__dirname, "..");
17+
const homeDir = process.env.HOME || os.homedir();
18+
19+
function readVersion() {
20+
const packageJson = JSON.parse(
21+
readFileSync(path.join(pluginDir, "package.json"), "utf8"),
22+
);
23+
if (!packageJson.version) {
24+
throw new Error("Failed to read version from package.json");
25+
}
26+
return packageJson.version;
27+
}
28+
29+
function installCommands(version) {
30+
const commandDir = path.join(homeDir, ".claude", "commands");
31+
const sentinel = path.join(commandDir, `.brooks-lint-v${version}`);
32+
33+
try {
34+
readFileSync(sentinel, "utf8");
35+
return;
36+
} catch {
37+
// Sentinel does not exist yet.
38+
}
39+
40+
mkdirSync(commandDir, { recursive: true });
41+
42+
const commandsDir = path.join(pluginDir, "commands");
43+
for (const entry of readdirSync(commandsDir)) {
44+
if (/^brooks-.*\.md$/.test(entry)) {
45+
copyFileSync(path.join(commandsDir, entry), path.join(commandDir, entry));
46+
}
47+
}
48+
49+
for (const entry of readdirSync(commandDir)) {
50+
if (entry === ".brooks-lint-installed" || entry.startsWith(".brooks-lint-v")) {
51+
rmSync(path.join(commandDir, entry), { force: true });
52+
}
53+
}
54+
55+
writeFileSync(sentinel, "");
56+
}
57+
58+
function buildContext() {
59+
return [
60+
"You have the brooks-lint plugin installed. It provides six independent skills - load the relevant one via the Skill tool:",
61+
" brooks-lint:brooks-review -> PR code review",
62+
" brooks-lint:brooks-audit -> Architecture audit",
63+
" brooks-lint:brooks-debt -> Tech debt assessment",
64+
" brooks-lint:brooks-test -> Test quality review",
65+
" brooks-lint:brooks-health -> Codebase health dashboard",
66+
" brooks-lint:brooks-sweep -> Full sweep: analyse all dimensions and auto-fix findings",
67+
"",
68+
"Triggers when the user asks to review code, discuss architecture, assess tech debt, or discuss test quality. Also triggers when the user mentions: Brooks's Law / Mythical Man-Month / conceptual integrity / second system effect / Hyrum's Law / deep modules / tactical programming / code smells / refactoring / clean architecture / DDD.",
69+
].join("\n");
70+
}
71+
72+
function buildOutput(context) {
73+
if (process.env.CLAUDE_PLUGIN_ROOT) {
74+
return {
75+
hookSpecificOutput: {
76+
hookEventName: "SessionStart",
77+
additionalContext: context,
78+
},
79+
};
80+
}
81+
82+
return {
83+
additional_context: context,
84+
};
85+
}
86+
87+
try {
88+
installCommands(readVersion());
89+
process.stdout.write(`${JSON.stringify(buildOutput(buildContext()), null, 2)}\n`);
90+
} catch (error) {
91+
process.stderr.write(`${error.message}\n`);
92+
process.exit(1);
93+
}

scripts/assemble-prompt.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
22
import path from "node:path";
33

44
/** Canonical list of valid mode names — import from here to avoid drift. */
5-
export const VALID_MODES = ["review", "audit", "debt", "test", "health"];
5+
export const VALID_MODES = ["review", "audit", "debt", "test", "health", "sweep"];
66

77
/**
88
* Assemble the system prompt for a given brooks-lint mode.
@@ -25,7 +25,7 @@ export function assembleSystemPrompt(mode, skillsDir) {
2525
// Add risk definitions based on mode
2626
if (mode === "test") {
2727
sections.push(read(path.join(sharedDir, "test-decay-risks.md")));
28-
} else if (mode === "health") {
28+
} else if (mode === "health" || mode === "sweep") {
2929
sections.push(read(path.join(sharedDir, "decay-risks.md")));
3030
sections.push(read(path.join(sharedDir, "test-decay-risks.md")));
3131
} else {
@@ -39,6 +39,7 @@ export function assembleSystemPrompt(mode, skillsDir) {
3939
debt: ["brooks-debt", "debt-guide.md"],
4040
test: ["brooks-test", "test-guide.md"],
4141
health: ["brooks-health", "health-guide.md"],
42+
sweep: ["brooks-sweep", "sweep-guide.md"],
4243
};
4344

4445
const [modeDir, guideFile] = guideMap[mode] ?? (() => { throw new Error(`Unknown mode: ${mode}`); })();

scripts/frontmatter.mjs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
* validation run on import.
77
*/
88

9+
function normalizeNewlines(text) {
10+
return text.replace(/\r\n/g, "\n");
11+
}
12+
913
/**
1014
* Parse the `books:` list from a YAML frontmatter block at the top of a
1115
* markdown file. Returns an array of book title strings, or null if the
@@ -23,7 +27,8 @@
2327
* other special characters — the only delimiter is the line break.
2428
*/
2529
export function parseFrontmatterBooks(text) {
26-
const match = text.match(/^---\n([\s\S]*?)\n---/);
30+
const normalized = normalizeNewlines(text);
31+
const match = normalized.match(/^---\n([\s\S]*?)\n---/);
2732
if (!match) return null;
2833
const booksSection = match[1].match(/^books:\n((?:[ \t]+-[^\n]+\n?)+)/m);
2934
if (!booksSection) return null;
@@ -38,31 +43,31 @@ export function parseFrontmatterBooks(text) {
3843
* Each book section uses the pattern: ## Author Name — *Book Title*
3944
*/
4045
export function countBookSections(text) {
41-
return (text.match(/^## .+ \*/gm) ?? []).length;
46+
return (normalizeNewlines(text).match(/^## .+ \*/gm) ?? []).length;
4247
}
4348

4449
/**
4550
* Count production decay risk sections in decay-risks.md.
4651
* Each risk section uses the pattern: ## Risk N: Title
4752
*/
4853
export function countProductionRisks(text) {
49-
return (text.match(/^## Risk \d+:/gm) ?? []).length;
54+
return (normalizeNewlines(text).match(/^## Risk \d+:/gm) ?? []).length;
5055
}
5156

5257
/**
5358
* Count test decay risk sections in test-decay-risks.md.
5459
* Each risk section uses the pattern: ## Risk TN: Title
5560
*/
5661
export function countTestRisks(text) {
57-
return (text.match(/^## Risk T\d+:/gm) ?? []).length;
62+
return (normalizeNewlines(text).match(/^## Risk T\d+:/gm) ?? []).length;
5863
}
5964

6065
/**
6166
* Extract the latest version string from CHANGELOG.md.
6267
* Returns null if no version header is found.
6368
*/
6469
export function extractChangelogVersion(text) {
65-
return text.match(/^## \[(.+?)\] - /m)?.[1] ?? null;
70+
return normalizeNewlines(text).match(/^## \[(.+?)\] - /m)?.[1] ?? null;
6671
}
6772

6873
/**
@@ -71,7 +76,7 @@ export function extractChangelogVersion(text) {
7176
* Returns: ["1", "2a", "6b", ...] — the label portion only.
7277
*/
7378
export function extractGuideStepLabels(text) {
74-
return (text.match(/^### Step (\d+[a-z]?)/gm) ?? [])
79+
return (normalizeNewlines(text).match(/^### Step (\d+[a-z]?)/gm) ?? [])
7580
.map(m => m.replace(/^### Step /, ""));
7681
}
7782

scripts/validate-repo.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1818
const root = path.resolve(__dirname, "..");
1919

2020
function readText(relPath) {
21-
return readFileSync(path.join(root, relPath), "utf8");
21+
return readFileSync(path.join(root, relPath), "utf8").replace(/\r\n/g, "\n");
2222
}
2323

2424
function readJson(relPath) {
@@ -302,7 +302,7 @@ function checkSecurity() {
302302
function checkHookOutput() {
303303
function runHook(env = {}) {
304304
const tempHome = mkdtempSync(path.join(os.tmpdir(), "brooks-lint-hook-home-"));
305-
const stdout = execFileSync("bash", ["hooks/session-start"], {
305+
const stdout = execFileSync(process.execPath, [path.join(root, "hooks", "session-start.mjs")], {
306306
cwd: root,
307307
env: { ...process.env, HOME: tempHome, ...env },
308308
encoding: "utf8",

scripts/validate-repo.test.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
1212
import { fileURLToPath } from "node:url";
1313
import path from "node:path";
1414
import os from "node:os";
15+
import { assembleSystemPrompt, VALID_MODES } from "./assemble-prompt.mjs";
1516
import { readHistory, appendHistory, getTrend } from "./history.mjs";
1617
import {
1718
parseFrontmatterBooks,
@@ -77,6 +78,11 @@ test("handles 4-space indentation", () => {
7778
assert.deepEqual(parseFrontmatterBooks(text), ["The Mythical Man-Month", "Code Complete"]);
7879
});
7980

81+
test("handles CRLF line endings", () => {
82+
const text = "---\r\nbooks:\r\n - The Mythical Man-Month\r\n - Code Complete\r\n---\r\n";
83+
assert.deepEqual(parseFrontmatterBooks(text), ["The Mythical Man-Month", "Code Complete"]);
84+
});
85+
8086
test("handles titles containing colons", () => {
8187
const text = "---\nbooks:\n - Domain-Driven Design: Tackling Complexity\n---\n";
8288
assert.deepEqual(parseFrontmatterBooks(text), ["Domain-Driven Design: Tackling Complexity"]);
@@ -234,6 +240,21 @@ test("handles full pr-review-guide pattern", () => {
234240
);
235241
});
236242

243+
// —— assembleSystemPrompt / VALID_MODES ————————————————————————————————
244+
245+
console.log("\nassembleSystemPrompt");
246+
247+
test("includes sweep in VALID_MODES", () => {
248+
assert.ok(VALID_MODES.includes("sweep"));
249+
});
250+
251+
test("assembles sweep prompt with both risk catalogs and sweep guide", () => {
252+
const prompt = assembleSystemPrompt("sweep", path.join(__dirname, "..", "skills"));
253+
assert.match(prompt, /## Risk 1: Cognitive Overload/);
254+
assert.match(prompt, /## Risk T1: Test Obscurity/);
255+
assert.match(prompt, /# Brooks-Lint .* Full Sweep Guide/);
256+
});
257+
237258
// ── readHistory ────────────────────────────────────────────────────────────
238259

239260
console.log("\nreadHistory");

0 commit comments

Comments
 (0)