flake-update-20260505
1#!/usr/bin/env bun
2/**
3 * PreToolUse hook (Write) — validate file paths and filenames.
4 *
5 * Mirrors pi's path-validator extension:
6 * • Reads policies from ~/.config/ai/path-policies.json
7 * • Blocks writes to wrong locations (old claude history paths)
8 * • Enforces filename conventions (YYYY-MM-DD-slug.md)
9 * • Falls back to built-in defaults if no config found
10 */
11
12import { readStdinJSON, HOME } from "./lib.ts";
13import { readFileSync, existsSync } from "node:fs";
14import { join, basename } from "node:path";
15
16// ── Types ──────────────────────────────────────────────────────────
17
18interface PreToolInput {
19 tool_name: string;
20 tool_input: {
21 file_path?: string;
22 filePath?: string;
23 path?: string;
24 command?: string;
25 };
26}
27
28interface PathPolicy {
29 name: string;
30 description?: string;
31 filenamePattern?: string;
32 pathPattern?: string;
33 allowedPaths?: string[];
34 blockedPaths?: string[];
35 requiredFilenameFormat?: string;
36 formatDescription?: string;
37 formatExample?: string;
38 suggestedPath?: string;
39 action?: "warn" | "block" | "suggest";
40 enabled?: boolean;
41}
42
43interface PolicyConfig {
44 policies: PathPolicy[];
45}
46
47// ── Helpers ────────────────────────────────────────────────────────
48
49function expandHome(p: string): string {
50 return p.startsWith("~/") ? join(HOME, p.slice(2)) : p;
51}
52
53function pathMatches(path: string, pattern: string): boolean {
54 const expanded = expandHome(pattern)
55 .replace(/[.+^${}()|[\]\\]/g, "\\$&")
56 .replace(/\*/g, ".*")
57 .replace(/\?/g, ".");
58 return new RegExp(`^${expanded}`).test(path);
59}
60
61// ── Default policies (same as pi path-validator) ───────────────────
62
63const DEFAULT_POLICIES: PathPolicy[] = [
64 {
65 name: "ai-sessions-location",
66 description: "Session files must go to unified AI storage, not old Claude paths",
67 filenamePattern: ".*session.*\\.md$",
68 blockedPaths: [
69 "~/.config/claude/history/sessions/",
70 "~/.claude/history/sessions/",
71 ],
72 suggestedPath: "~/.local/share/ai/sessions/YYYY-MM/",
73 action: "block",
74 enabled: true,
75 },
76 {
77 name: "ai-sessions-format",
78 description: "Session files must follow YYYY-MM-DD-description.md format",
79 pathPattern: ".*/ai/sessions/.*\\.md$",
80 requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
81 formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only, no underscores)",
82 formatExample: "2026-02-05-unified-ai-storage.md",
83 action: "block",
84 enabled: true,
85 },
86 {
87 name: "ai-plans-location",
88 description: "Plan files must go to unified AI storage",
89 filenamePattern: ".*plan.*\\.md$",
90 blockedPaths: [
91 "~/.config/claude/plans/",
92 "~/.claude/plans/",
93 "~/.config/claude/history/plans/",
94 ],
95 suggestedPath: "~/.local/share/ai/plans/",
96 action: "block",
97 enabled: true,
98 },
99 {
100 name: "ai-plans-format",
101 description: "Plan files must follow kebab-case.md format (no dates)",
102 pathPattern: ".*/ai/plans/.*\\.md$",
103 requiredFilenameFormat: "^[a-z0-9][a-z0-9-]*\\.md$",
104 formatDescription: "kebab-case.md (lowercase, hyphens, no dates)",
105 formatExample: "homelab-migration-plan.md",
106 action: "block",
107 enabled: true,
108 },
109 {
110 name: "ai-learnings-location",
111 description: "Learning files must go to unified AI storage",
112 filenamePattern: ".*learning.*\\.md$",
113 blockedPaths: ["~/.config/claude/history/learnings/"],
114 suggestedPath: "~/.local/share/ai/learnings/YYYY-MM/",
115 action: "block",
116 enabled: true,
117 },
118 {
119 name: "ai-learnings-format",
120 description: "Learning files must follow YYYY-MM-DD-description.md format",
121 pathPattern: ".*/ai/learnings/.*\\.md$",
122 requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
123 formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only)",
124 formatExample: "2026-02-05-nix-flake-patterns.md",
125 action: "block",
126 enabled: true,
127 },
128 {
129 name: "ai-research-location",
130 description: "Research files must go to unified AI storage",
131 filenamePattern: ".*research.*\\.md$",
132 blockedPaths: ["~/.config/claude/history/research/"],
133 suggestedPath: "~/.local/share/ai/research/YYYY-MM/",
134 action: "block",
135 enabled: true,
136 },
137 {
138 name: "ai-research-format",
139 description: "Research files must follow YYYY-MM-DD-description.md format",
140 pathPattern: ".*/ai/research/.*\\.md$",
141 requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
142 formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only)",
143 formatExample: "2026-02-05-chrono-node-evaluation.md",
144 action: "block",
145 enabled: true,
146 },
147 {
148 name: "no-secrets-in-repos",
149 description: "Prevent writing secrets to git repositories",
150 filenamePattern: "\\.(env|pem|key|secret|credentials)$",
151 blockedPaths: ["~/src/*", "~/projects/*"],
152 action: "block",
153 enabled: true,
154 },
155];
156
157// ── Load policies ──────────────────────────────────────────────────
158
159function loadPolicies(): PathPolicy[] {
160 const configPath = join(HOME, ".config", "ai", "path-policies.json");
161 try {
162 if (existsSync(configPath)) {
163 const config: PolicyConfig = JSON.parse(
164 readFileSync(configPath, "utf-8")
165 );
166 if (config.policies && Array.isArray(config.policies)) {
167 const names = new Set(config.policies.map((p) => p.name));
168 return [
169 ...config.policies,
170 ...DEFAULT_POLICIES.filter((p) => !names.has(p.name)),
171 ];
172 }
173 }
174 } catch {}
175 return DEFAULT_POLICIES;
176}
177
178// ── Validate ───────────────────────────────────────────────────────
179
180function validate(
181 filePath: string,
182 policies: PathPolicy[]
183): { blocks: string[]; warns: string[] } {
184 const expanded = expandHome(filePath);
185 const fname = basename(expanded);
186 const blocks: string[] = [];
187 const warns: string[] = [];
188
189 for (const pol of policies) {
190 if (pol.enabled === false) continue;
191
192 // Check if this policy applies (filename or path pattern)
193 let matches = false;
194 if (pol.filenamePattern && new RegExp(pol.filenamePattern, "i").test(fname))
195 matches = true;
196 if (pol.pathPattern && new RegExp(pol.pathPattern, "i").test(expanded))
197 matches = true;
198 if (!matches) continue;
199
200 const action = pol.action || "warn";
201 const bucket = action === "block" ? blocks : warns;
202
203 // Check blocked paths
204 if (pol.blockedPaths) {
205 for (const bp of pol.blockedPaths) {
206 if (pathMatches(expanded, bp)) {
207 let msg = `[${pol.name}] ${pol.description || "Blocked path"}`;
208 if (pol.suggestedPath) msg += `\n → Use: ${pol.suggestedPath}`;
209 bucket.push(msg);
210 break;
211 }
212 }
213 }
214
215 // Check allowed paths
216 if (pol.allowedPaths?.length) {
217 const ok = pol.allowedPaths.some((ap) => pathMatches(expanded, ap));
218 if (!ok) {
219 bucket.push(
220 `[${pol.name}] Path not in allowed locations: ${pol.allowedPaths.join(", ")}`
221 );
222 }
223 }
224
225 // Check required filename format
226 if (pol.requiredFilenameFormat) {
227 if (!new RegExp(pol.requiredFilenameFormat).test(fname)) {
228 const desc = pol.formatDescription || pol.requiredFilenameFormat;
229 const ex = pol.formatExample ? `\n Example: ${pol.formatExample}` : "";
230 bucket.push(`[${pol.name}] Filename doesn't match format: ${desc}${ex}`);
231 }
232 }
233 }
234
235 return { blocks, warns };
236}
237
238// ── Main ───────────────────────────────────────────────────────────
239
240async function main() {
241 const data = await readStdinJSON<PreToolInput>();
242 if (!data) process.exit(0);
243
244 const filePath =
245 data.tool_input?.file_path ||
246 data.tool_input?.filePath ||
247 data.tool_input?.path;
248
249 if (!filePath || typeof filePath !== "string") process.exit(0);
250
251 const policies = loadPolicies();
252 const { blocks, warns } = validate(filePath, policies);
253
254 // Stderr warnings (non-blocking)
255 for (const w of warns) {
256 process.stderr.write(`⚠️ ${w}\n`);
257 }
258
259 // Block if violations
260 if (blocks.length > 0) {
261 process.stdout.write(
262 JSON.stringify({
263 decision: "block",
264 reason: ["BLOCKED: Path policy violation", "", ...blocks].join("\n"),
265 })
266 );
267 }
268
269 process.exit(0);
270}
271
272main();