@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,94 @@
1
+ import type { CronSchedule } from "../types/scheduler";
2
+
3
+ // ── Field Parsing ───────────────────────────────────────────────────────────
4
+
5
+ function parseField(field: string, min: number, max: number): number[] {
6
+ const values = new Set<number>();
7
+
8
+ for (const part of field.split(",")) {
9
+ if (part === "*") {
10
+ for (let i = min; i <= max; i++) values.add(i);
11
+ } else if (part.includes("/")) {
12
+ const [range, stepStr] = part.split("/");
13
+ const step = parseInt(stepStr, 10);
14
+ if (isNaN(step) || step <= 0) {
15
+ throw new Error(`Invalid step value: ${part}`);
16
+ }
17
+ const start = range === "*" ? min : parseInt(range, 10);
18
+ if (isNaN(start) || start < min || start > max) {
19
+ throw new Error(`Invalid range start: ${part}`);
20
+ }
21
+ for (let i = start; i <= max; i += step) values.add(i);
22
+ } else if (part.includes("-")) {
23
+ const [lo, hi] = part.split("-").map(Number);
24
+ if (isNaN(lo) || isNaN(hi) || lo < min || hi > max || lo > hi) {
25
+ throw new Error(`Invalid range: ${part}`);
26
+ }
27
+ for (let i = lo; i <= hi; i++) values.add(i);
28
+ } else {
29
+ const n = parseInt(part, 10);
30
+ if (isNaN(n) || n < min || n > max) {
31
+ throw new Error(`Invalid value: ${part} (must be ${min}-${max})`);
32
+ }
33
+ values.add(n);
34
+ }
35
+ }
36
+
37
+ return [...values].sort((a, b) => a - b);
38
+ }
39
+
40
+ // ── Public API ──────────────────────────────────────────────────────────────
41
+
42
+ export function parseCronExpression(expr: string): CronSchedule {
43
+ const fields = expr.trim().split(/\s+/);
44
+ if (fields.length !== 5) {
45
+ throw new Error(
46
+ `Invalid cron expression: expected 5 fields, got ${fields.length}`
47
+ );
48
+ }
49
+
50
+ return {
51
+ minute: parseField(fields[0], 0, 59),
52
+ hour: parseField(fields[1], 0, 23),
53
+ dayOfMonth: parseField(fields[2], 1, 31),
54
+ month: parseField(fields[3], 1, 12),
55
+ dayOfWeek: parseField(fields[4], 0, 6),
56
+ };
57
+ }
58
+
59
+ function matches(schedule: CronSchedule, date: Date): boolean {
60
+ return (
61
+ schedule.minute.includes(date.getUTCMinutes()) &&
62
+ schedule.hour.includes(date.getUTCHours()) &&
63
+ schedule.dayOfMonth.includes(date.getUTCDate()) &&
64
+ schedule.month.includes(date.getUTCMonth() + 1) &&
65
+ schedule.dayOfWeek.includes(date.getUTCDay())
66
+ );
67
+ }
68
+
69
+ export function nextRunAfter(schedule: CronSchedule, after: Date): Date {
70
+ // Start from the next whole minute
71
+ const candidate = new Date(after.getTime());
72
+ candidate.setUTCSeconds(0, 0);
73
+ candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
74
+
75
+ const limit = after.getTime() + 366 * 24 * 60 * 60 * 1000;
76
+
77
+ while (candidate.getTime() <= limit) {
78
+ if (matches(schedule, candidate)) {
79
+ return candidate;
80
+ }
81
+ candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
82
+ }
83
+
84
+ throw new Error("No matching time found within 366 days");
85
+ }
86
+
87
+ export function isInCurrentPeriod(
88
+ schedule: CronSchedule,
89
+ lastRun: Date,
90
+ now: Date
91
+ ): boolean {
92
+ const next = nextRunAfter(schedule, lastRun);
93
+ return now.getTime() < next.getTime();
94
+ }
@@ -0,0 +1,152 @@
1
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, openSync } from "fs";
2
+ import { mkdirSync } from "fs";
3
+ import { dirname, resolve } from "path";
4
+ import { schedulerPidPath, schedulerLogPath } from "./paths";
5
+ import type { PidFileData } from "../types/scheduler";
6
+
7
+ // ── PID File Operations ─────────────────────────────────────────────────────
8
+
9
+ export function readPidFile(): PidFileData | null {
10
+ try {
11
+ const raw = readFileSync(schedulerPidPath(), "utf-8");
12
+ const data = JSON.parse(raw);
13
+ if (
14
+ data &&
15
+ typeof data.pid === "number" &&
16
+ typeof data.startedAt === "string" &&
17
+ typeof data.projectCwd === "string"
18
+ ) {
19
+ return data as PidFileData;
20
+ }
21
+ return null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function writePidFile(data: PidFileData): void {
28
+ const pidPath = schedulerPidPath();
29
+ mkdirSync(dirname(pidPath), { recursive: true });
30
+ writeFileSync(pidPath, JSON.stringify(data, null, 2));
31
+ }
32
+
33
+ export function removePidFile(): void {
34
+ try {
35
+ unlinkSync(schedulerPidPath());
36
+ } catch {
37
+ // Already removed
38
+ }
39
+ }
40
+
41
+ export function isProcessAlive(pid: number): boolean {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ // ── Daemon Lifecycle ────────────────────────────────────────────────────────
51
+
52
+ export function startDaemon(cwd: string): void {
53
+ const existing = readPidFile();
54
+ if (existing && isProcessAlive(existing.pid)) {
55
+ console.log(
56
+ `[mink] scheduler is already running (PID: ${existing.pid})`
57
+ );
58
+ return;
59
+ }
60
+
61
+ // Clean up stale PID file
62
+ if (existing) {
63
+ removePidFile();
64
+ }
65
+
66
+ // Resolve the CLI entry point
67
+ const cliPath = resolve(import.meta.dir, "../cli.ts");
68
+
69
+ // Ensure log directory exists
70
+ const logPath = schedulerLogPath();
71
+ mkdirSync(dirname(logPath), { recursive: true });
72
+ const logFd = openSync(logPath, "a");
73
+
74
+ const proc = Bun.spawn(["bun", "run", cliPath, "cron", "__daemon"], {
75
+ cwd,
76
+ stdout: logFd,
77
+ stderr: logFd,
78
+ stdin: "ignore",
79
+ env: process.env,
80
+ });
81
+
82
+ // Unref so parent can exit
83
+ proc.unref();
84
+
85
+ writePidFile({
86
+ pid: proc.pid,
87
+ startedAt: new Date().toISOString(),
88
+ projectCwd: cwd,
89
+ });
90
+
91
+ console.log(`[mink] scheduler started (PID: ${proc.pid})`);
92
+ console.log(`[mink] log: ${logPath}`);
93
+ }
94
+
95
+ export async function stopDaemon(): Promise<void> {
96
+ const pidData = readPidFile();
97
+ if (!pidData) {
98
+ console.log("[mink] scheduler is not running (no PID file)");
99
+ return;
100
+ }
101
+
102
+ if (!isProcessAlive(pidData.pid)) {
103
+ console.log("[mink] scheduler is not running (stale PID file)");
104
+ removePidFile();
105
+ return;
106
+ }
107
+
108
+ // Send SIGTERM
109
+ process.kill(pidData.pid, "SIGTERM");
110
+ console.log(`[mink] sent SIGTERM to PID ${pidData.pid}`);
111
+
112
+ // Poll for up to 5 seconds
113
+ for (let i = 0; i < 50; i++) {
114
+ await new Promise((r) => setTimeout(r, 100));
115
+ if (!isProcessAlive(pidData.pid)) {
116
+ removePidFile();
117
+ console.log("[mink] scheduler stopped");
118
+ return;
119
+ }
120
+ }
121
+
122
+ // Force kill
123
+ try {
124
+ process.kill(pidData.pid, "SIGKILL");
125
+ } catch {
126
+ // Process may have just exited
127
+ }
128
+ removePidFile();
129
+ console.log("[mink] scheduler force-stopped (SIGKILL)");
130
+ }
131
+
132
+ export function getDaemonStatus(cwd: string): {
133
+ running: boolean;
134
+ pid?: number;
135
+ startedAt?: string;
136
+ projectCwd?: string;
137
+ } {
138
+ const pidData = readPidFile();
139
+ if (!pidData) {
140
+ return { running: false };
141
+ }
142
+ if (!isProcessAlive(pidData.pid)) {
143
+ removePidFile();
144
+ return { running: false };
145
+ }
146
+ return {
147
+ running: true,
148
+ pid: pidData.pid,
149
+ startedAt: pidData.startedAt,
150
+ projectCwd: pidData.projectCwd,
151
+ };
152
+ }
@@ -0,0 +1,280 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import {
3
+ projectDir,
4
+ fileIndexPath,
5
+ tokenLedgerPath,
6
+ bugMemoryPath,
7
+ actionLogPath,
8
+ learningMemoryPath,
9
+ projectMetaPath,
10
+ sessionPath,
11
+ configPath,
12
+ schedulerManifestPath,
13
+ designReportPath,
14
+ } from "./paths";
15
+ import { safeReadJson } from "./fs-utils";
16
+ import { isFileIndex } from "./index-store";
17
+ import { loadLedger } from "./token-ledger";
18
+ import { parseLearningMemory } from "./learning-memory";
19
+ import { loadBugMemory } from "./bug-memory";
20
+ import { safeReadLog, parseLogSessions } from "./action-log";
21
+ import { getDaemonStatus } from "./daemon";
22
+ import { loadManifest, removeFromDeadLetter, saveManifest } from "./scheduler";
23
+ import { getBuiltInTasks, executeTask } from "./task-registry";
24
+ import type {
25
+ OverviewPayload,
26
+ TokenLedgerPayload,
27
+ FileIndexPayload,
28
+ SchedulerPayload,
29
+ BugLogPayload,
30
+ ActionLogPayload,
31
+ ActionResult,
32
+ FileStatus,
33
+ DesignPayload,
34
+ } from "../types/dashboard";
35
+ import { isDesignEvalReport } from "../types/design-eval";
36
+ import type { DesignEvalReport } from "../types/design-eval";
37
+ import type { FileIndex, FileIndexEntry } from "../types/file-index";
38
+ import type { LearningMemory } from "../types/learning-memory";
39
+
40
+ // ── File Status Checks ─────────────────────────────────────────────────────
41
+
42
+ function checkJsonFile(
43
+ name: string,
44
+ filePath: string,
45
+ validator?: (v: unknown) => boolean
46
+ ): FileStatus {
47
+ if (!existsSync(filePath)) return { name, status: "missing" };
48
+ const data = safeReadJson(filePath);
49
+ if (data === null) return { name, status: "corrupt" };
50
+ if (validator && !validator(data)) return { name, status: "corrupt" };
51
+ return { name, status: "ok" };
52
+ }
53
+
54
+ function checkTextFile(name: string, filePath: string): FileStatus {
55
+ if (!existsSync(filePath)) return { name, status: "missing" };
56
+ try {
57
+ readFileSync(filePath, "utf-8");
58
+ return { name, status: "ok" };
59
+ } catch {
60
+ return { name, status: "corrupt" };
61
+ }
62
+ }
63
+
64
+ // ── Panel Loaders ──────────────────────────────────────────────────────────
65
+
66
+ export function loadOverview(cwd: string): OverviewPayload {
67
+ // Project metadata
68
+ let project: OverviewPayload["project"] = null;
69
+ const meta = safeReadJson(projectMetaPath(cwd)) as {
70
+ name?: string;
71
+ description?: string;
72
+ } | null;
73
+ if (meta && typeof meta === "object") {
74
+ project = {
75
+ name: meta.name ?? "Unknown",
76
+ description: meta.description ?? "",
77
+ cwd,
78
+ };
79
+ }
80
+
81
+ // Daemon status
82
+ const daemonStatus = getDaemonStatus(cwd);
83
+ const daemon: OverviewPayload["daemon"] = {
84
+ running: daemonStatus.running,
85
+ pid: daemonStatus.pid,
86
+ startedAt: daemonStatus.startedAt,
87
+ uptimeMs: daemonStatus.startedAt
88
+ ? Date.now() - new Date(daemonStatus.startedAt).getTime()
89
+ : undefined,
90
+ };
91
+
92
+ // Token ledger summary
93
+ const ledger = loadLedger(tokenLedgerPath(cwd));
94
+ const summary = {
95
+ totalSessions: ledger.lifetime.totalSessions,
96
+ totalTokens: ledger.lifetime.totalTokens,
97
+ totalReads: ledger.lifetime.totalReads,
98
+ totalWrites: ledger.lifetime.totalWrites,
99
+ estimatedSavings: ledger.lifetime.totalEstimatedSavings,
100
+ };
101
+
102
+ // State file health
103
+ const stateFiles: FileStatus[] = [
104
+ checkJsonFile("session.json", sessionPath(cwd)),
105
+ checkJsonFile("file-index.json", fileIndexPath(cwd), isFileIndex),
106
+ checkJsonFile("config.json", configPath(cwd)),
107
+ checkTextFile("learning-memory.md", learningMemoryPath(cwd)),
108
+ checkJsonFile("token-ledger.json", tokenLedgerPath(cwd)),
109
+ checkJsonFile("bug-memory.json", bugMemoryPath(cwd)),
110
+ checkTextFile("action-log.md", actionLogPath(cwd)),
111
+ checkJsonFile("scheduler-manifest.json", schedulerManifestPath(cwd)),
112
+ ];
113
+
114
+ return { project, daemon, summary, stateFiles };
115
+ }
116
+
117
+ export function loadTokenLedgerPanel(cwd: string): TokenLedgerPayload {
118
+ const ledger = loadLedger(tokenLedgerPath(cwd));
119
+ return {
120
+ lifetime: ledger.lifetime,
121
+ sessions: ledger.sessions,
122
+ wasteFlags: ledger.wasteFlags ?? [],
123
+ };
124
+ }
125
+
126
+ export function loadFileIndexPanel(cwd: string): FileIndexPayload {
127
+ const raw = safeReadJson(fileIndexPath(cwd));
128
+ if (raw && isFileIndex(raw)) {
129
+ const index = raw as FileIndex;
130
+ const entries: FileIndexEntry[] = Object.values(index.entries);
131
+ return { header: index.header, entries };
132
+ }
133
+ return {
134
+ header: {
135
+ lastScanTimestamp: "",
136
+ totalFiles: 0,
137
+ lifetimeHits: 0,
138
+ lifetimeMisses: 0,
139
+ },
140
+ entries: [],
141
+ };
142
+ }
143
+
144
+ export function loadSchedulerPanel(cwd: string): SchedulerPayload {
145
+ const manifest = loadManifest(cwd);
146
+ const definitions = getBuiltInTasks();
147
+
148
+ const tasks = definitions.map((def) => {
149
+ const state = manifest?.tasks.find((t) => t.taskId === def.id) ?? null;
150
+ return { definition: def, state };
151
+ });
152
+
153
+ return {
154
+ tasks,
155
+ deadLetterQueue: manifest?.deadLetterQueue ?? [],
156
+ lastHeartbeat: manifest?.lastHeartbeat ?? null,
157
+ };
158
+ }
159
+
160
+ export function loadLearningMemoryPanel(cwd: string): LearningMemory {
161
+ const memPath = learningMemoryPath(cwd);
162
+ if (!existsSync(memPath)) {
163
+ return {
164
+ projectName: "unknown",
165
+ sections: {
166
+ "User Preferences": [],
167
+ "Key Learnings": [],
168
+ "Do-Not-Repeat": [],
169
+ "Decision Log": [],
170
+ },
171
+ };
172
+ }
173
+ try {
174
+ const content = readFileSync(memPath, "utf-8");
175
+ return parseLearningMemory(content);
176
+ } catch {
177
+ return {
178
+ projectName: "unknown",
179
+ sections: {
180
+ "User Preferences": [],
181
+ "Key Learnings": [],
182
+ "Do-Not-Repeat": [],
183
+ "Decision Log": [],
184
+ },
185
+ };
186
+ }
187
+ }
188
+
189
+ export function loadActionLogPanel(cwd: string): ActionLogPayload {
190
+ const content = safeReadLog(actionLogPath(cwd));
191
+ const sessions = parseLogSessions(content);
192
+ return { sessions };
193
+ }
194
+
195
+ export function loadBugLogPanel(cwd: string): BugLogPayload {
196
+ const memory = loadBugMemory(bugMemoryPath(cwd));
197
+ return { entries: memory.entries, nextId: memory.nextId };
198
+ }
199
+
200
+ export function loadDesignPanel(cwd: string): DesignPayload {
201
+ const raw = safeReadJson(designReportPath(cwd));
202
+ if (!raw || !isDesignEvalReport(raw)) {
203
+ return { images: [] };
204
+ }
205
+
206
+ const report = raw as DesignEvalReport;
207
+ return {
208
+ images: report.captures.map((c) => ({
209
+ url: `/api/design-images/${c.fileName}`,
210
+ route: c.route,
211
+ viewport: c.viewport,
212
+ section: c.section,
213
+ timestamp: c.timestamp,
214
+ })),
215
+ };
216
+ }
217
+
218
+ // ── Action Triggers ────────────────────────────────────────────────────────
219
+
220
+ export async function triggerTask(
221
+ cwd: string,
222
+ taskId: string
223
+ ): Promise<ActionResult> {
224
+ try {
225
+ await executeTask(taskId, cwd);
226
+ return { success: true };
227
+ } catch (err) {
228
+ return {
229
+ success: false,
230
+ error: err instanceof Error ? err.message : String(err),
231
+ };
232
+ }
233
+ }
234
+
235
+ export async function triggerDeadLetterRetry(
236
+ cwd: string,
237
+ taskId: string
238
+ ): Promise<ActionResult> {
239
+ try {
240
+ const manifest = loadManifest(cwd);
241
+ if (!manifest) {
242
+ return { success: false, error: "No scheduler manifest found" };
243
+ }
244
+
245
+ const entry = removeFromDeadLetter(manifest, taskId);
246
+ if (!entry) {
247
+ return { success: false, error: `Task ${taskId} not in dead letter queue` };
248
+ }
249
+
250
+ // Reset the task record
251
+ const record = manifest.tasks.find((t) => t.taskId === taskId);
252
+ if (record) {
253
+ record.status = "idle";
254
+ record.consecutiveFailures = 0;
255
+ record.currentAttempt = 0;
256
+ }
257
+
258
+ saveManifest(cwd, manifest);
259
+ await executeTask(taskId, cwd);
260
+ return { success: true };
261
+ } catch (err) {
262
+ return {
263
+ success: false,
264
+ error: err instanceof Error ? err.message : String(err),
265
+ };
266
+ }
267
+ }
268
+
269
+ export async function triggerRescan(cwd: string): Promise<ActionResult> {
270
+ try {
271
+ const { scan } = await import("../commands/scan");
272
+ scan(cwd, { check: false });
273
+ return { success: true };
274
+ } catch (err) {
275
+ return {
276
+ success: false,
277
+ error: err instanceof Error ? err.message : String(err),
278
+ };
279
+ }
280
+ }