@drewpayment/mink 0.1.0

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.
Files changed (72) hide show
  1. package/README.md +347 -0
  2. package/package.json +32 -0
  3. package/src/cli.ts +176 -0
  4. package/src/commands/bug-search.ts +32 -0
  5. package/src/commands/config.ts +109 -0
  6. package/src/commands/cron.ts +295 -0
  7. package/src/commands/daemon.ts +46 -0
  8. package/src/commands/dashboard.ts +21 -0
  9. package/src/commands/designqc.ts +160 -0
  10. package/src/commands/detect-waste.ts +81 -0
  11. package/src/commands/framework-advisor.ts +52 -0
  12. package/src/commands/init.ts +159 -0
  13. package/src/commands/post-read.ts +123 -0
  14. package/src/commands/post-write.ts +157 -0
  15. package/src/commands/pre-read.ts +109 -0
  16. package/src/commands/pre-write.ts +136 -0
  17. package/src/commands/reflect.ts +39 -0
  18. package/src/commands/restore.ts +31 -0
  19. package/src/commands/scan.ts +101 -0
  20. package/src/commands/session-start.ts +21 -0
  21. package/src/commands/session-stop.ts +115 -0
  22. package/src/commands/status.ts +152 -0
  23. package/src/commands/update.ts +121 -0
  24. package/src/core/action-log.ts +341 -0
  25. package/src/core/backup.ts +122 -0
  26. package/src/core/bug-memory.ts +223 -0
  27. package/src/core/cron-parser.ts +94 -0
  28. package/src/core/daemon.ts +152 -0
  29. package/src/core/dashboard-api.ts +280 -0
  30. package/src/core/dashboard-server.ts +580 -0
  31. package/src/core/description.ts +232 -0
  32. package/src/core/design-eval/capture.ts +269 -0
  33. package/src/core/design-eval/route-detect.ts +165 -0
  34. package/src/core/design-eval/server-detect.ts +91 -0
  35. package/src/core/framework-advisor/catalog.ts +360 -0
  36. package/src/core/framework-advisor/decision-tree.ts +287 -0
  37. package/src/core/framework-advisor/generate.ts +132 -0
  38. package/src/core/framework-advisor/migration-prompts.ts +502 -0
  39. package/src/core/framework-advisor/validate.ts +137 -0
  40. package/src/core/fs-utils.ts +30 -0
  41. package/src/core/global-config.ts +74 -0
  42. package/src/core/index-store.ts +72 -0
  43. package/src/core/learning-memory.ts +120 -0
  44. package/src/core/paths.ts +86 -0
  45. package/src/core/pattern-engine.ts +108 -0
  46. package/src/core/project-id.ts +19 -0
  47. package/src/core/project-registry.ts +64 -0
  48. package/src/core/reflection.ts +256 -0
  49. package/src/core/scanner.ts +99 -0
  50. package/src/core/scheduler.ts +352 -0
  51. package/src/core/seed.ts +239 -0
  52. package/src/core/session.ts +128 -0
  53. package/src/core/stdin.ts +13 -0
  54. package/src/core/task-registry.ts +202 -0
  55. package/src/core/token-estimate.ts +36 -0
  56. package/src/core/token-ledger.ts +185 -0
  57. package/src/core/waste-detection.ts +214 -0
  58. package/src/core/write-exclusions.ts +24 -0
  59. package/src/types/action-log.ts +20 -0
  60. package/src/types/backup.ts +6 -0
  61. package/src/types/bug-memory.ts +24 -0
  62. package/src/types/config.ts +59 -0
  63. package/src/types/dashboard.ts +104 -0
  64. package/src/types/design-eval.ts +64 -0
  65. package/src/types/file-index.ts +38 -0
  66. package/src/types/framework-advisor.ts +97 -0
  67. package/src/types/hook-input.ts +27 -0
  68. package/src/types/learning-memory.ts +36 -0
  69. package/src/types/scheduler.ts +82 -0
  70. package/src/types/session.ts +50 -0
  71. package/src/types/token-ledger.ts +43 -0
  72. package/src/types/waste-detection.ts +21 -0
@@ -0,0 +1,159 @@
1
+ import { execSync } from "child_process";
2
+ import { mkdirSync, existsSync } from "fs";
3
+ import { resolve, dirname, basename, join } from "path";
4
+ import { projectDir, projectMetaPath } from "../core/paths";
5
+ import { generateProjectId } from "../core/project-id";
6
+ import { atomicWriteJson, safeReadJson } from "../core/fs-utils";
7
+
8
+ interface HookCommand {
9
+ type: "command";
10
+ command: string;
11
+ }
12
+
13
+ interface HookEntry {
14
+ matcher: string;
15
+ hooks: HookCommand[];
16
+ }
17
+
18
+ type HooksConfig = Record<string, HookEntry[]>;
19
+
20
+ export function detectRuntime(): "bun" | "node" {
21
+ try {
22
+ execSync("bun --version", { stdio: "ignore" });
23
+ return "bun";
24
+ } catch {
25
+ return "node";
26
+ }
27
+ }
28
+
29
+ export function buildHooksConfig(
30
+ runtime: "bun" | "node",
31
+ cliPath: string
32
+ ): HooksConfig {
33
+ const prefix = runtime === "bun" ? `bun run ${cliPath}` : `node ${cliPath}`;
34
+ const hook = (cmd: string): HookCommand[] => [{ type: "command", command: cmd }];
35
+ return {
36
+ SessionStart: [{ matcher: "", hooks: hook(`${prefix} session-start`) }],
37
+ Stop: [{ matcher: "", hooks: hook(`${prefix} session-stop`) }],
38
+ PreToolUse: [
39
+ { matcher: "Read", hooks: hook(`${prefix} pre-read`) },
40
+ { matcher: "Edit", hooks: hook(`${prefix} pre-write`) },
41
+ { matcher: "Write", hooks: hook(`${prefix} pre-write`) },
42
+ ],
43
+ PostToolUse: [
44
+ { matcher: "Read", hooks: hook(`${prefix} post-read`) },
45
+ { matcher: "Edit", hooks: hook(`${prefix} post-write`) },
46
+ { matcher: "Write", hooks: hook(`${prefix} post-write`) },
47
+ ],
48
+ };
49
+ }
50
+
51
+ function isMinkCommand(cmd: string): boolean {
52
+ return (
53
+ cmd.includes("cli") &&
54
+ (cmd.includes("session-start") ||
55
+ cmd.includes("session-stop") ||
56
+ cmd.includes("pre-read") ||
57
+ cmd.includes("post-read") ||
58
+ cmd.includes("pre-write") ||
59
+ cmd.includes("post-write"))
60
+ );
61
+ }
62
+
63
+ function isMinkHook(entry: HookEntry | Record<string, unknown>): boolean {
64
+ // Handle current format: { matcher, hooks: [{ type, command }] }
65
+ if (Array.isArray((entry as HookEntry).hooks)) {
66
+ return (entry as HookEntry).hooks.some((h) => isMinkCommand(h.command));
67
+ }
68
+ // Handle legacy format: { matcher, command }
69
+ if (typeof (entry as Record<string, unknown>).command === "string") {
70
+ return isMinkCommand((entry as Record<string, unknown>).command as string);
71
+ }
72
+ return false;
73
+ }
74
+
75
+ export function mergeHooksIntoSettings(
76
+ settingsPath: string,
77
+ newHooks: HooksConfig
78
+ ): void {
79
+ mkdirSync(dirname(settingsPath), { recursive: true });
80
+
81
+ const existing = (safeReadJson(settingsPath) as Record<string, unknown>) ?? {};
82
+ const existingHooks = (existing.hooks ?? {}) as HooksConfig;
83
+
84
+ // For each hook type mink manages, remove old mink entries then add new ones
85
+ for (const [event, entries] of Object.entries(newHooks)) {
86
+ const current = existingHooks[event] ?? [];
87
+ const withoutMink = current.filter((e) => !isMinkHook(e));
88
+ existingHooks[event] = [...withoutMink, ...entries];
89
+ }
90
+
91
+ existing.hooks = existingHooks;
92
+ atomicWriteJson(settingsPath, existing);
93
+ }
94
+
95
+ function isExistingInstallation(cwd: string): boolean {
96
+ const dir = projectDir(cwd);
97
+ if (!existsSync(dir)) return false;
98
+ return existsSync(join(dir, "file-index.json"));
99
+ }
100
+
101
+ export async function init(cwd: string): Promise<void> {
102
+ const runtime = detectRuntime();
103
+ const cliPath = resolve(dirname(new URL(import.meta.url).pathname), "../cli.ts");
104
+ const hooks = buildHooksConfig(runtime, cliPath);
105
+ const settingsPath = resolve(cwd, ".claude", "settings.json");
106
+ const dir = projectDir(cwd);
107
+ const upgrading = isExistingInstallation(cwd);
108
+
109
+ if (upgrading) {
110
+ console.log("[mink] existing installation detected, upgrading...");
111
+ const { createBackup } = await import("../core/backup");
112
+ const backupName = createBackup(cwd);
113
+ console.log(` backup: ${backupName}`);
114
+ }
115
+
116
+ mergeHooksIntoSettings(settingsPath, hooks);
117
+
118
+ mkdirSync(dir, { recursive: true });
119
+
120
+ const projectId = generateProjectId(cwd);
121
+
122
+ // Write project metadata
123
+ const metaPath = projectMetaPath(cwd);
124
+ const existingMeta = safeReadJson(metaPath) as Record<string, unknown> | null;
125
+ atomicWriteJson(metaPath, {
126
+ ...(existingMeta ?? {}),
127
+ cwd,
128
+ name: basename(cwd),
129
+ initTimestamp: existingMeta?.initTimestamp ?? new Date().toISOString(),
130
+ version: "0.1.0",
131
+ });
132
+
133
+ if (upgrading) {
134
+ console.log(`[mink] upgrade complete`);
135
+ console.log(` project: ${projectId}`);
136
+ console.log(` hooks: ${settingsPath}`);
137
+ } else {
138
+ console.log(`[mink] initialized`);
139
+ console.log(` project: ${projectId}`);
140
+ console.log(` state: ${dir}`);
141
+ console.log(` runtime: ${runtime}`);
142
+ console.log(` hooks: ${settingsPath}`);
143
+ }
144
+
145
+ // Run initial scan
146
+ const { scan } = await import("./scan");
147
+ scan(cwd, { check: false });
148
+
149
+ // Seed learning memory if it doesn't exist
150
+ const { learningMemoryPath } = await import("../core/paths");
151
+ const memPath = learningMemoryPath(cwd);
152
+ if (!existsSync(memPath)) {
153
+ const { seedLearningMemory } = await import("../core/seed");
154
+ const { serializeLearningMemory } = await import("../core/learning-memory");
155
+ const { atomicWriteText } = await import("../core/fs-utils");
156
+ const mem = seedLearningMemory(cwd);
157
+ atomicWriteText(memPath, serializeLearningMemory(mem));
158
+ }
159
+ }
@@ -0,0 +1,123 @@
1
+ import { relative } from "path";
2
+ import { readStdinJson } from "../core/stdin";
3
+ import { sessionPath, fileIndexPath, actionLogPath } from "../core/paths";
4
+ import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
5
+ import { createSessionState, isSessionState, recordRead } from "../core/session";
6
+ import { isFileIndex, lookupEntry } from "../core/index-store";
7
+ import { estimateTokens, isBinaryFile } from "../core/token-estimate";
8
+ import { createActionLogWriter } from "../core/action-log";
9
+ import type { SessionState } from "../types/session";
10
+ import type { FileIndex } from "../types/file-index";
11
+ import type { PostToolUseInput } from "../types/hook-input";
12
+
13
+ export interface PostReadResult {
14
+ estimatedTokens: number;
15
+ indexHit: boolean;
16
+ source: "content" | "index-fallback" | "none";
17
+ }
18
+
19
+ export function analyzePostRead(
20
+ filePath: string,
21
+ content: string | null,
22
+ index: FileIndex | null
23
+ ): PostReadResult {
24
+ // Binary file — skip token estimation
25
+ if (isBinaryFile(filePath, content ?? undefined)) {
26
+ const entry = index ? lookupEntry(index, filePath) : null;
27
+ return { estimatedTokens: 0, indexHit: !!entry, source: "none" };
28
+ }
29
+
30
+ // Content available — estimate from actual content
31
+ if (content !== null && content.length > 0) {
32
+ const entry = index ? lookupEntry(index, filePath) : null;
33
+ return {
34
+ estimatedTokens: estimateTokens(content, filePath),
35
+ indexHit: !!entry,
36
+ source: "content",
37
+ };
38
+ }
39
+
40
+ // No content — try file index fallback
41
+ if (index) {
42
+ const entry = lookupEntry(index, filePath);
43
+ if (entry) {
44
+ return {
45
+ estimatedTokens: entry.estimatedTokens,
46
+ indexHit: true,
47
+ source: "index-fallback",
48
+ };
49
+ }
50
+ }
51
+
52
+ return { estimatedTokens: 0, indexHit: false, source: "none" };
53
+ }
54
+
55
+ function isPostToolUseInput(value: unknown): value is PostToolUseInput {
56
+ if (value === null || typeof value !== "object") return false;
57
+ const obj = value as Record<string, unknown>;
58
+ if (typeof obj.tool_name !== "string") return false;
59
+ if (typeof obj.tool_input !== "object" || obj.tool_input === null) return false;
60
+ return true;
61
+ }
62
+
63
+ function extractContent(input: PostToolUseInput): string | null {
64
+ if (!input.tool_output) return null;
65
+ if (typeof input.tool_output.content === "string") {
66
+ return input.tool_output.content;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export async function postRead(cwd: string): Promise<void> {
72
+ // 5-second safety timeout
73
+ const timer = setTimeout(() => process.exit(0), 5000);
74
+
75
+ try {
76
+ const input = await readStdinJson();
77
+ if (!isPostToolUseInput(input)) return;
78
+ if (input.tool_name !== "Read") return;
79
+
80
+ const absolutePath = input.tool_input.file_path;
81
+ if (!absolutePath) return;
82
+
83
+ const filePath = relative(cwd, absolutePath);
84
+
85
+ // Load session state (create fresh if missing)
86
+ const rawState = safeReadJson(sessionPath(cwd));
87
+ const state: SessionState = isSessionState(rawState)
88
+ ? rawState
89
+ : createSessionState();
90
+
91
+ // Load file index for token fallback and indexHit determination
92
+ const rawIndex = safeReadJson(fileIndexPath(cwd));
93
+ const index: FileIndex | null = isFileIndex(rawIndex) ? rawIndex : null;
94
+
95
+ // Extract content from tool output
96
+ const content = extractContent(input);
97
+
98
+ const result = analyzePostRead(filePath, content, index);
99
+
100
+ // Record the read in session state
101
+ recordRead(state, filePath, result.estimatedTokens, result.indexHit);
102
+
103
+ // Append read entry to action log
104
+ try {
105
+ const logWriter = createActionLogWriter(actionLogPath(cwd));
106
+ logWriter.appendReadEntry(
107
+ new Date().toISOString(),
108
+ filePath,
109
+ result.indexHit,
110
+ result.estimatedTokens
111
+ );
112
+ } catch {
113
+ // Never crash
114
+ }
115
+
116
+ // Persist state
117
+ atomicWriteJson(sessionPath(cwd), state);
118
+ } catch {
119
+ // Never crash — exit silently
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }
@@ -0,0 +1,157 @@
1
+ import { relative } from "path";
2
+ import { readFileSync } from "fs";
3
+ import { readStdinJson } from "../core/stdin";
4
+ import { sessionPath, fileIndexPath, actionLogPath } from "../core/paths";
5
+ import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
6
+ import { createSessionState, isSessionState, recordWrite } from "../core/session";
7
+ import {
8
+ isFileIndex,
9
+ lookupEntry,
10
+ upsertEntry,
11
+ createEmptyIndex,
12
+ } from "../core/index-store";
13
+ import { extractDescription } from "../core/description";
14
+ import { estimateTokens, isBinaryFile } from "../core/token-estimate";
15
+ import { isWriteExcluded } from "../core/write-exclusions";
16
+ import { createActionLogWriter } from "../core/action-log";
17
+ import type { SessionState } from "../types/session";
18
+ import type { FileIndex, FileIndexEntry } from "../types/file-index";
19
+ import type { PostToolUseInput } from "../types/hook-input";
20
+
21
+ export interface PostWriteResult {
22
+ excluded: boolean;
23
+ action: "create" | "edit";
24
+ estimatedTokens: number;
25
+ description: string;
26
+ indexEntry: FileIndexEntry | null;
27
+ }
28
+
29
+ export function analyzePostWrite(
30
+ filePath: string,
31
+ fileContent: string | null,
32
+ index: FileIndex | null
33
+ ): PostWriteResult {
34
+ // Check exclusions
35
+ if (isWriteExcluded(filePath)) {
36
+ return {
37
+ excluded: true,
38
+ action: "edit",
39
+ estimatedTokens: 0,
40
+ description: "",
41
+ indexEntry: null,
42
+ };
43
+ }
44
+
45
+ // Determine action from index presence
46
+ const existingEntry = index ? lookupEntry(index, filePath) : null;
47
+ const action: "create" | "edit" = existingEntry ? "edit" : "create";
48
+
49
+ // Handle binary or unreadable content
50
+ if (fileContent === null || isBinaryFile(filePath, fileContent)) {
51
+ return {
52
+ excluded: false,
53
+ action,
54
+ estimatedTokens: 0,
55
+ description: "",
56
+ indexEntry: null,
57
+ };
58
+ }
59
+
60
+ // Extract description and estimate tokens
61
+ const description = extractDescription(filePath, fileContent);
62
+ const tokens = estimateTokens(fileContent, filePath);
63
+
64
+ // Build index entry
65
+ const now = new Date().toISOString();
66
+ const indexEntry: FileIndexEntry = {
67
+ filePath,
68
+ description,
69
+ estimatedTokens: tokens,
70
+ lastModified: now,
71
+ lastIndexed: now,
72
+ };
73
+
74
+ return {
75
+ excluded: false,
76
+ action,
77
+ estimatedTokens: tokens,
78
+ description,
79
+ indexEntry,
80
+ };
81
+ }
82
+
83
+ function isPostToolUseInput(value: unknown): value is PostToolUseInput {
84
+ if (value === null || typeof value !== "object") return false;
85
+ const obj = value as Record<string, unknown>;
86
+ if (typeof obj.tool_name !== "string") return false;
87
+ if (typeof obj.tool_input !== "object" || obj.tool_input === null) return false;
88
+ return true;
89
+ }
90
+
91
+ export async function postWrite(cwd: string): Promise<void> {
92
+ // 10-second safety timeout (longer due to file I/O + index updates)
93
+ const timer = setTimeout(() => process.exit(0), 10000);
94
+
95
+ try {
96
+ const input = await readStdinJson();
97
+ if (!isPostToolUseInput(input)) return;
98
+ if (input.tool_name !== "Write" && input.tool_name !== "Edit") return;
99
+
100
+ const absolutePath = input.tool_input.file_path;
101
+ if (!absolutePath) return;
102
+
103
+ const filePath = relative(cwd, absolutePath);
104
+
105
+ // Read file content from disk (post-write — file now exists)
106
+ let fileContent: string | null = null;
107
+ try {
108
+ fileContent = readFileSync(absolutePath, "utf-8");
109
+ } catch {
110
+ // File unreadable (permissions, race condition) — continue with null
111
+ }
112
+
113
+ // Load session state
114
+ const rawState = safeReadJson(sessionPath(cwd));
115
+ const state: SessionState = isSessionState(rawState)
116
+ ? rawState
117
+ : createSessionState();
118
+
119
+ // Load file index
120
+ const rawIndex = safeReadJson(fileIndexPath(cwd));
121
+ const index: FileIndex = isFileIndex(rawIndex) ? rawIndex : createEmptyIndex();
122
+
123
+ const result = analyzePostWrite(filePath, fileContent, index);
124
+
125
+ if (result.excluded) return;
126
+
127
+ // 1. File index update
128
+ if (result.indexEntry) {
129
+ upsertEntry(index, result.indexEntry);
130
+ }
131
+
132
+ // 2. Action log entry
133
+ try {
134
+ const logWriter = createActionLogWriter(actionLogPath(cwd));
135
+ logWriter.appendWriteEntry(
136
+ new Date().toISOString(),
137
+ filePath,
138
+ result.action,
139
+ result.description,
140
+ result.estimatedTokens
141
+ );
142
+ } catch {
143
+ // Never crash
144
+ }
145
+
146
+ // 3. Session state update
147
+ recordWrite(state, filePath, result.action, result.estimatedTokens);
148
+
149
+ // Persist state changes
150
+ atomicWriteJson(sessionPath(cwd), state);
151
+ atomicWriteJson(fileIndexPath(cwd), index);
152
+ } catch {
153
+ // Never crash — exit silently
154
+ } finally {
155
+ clearTimeout(timer);
156
+ }
157
+ }
@@ -0,0 +1,109 @@
1
+ import { relative } from "path";
2
+ import { readStdinJson } from "../core/stdin";
3
+ import { sessionPath, fileIndexPath } from "../core/paths";
4
+ import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
5
+ import { createSessionState, isSessionState } from "../core/session";
6
+ import {
7
+ isFileIndex,
8
+ lookupEntry,
9
+ recordHit,
10
+ recordMiss,
11
+ } from "../core/index-store";
12
+ import type { SessionState } from "../types/session";
13
+ import type { FileIndex, FileIndexEntry } from "../types/file-index";
14
+ import type { PreToolUseInput } from "../types/hook-input";
15
+
16
+ export interface PreReadResult {
17
+ warnings: string[];
18
+ indexHit: boolean;
19
+ repeatedRead: boolean;
20
+ entry: FileIndexEntry | null;
21
+ }
22
+
23
+ export function analyzePreRead(
24
+ filePath: string,
25
+ state: SessionState,
26
+ index: FileIndex | null
27
+ ): PreReadResult {
28
+ const warnings: string[] = [];
29
+ let repeatedRead = false;
30
+ let indexHit = false;
31
+ let entry: FileIndexEntry | null = null;
32
+
33
+ // Check for repeated read
34
+ const existing = state.reads[filePath];
35
+ if (existing) {
36
+ repeatedRead = true;
37
+ warnings.push(
38
+ `[mink] ${filePath} was already read this session (~${existing.estimatedTokens} tokens)`
39
+ );
40
+ state.counters.repeatedReadWarnings++;
41
+ }
42
+
43
+ // File index lookup
44
+ if (index) {
45
+ entry = lookupEntry(index, filePath);
46
+ if (entry) {
47
+ indexHit = true;
48
+ recordHit(index);
49
+ warnings.push(
50
+ `[mink] ${filePath} — ${entry.description} (~${entry.estimatedTokens} tokens)`
51
+ );
52
+ } else {
53
+ recordMiss(index);
54
+ }
55
+ }
56
+
57
+ return { warnings, indexHit, repeatedRead, entry };
58
+ }
59
+
60
+ function isPreToolUseInput(value: unknown): value is PreToolUseInput {
61
+ if (value === null || typeof value !== "object") return false;
62
+ const obj = value as Record<string, unknown>;
63
+ if (typeof obj.tool_name !== "string") return false;
64
+ if (typeof obj.tool_input !== "object" || obj.tool_input === null) return false;
65
+ return true;
66
+ }
67
+
68
+ export async function preRead(cwd: string): Promise<void> {
69
+ // 5-second safety timeout
70
+ const timer = setTimeout(() => process.exit(0), 5000);
71
+
72
+ try {
73
+ const input = await readStdinJson();
74
+ if (!isPreToolUseInput(input)) return;
75
+ if (input.tool_name !== "Read") return;
76
+
77
+ const absolutePath = input.tool_input.file_path;
78
+ if (!absolutePath) return;
79
+
80
+ const filePath = relative(cwd, absolutePath);
81
+
82
+ // Load session state (create fresh if missing)
83
+ const rawState = safeReadJson(sessionPath(cwd));
84
+ const state: SessionState = isSessionState(rawState)
85
+ ? rawState
86
+ : createSessionState();
87
+
88
+ // Load file index (null if missing/corrupt)
89
+ const rawIndex = safeReadJson(fileIndexPath(cwd));
90
+ const index: FileIndex | null = isFileIndex(rawIndex) ? rawIndex : null;
91
+
92
+ const result = analyzePreRead(filePath, state, index);
93
+
94
+ // Emit warnings to stderr
95
+ for (const warning of result.warnings) {
96
+ process.stderr.write(warning + "\n");
97
+ }
98
+
99
+ // Persist state changes
100
+ atomicWriteJson(sessionPath(cwd), state);
101
+ if (index) {
102
+ atomicWriteJson(fileIndexPath(cwd), index);
103
+ }
104
+ } catch {
105
+ // Never crash — exit silently
106
+ } finally {
107
+ clearTimeout(timer);
108
+ }
109
+ }
@@ -0,0 +1,136 @@
1
+ import { relative } from "path";
2
+ import { readFileSync } from "fs";
3
+ import { readStdinJson } from "../core/stdin";
4
+ import { sessionPath, learningMemoryPath, bugMemoryPath } from "../core/paths";
5
+ import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
6
+ import { createSessionState, isSessionState } from "../core/session";
7
+ import { parseLearningMemory, getEntries } from "../core/learning-memory";
8
+ import { extractPatterns, matchPatterns } from "../core/pattern-engine";
9
+ import {
10
+ loadBugMemory,
11
+ lookupBugsForFile,
12
+ formatBugSummary,
13
+ } from "../core/bug-memory";
14
+ import type { SessionState } from "../types/session";
15
+ import type { PatternMatch } from "../types/learning-memory";
16
+ import type { BugMemory } from "../types/bug-memory";
17
+ import type { PreToolUseInput } from "../types/hook-input";
18
+
19
+ export interface PreWriteResult {
20
+ warnings: string[];
21
+ patternMatches: PatternMatch[];
22
+ bugSummary: string | null;
23
+ }
24
+
25
+ export function analyzePreWrite(
26
+ filePath: string,
27
+ writeContent: string,
28
+ doNotRepeatEntries: string[],
29
+ bugMemory?: BugMemory
30
+ ): PreWriteResult {
31
+ const warnings: string[] = [];
32
+ const allMatches: PatternMatch[] = [];
33
+
34
+ // 1. Learning memory enforcement
35
+ if (doNotRepeatEntries.length > 0 && writeContent.length > 0) {
36
+ const patterns = extractPatterns(doNotRepeatEntries);
37
+ const matches = matchPatterns(patterns, writeContent);
38
+ allMatches.push(...matches);
39
+
40
+ for (const match of matches) {
41
+ warnings.push(
42
+ `[mink] Do-Not-Repeat violation: "${match.matchedText}" — from: ${match.pattern.sourceEntry}`
43
+ );
44
+ }
45
+ }
46
+
47
+ // 2. Bug memory lookup — surface known bugs for this file
48
+ let bugSummary: string | null = null;
49
+ if (bugMemory) {
50
+ const bugEntries = lookupBugsForFile(bugMemory, filePath);
51
+ bugSummary = formatBugSummary(bugEntries);
52
+ }
53
+
54
+ return { warnings, patternMatches: allMatches, bugSummary };
55
+ }
56
+
57
+ function isPreToolUseInput(value: unknown): value is PreToolUseInput {
58
+ if (value === null || typeof value !== "object") return false;
59
+ const obj = value as Record<string, unknown>;
60
+ if (typeof obj.tool_name !== "string") return false;
61
+ if (typeof obj.tool_input !== "object" || obj.tool_input === null) return false;
62
+ return true;
63
+ }
64
+
65
+ function extractWriteContent(input: PreToolUseInput): string {
66
+ const ti = input.tool_input;
67
+ if (input.tool_name === "Write" && typeof ti.content === "string") {
68
+ return ti.content;
69
+ }
70
+ if (input.tool_name === "Edit" && typeof ti.new_string === "string") {
71
+ return ti.new_string;
72
+ }
73
+ return "";
74
+ }
75
+
76
+ export async function preWrite(cwd: string): Promise<void> {
77
+ // 5-second safety timeout
78
+ const timer = setTimeout(() => process.exit(0), 5000);
79
+
80
+ try {
81
+ const input = await readStdinJson();
82
+ if (!isPreToolUseInput(input)) return;
83
+ if (input.tool_name !== "Write" && input.tool_name !== "Edit") return;
84
+
85
+ const absolutePath = input.tool_input.file_path;
86
+ if (!absolutePath) return;
87
+
88
+ const filePath = relative(cwd, absolutePath);
89
+ const writeContent = extractWriteContent(input);
90
+
91
+ // Load learning memory Do-Not-Repeat entries
92
+ let doNotRepeatEntries: string[] = [];
93
+ try {
94
+ const markdown = readFileSync(learningMemoryPath(cwd), "utf-8");
95
+ const mem = parseLearningMemory(markdown);
96
+ doNotRepeatEntries = getEntries(mem, "Do-Not-Repeat");
97
+ } catch {
98
+ // Learning memory not found or corrupt — skip enforcement
99
+ }
100
+
101
+ // Load bug memory for this file
102
+ let bugMemory: BugMemory | undefined;
103
+ try {
104
+ bugMemory = loadBugMemory(bugMemoryPath(cwd));
105
+ } catch {
106
+ // Bug memory not found or corrupt — skip lookup
107
+ }
108
+
109
+ const result = analyzePreWrite(filePath, writeContent, doNotRepeatEntries, bugMemory);
110
+
111
+ // Emit warnings to stderr (advisory only)
112
+ for (const warning of result.warnings) {
113
+ process.stderr.write(warning + "\n");
114
+ }
115
+
116
+ // Emit bug summary to stderr if there are known bugs for this file
117
+ if (result.bugSummary) {
118
+ process.stderr.write(result.bugSummary + "\n");
119
+ }
120
+
121
+ // Update session counters if there were pattern matches
122
+ if (result.patternMatches.length > 0) {
123
+ const rawState = safeReadJson(sessionPath(cwd));
124
+ const state: SessionState = isSessionState(rawState)
125
+ ? rawState
126
+ : createSessionState();
127
+
128
+ state.counters.learnedRuleWarnings += result.patternMatches.length;
129
+ atomicWriteJson(sessionPath(cwd), state);
130
+ }
131
+ } catch {
132
+ // Never crash — exit silently
133
+ } finally {
134
+ clearTimeout(timer);
135
+ }
136
+ }