@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.
- package/README.md +347 -0
- package/package.json +32 -0
- package/src/cli.ts +176 -0
- package/src/commands/bug-search.ts +32 -0
- package/src/commands/config.ts +109 -0
- package/src/commands/cron.ts +295 -0
- package/src/commands/daemon.ts +46 -0
- package/src/commands/dashboard.ts +21 -0
- package/src/commands/designqc.ts +160 -0
- package/src/commands/detect-waste.ts +81 -0
- package/src/commands/framework-advisor.ts +52 -0
- package/src/commands/init.ts +159 -0
- package/src/commands/post-read.ts +123 -0
- package/src/commands/post-write.ts +157 -0
- package/src/commands/pre-read.ts +109 -0
- package/src/commands/pre-write.ts +136 -0
- package/src/commands/reflect.ts +39 -0
- package/src/commands/restore.ts +31 -0
- package/src/commands/scan.ts +101 -0
- package/src/commands/session-start.ts +21 -0
- package/src/commands/session-stop.ts +115 -0
- package/src/commands/status.ts +152 -0
- package/src/commands/update.ts +121 -0
- package/src/core/action-log.ts +341 -0
- package/src/core/backup.ts +122 -0
- package/src/core/bug-memory.ts +223 -0
- package/src/core/cron-parser.ts +94 -0
- package/src/core/daemon.ts +152 -0
- package/src/core/dashboard-api.ts +280 -0
- package/src/core/dashboard-server.ts +580 -0
- package/src/core/description.ts +232 -0
- package/src/core/design-eval/capture.ts +269 -0
- package/src/core/design-eval/route-detect.ts +165 -0
- package/src/core/design-eval/server-detect.ts +91 -0
- package/src/core/framework-advisor/catalog.ts +360 -0
- package/src/core/framework-advisor/decision-tree.ts +287 -0
- package/src/core/framework-advisor/generate.ts +132 -0
- package/src/core/framework-advisor/migration-prompts.ts +502 -0
- package/src/core/framework-advisor/validate.ts +137 -0
- package/src/core/fs-utils.ts +30 -0
- package/src/core/global-config.ts +74 -0
- package/src/core/index-store.ts +72 -0
- package/src/core/learning-memory.ts +120 -0
- package/src/core/paths.ts +86 -0
- package/src/core/pattern-engine.ts +108 -0
- package/src/core/project-id.ts +19 -0
- package/src/core/project-registry.ts +64 -0
- package/src/core/reflection.ts +256 -0
- package/src/core/scanner.ts +99 -0
- package/src/core/scheduler.ts +352 -0
- package/src/core/seed.ts +239 -0
- package/src/core/session.ts +128 -0
- package/src/core/stdin.ts +13 -0
- package/src/core/task-registry.ts +202 -0
- package/src/core/token-estimate.ts +36 -0
- package/src/core/token-ledger.ts +185 -0
- package/src/core/waste-detection.ts +214 -0
- package/src/core/write-exclusions.ts +24 -0
- package/src/types/action-log.ts +20 -0
- package/src/types/backup.ts +6 -0
- package/src/types/bug-memory.ts +24 -0
- package/src/types/config.ts +59 -0
- package/src/types/dashboard.ts +104 -0
- package/src/types/design-eval.ts +64 -0
- package/src/types/file-index.ts +38 -0
- package/src/types/framework-advisor.ts +97 -0
- package/src/types/hook-input.ts +27 -0
- package/src/types/learning-memory.ts +36 -0
- package/src/types/scheduler.ts +82 -0
- package/src/types/session.ts +50 -0
- package/src/types/token-ledger.ts +43 -0
- 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
|
+
}
|