@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,256 @@
1
+ import type { LearningMemory, SectionName, ReflectionResult } from "../types/learning-memory";
2
+ import { serializeLearningMemory } from "./learning-memory";
3
+ import { estimateTokens } from "./token-estimate";
4
+
5
+ // Trim order: Decision Log first → Key Learnings → User Preferences → Do-Not-Repeat last
6
+ const TRIM_ORDER: SectionName[] = [
7
+ "Decision Log",
8
+ "Key Learnings",
9
+ "User Preferences",
10
+ "Do-Not-Repeat",
11
+ ];
12
+
13
+ function normalizeWhitespace(s: string): string {
14
+ return s.replace(/\s+/g, " ").trim();
15
+ }
16
+
17
+ /**
18
+ * Extract the quoted pattern from a Do-Not-Repeat entry, e.g.
19
+ * `Don't use "var" in code` → `var`
20
+ * Only matches double-quoted strings to avoid matching apostrophes in contractions.
21
+ * Returns null if no double-quoted pattern found.
22
+ */
23
+ function extractQuotedPattern(entry: string): string | null {
24
+ const m = entry.match(/"([^"]+)"/);
25
+ return m ? m[1] : null;
26
+ }
27
+
28
+ /**
29
+ * Extract date from an entry in the form `[YYYY-MM-DD]`
30
+ * Returns the date string or null.
31
+ */
32
+ function extractDate(entry: string): string | null {
33
+ const m = entry.match(/\[(\d{4}-\d{2}-\d{2})\]/);
34
+ return m ? m[1] : null;
35
+ }
36
+
37
+ function deepCopy(mem: LearningMemory): LearningMemory {
38
+ return {
39
+ projectName: mem.projectName,
40
+ sections: {
41
+ "User Preferences": [...mem.sections["User Preferences"]],
42
+ "Key Learnings": [...mem.sections["Key Learnings"]],
43
+ "Do-Not-Repeat": [...mem.sections["Do-Not-Repeat"]],
44
+ "Decision Log": [...mem.sections["Decision Log"]],
45
+ },
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Merge duplicates within each section.
51
+ * - Exact duplicates (normalized whitespace) → keep one
52
+ * - Do-Not-Repeat: entries sharing same quoted pattern → merge, keep newer date
53
+ * Returns a new LearningMemory (does not mutate input).
54
+ */
55
+ export function mergeDuplicates(mem: LearningMemory): LearningMemory {
56
+ const result = deepCopy(mem);
57
+
58
+ const sectionNames: SectionName[] = [
59
+ "User Preferences",
60
+ "Key Learnings",
61
+ "Do-Not-Repeat",
62
+ "Decision Log",
63
+ ];
64
+
65
+ for (const section of sectionNames) {
66
+ const entries = result.sections[section];
67
+
68
+ if (section === "Do-Not-Repeat") {
69
+ // First pass: group by quoted pattern (where it exists)
70
+ const byQuotedPattern = new Map<string, string[]>();
71
+ const noPattern: string[] = [];
72
+
73
+ for (const entry of entries) {
74
+ const qp = extractQuotedPattern(entry);
75
+ if (qp !== null) {
76
+ if (!byQuotedPattern.has(qp)) {
77
+ byQuotedPattern.set(qp, []);
78
+ }
79
+ byQuotedPattern.get(qp)!.push(entry);
80
+ } else {
81
+ noPattern.push(entry);
82
+ }
83
+ }
84
+
85
+ const merged: string[] = [];
86
+
87
+ // For each quoted pattern group, keep the one with the newer date (or last entry)
88
+ for (const [, group] of byQuotedPattern) {
89
+ if (group.length === 1) {
90
+ merged.push(group[0]);
91
+ } else {
92
+ // Find the entry with the newest date
93
+ let best = group[0];
94
+ let bestDate = extractDate(group[0]);
95
+ for (let i = 1; i < group.length; i++) {
96
+ const d = extractDate(group[i]);
97
+ if (d !== null && (bestDate === null || d > bestDate)) {
98
+ best = group[i];
99
+ bestDate = d;
100
+ } else if (d === null && bestDate === null) {
101
+ // No dates — keep last (newer = later in list)
102
+ best = group[i];
103
+ }
104
+ }
105
+ merged.push(best);
106
+ }
107
+ }
108
+
109
+ // For entries without quoted patterns, deduplicate by normalized whitespace
110
+ const seenNoPattern = new Set<string>();
111
+ for (const entry of noPattern) {
112
+ const norm = normalizeWhitespace(entry);
113
+ if (!seenNoPattern.has(norm)) {
114
+ seenNoPattern.add(norm);
115
+ merged.push(entry);
116
+ }
117
+ }
118
+
119
+ result.sections[section] = merged;
120
+ } else {
121
+ // Standard deduplication: normalize whitespace and deduplicate
122
+ const seen = new Set<string>();
123
+ const deduped: string[] = [];
124
+ for (const entry of entries) {
125
+ const norm = normalizeWhitespace(entry);
126
+ if (!seen.has(norm)) {
127
+ seen.add(norm);
128
+ deduped.push(entry);
129
+ }
130
+ }
131
+ result.sections[section] = deduped;
132
+ }
133
+ }
134
+
135
+ return result;
136
+ }
137
+
138
+ /**
139
+ * Trim oldest entries from sections in the given order.
140
+ * Trim order: Decision Log → Key Learnings → User Preferences → Do-Not-Repeat
141
+ * Within each section, remove oldest (first) entries.
142
+ * Returns a new LearningMemory (does not mutate input).
143
+ */
144
+ export function trimOldest(mem: LearningMemory, trimCount: number): LearningMemory {
145
+ if (trimCount <= 0) {
146
+ return deepCopy(mem);
147
+ }
148
+
149
+ const result = deepCopy(mem);
150
+ let remaining = trimCount;
151
+
152
+ for (const section of TRIM_ORDER) {
153
+ if (remaining <= 0) break;
154
+ const entries = result.sections[section];
155
+ const toRemove = Math.min(remaining, entries.length);
156
+ result.sections[section] = entries.slice(toRemove);
157
+ remaining -= toRemove;
158
+ }
159
+
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Reflect memory against a token budget: merge duplicates then trim oldest until within budget.
165
+ * If budget <= 0, skip pruning.
166
+ */
167
+ export function reflectMemory(
168
+ mem: LearningMemory,
169
+ tokenBudget: number
170
+ ): { memory: LearningMemory; result: ReflectionResult } {
171
+ const serialized = serializeLearningMemory(mem);
172
+ const beforeTokens = estimateTokens(serialized, "learning-memory.md");
173
+
174
+ if (tokenBudget <= 0) {
175
+ return {
176
+ memory: deepCopy(mem),
177
+ result: {
178
+ beforeTokens,
179
+ afterTokens: beforeTokens,
180
+ mergedCount: 0,
181
+ trimmedCount: 0,
182
+ withinBudget: true,
183
+ },
184
+ };
185
+ }
186
+
187
+ // Step 1: Always merge duplicates (regardless of budget)
188
+ const beforeMergeCount = countEntries(mem);
189
+ const afterMerge = mergeDuplicates(mem);
190
+ const afterMergeCount = countEntries(afterMerge);
191
+ const mergedCount = beforeMergeCount - afterMergeCount;
192
+
193
+ const afterMergeSerialized = serializeLearningMemory(afterMerge);
194
+ const afterMergeTokens = estimateTokens(afterMergeSerialized, "learning-memory.md");
195
+
196
+ if (afterMergeTokens <= tokenBudget) {
197
+ return {
198
+ memory: afterMerge,
199
+ result: {
200
+ beforeTokens,
201
+ afterTokens: afterMergeTokens,
202
+ mergedCount,
203
+ trimmedCount: 0,
204
+ withinBudget: true,
205
+ },
206
+ };
207
+ }
208
+
209
+ // Step 2: Trim oldest one at a time until within budget or empty
210
+ let current = afterMerge;
211
+ let trimmedCount = 0;
212
+
213
+ while (true) {
214
+ const currentSerialized = serializeLearningMemory(current);
215
+ const currentTokens = estimateTokens(currentSerialized, "learning-memory.md");
216
+
217
+ if (currentTokens <= tokenBudget) {
218
+ return {
219
+ memory: current,
220
+ result: {
221
+ beforeTokens,
222
+ afterTokens: currentTokens,
223
+ mergedCount,
224
+ trimmedCount,
225
+ withinBudget: true,
226
+ },
227
+ };
228
+ }
229
+
230
+ const total = countEntries(current);
231
+ if (total === 0) {
232
+ return {
233
+ memory: current,
234
+ result: {
235
+ beforeTokens,
236
+ afterTokens: currentTokens,
237
+ mergedCount,
238
+ trimmedCount,
239
+ withinBudget: false,
240
+ },
241
+ };
242
+ }
243
+
244
+ current = trimOldest(current, 1);
245
+ trimmedCount += 1;
246
+ }
247
+ }
248
+
249
+ function countEntries(mem: LearningMemory): number {
250
+ return (
251
+ mem.sections["User Preferences"].length +
252
+ mem.sections["Key Learnings"].length +
253
+ mem.sections["Do-Not-Repeat"].length +
254
+ mem.sections["Decision Log"].length
255
+ );
256
+ }
@@ -0,0 +1,99 @@
1
+ import { readdirSync, statSync } from "fs";
2
+ import { join, relative } from "path";
3
+ import type { ScannedFile, ProjectConfig } from "../types/file-index";
4
+ import { safeReadJson } from "./fs-utils";
5
+
6
+ export const DEFAULT_EXCLUDES: string[] = [
7
+ "node_modules", "vendor", ".venv", "venv", "__pycache__",
8
+ "bower_components", ".yarn", ".pnp",
9
+ "dist", "build", "out", ".next", ".nuxt", ".svelte-kit",
10
+ ".turbo", ".vercel", ".output",
11
+ "coverage", ".nyc_output",
12
+ ".git", ".hg", ".svn",
13
+ "package-lock.json", "bun.lock", "yarn.lock",
14
+ "pnpm-lock.yaml", "Gemfile.lock", "poetry.lock", "composer.lock",
15
+ "*.min.js", "*.min.css", "*.map",
16
+ "*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.ico",
17
+ "*.woff", "*.woff2", "*.ttf", "*.eot",
18
+ "*.mp3", "*.mp4", "*.webm", "*.zip", "*.tar", "*.gz",
19
+ "*.pdf", "*.exe", "*.dll", "*.so", "*.dylib",
20
+ ".env", ".env.*",
21
+ ".mink",
22
+ ];
23
+
24
+ const DEFAULT_MAX_FILES = 500;
25
+
26
+ function matchesPattern(name: string, pattern: string): boolean {
27
+ if (pattern.includes("*")) {
28
+ // Glob: *.min.js -> match against basename
29
+ const regex = new RegExp(
30
+ "^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
31
+ );
32
+ return regex.test(name);
33
+ }
34
+ // Exact match against name
35
+ return name === pattern;
36
+ }
37
+
38
+ function isExcluded(name: string, excludes: string[]): boolean {
39
+ return excludes.some((pattern) => matchesPattern(name, pattern));
40
+ }
41
+
42
+ function walkDirectory(
43
+ dir: string,
44
+ projectRoot: string,
45
+ excludes: string[],
46
+ results: ScannedFile[]
47
+ ): void {
48
+ let entries;
49
+ try {
50
+ entries = readdirSync(dir, { withFileTypes: true });
51
+ } catch {
52
+ return; // Permission denied or other error — skip
53
+ }
54
+
55
+ for (const entry of entries) {
56
+ if (entry.isSymbolicLink()) continue;
57
+
58
+ if (entry.isDirectory()) {
59
+ if (isExcluded(entry.name, excludes)) continue;
60
+ walkDirectory(join(dir, entry.name), projectRoot, excludes, results);
61
+ continue;
62
+ }
63
+
64
+ if (entry.isFile()) {
65
+ if (isExcluded(entry.name, excludes)) continue;
66
+ try {
67
+ const fullPath = join(dir, entry.name);
68
+ const stat = statSync(fullPath);
69
+ results.push({
70
+ relativePath: relative(projectRoot, fullPath),
71
+ mtimeMs: stat.mtimeMs,
72
+ });
73
+ } catch {
74
+ // stat failed — skip
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ export function loadConfig(configPath: string): ProjectConfig {
81
+ const raw = safeReadJson(configPath);
82
+ if (raw && typeof raw === "object") return raw as ProjectConfig;
83
+ return {};
84
+ }
85
+
86
+ export function getExcludes(config: ProjectConfig): string[] {
87
+ return [...DEFAULT_EXCLUDES, ...(config.excludePatterns ?? [])];
88
+ }
89
+
90
+ export function scanProject(
91
+ projectRoot: string,
92
+ excludes: string[],
93
+ maxFiles: number = DEFAULT_MAX_FILES
94
+ ): ScannedFile[] {
95
+ const results: ScannedFile[] = [];
96
+ walkDirectory(projectRoot, projectRoot, excludes, results);
97
+ results.sort((a, b) => b.mtimeMs - a.mtimeMs);
98
+ return results.slice(0, maxFiles);
99
+ }
@@ -0,0 +1,352 @@
1
+ import { parseCronExpression, nextRunAfter, isInCurrentPeriod } from "./cron-parser";
2
+ import { getBuiltInTasks, getTaskById, executeTask } from "./task-registry";
3
+ import { schedulerManifestPath } from "./paths";
4
+ import { atomicWriteJson, safeReadJson } from "./fs-utils";
5
+ import type {
6
+ SchedulerManifest,
7
+ TaskRunRecord,
8
+ DeadLetterEntry,
9
+ HealthStatus,
10
+ TaskStatus,
11
+ } from "../types/scheduler";
12
+
13
+ // ── Backoff ─────────────────────────────────────────────────────────────────
14
+
15
+ export function calculateBackoffMs(
16
+ baseDelayMs: number,
17
+ attempt: number
18
+ ): number {
19
+ return baseDelayMs * Math.pow(2, attempt);
20
+ }
21
+
22
+ // ── Dead Letter Operations ──────────────────────────────────────────────────
23
+
24
+ export function addToDeadLetter(
25
+ manifest: SchedulerManifest,
26
+ entry: DeadLetterEntry
27
+ ): void {
28
+ // Remove existing entry for same task if present
29
+ manifest.deadLetterQueue = manifest.deadLetterQueue.filter(
30
+ (e) => e.taskId !== entry.taskId
31
+ );
32
+ manifest.deadLetterQueue.push(entry);
33
+ }
34
+
35
+ export function removeFromDeadLetter(
36
+ manifest: SchedulerManifest,
37
+ taskId: string
38
+ ): DeadLetterEntry | undefined {
39
+ const idx = manifest.deadLetterQueue.findIndex((e) => e.taskId === taskId);
40
+ if (idx === -1) return undefined;
41
+ return manifest.deadLetterQueue.splice(idx, 1)[0];
42
+ }
43
+
44
+ export function listDeadLetterEntries(
45
+ manifest: SchedulerManifest
46
+ ): DeadLetterEntry[] {
47
+ return manifest.deadLetterQueue;
48
+ }
49
+
50
+ // ── Manifest Management ─────────────────────────────────────────────────────
51
+
52
+ export function createInitialManifest(now: Date = new Date()): SchedulerManifest {
53
+ const tasks: TaskRunRecord[] = getBuiltInTasks().map((task) => {
54
+ const schedule = parseCronExpression(task.schedule);
55
+ return {
56
+ taskId: task.id,
57
+ lastRunAt: null,
58
+ lastSuccessAt: null,
59
+ lastFailureAt: null,
60
+ nextRunAt: nextRunAfter(schedule, now).toISOString(),
61
+ status: "idle" as TaskStatus,
62
+ consecutiveFailures: 0,
63
+ currentAttempt: 0,
64
+ };
65
+ });
66
+
67
+ return {
68
+ tasks,
69
+ deadLetterQueue: [],
70
+ lastHeartbeat: now.toISOString(),
71
+ };
72
+ }
73
+
74
+ export function loadManifest(cwd: string): SchedulerManifest | null {
75
+ const raw = safeReadJson(schedulerManifestPath(cwd));
76
+ if (
77
+ raw &&
78
+ typeof raw === "object" &&
79
+ "tasks" in (raw as object) &&
80
+ "deadLetterQueue" in (raw as object)
81
+ ) {
82
+ return raw as SchedulerManifest;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export function saveManifest(cwd: string, manifest: SchedulerManifest): void {
88
+ atomicWriteJson(schedulerManifestPath(cwd), manifest);
89
+ }
90
+
91
+ function getOrCreateManifest(cwd: string, now: Date): SchedulerManifest {
92
+ const existing = loadManifest(cwd);
93
+ if (existing) return existing;
94
+ const fresh = createInitialManifest(now);
95
+ saveManifest(cwd, fresh);
96
+ return fresh;
97
+ }
98
+
99
+ // ── Crash Recovery ──────────────────────────────────────────────────────────
100
+
101
+ export function recoverManifest(
102
+ manifest: SchedulerManifest,
103
+ now: Date
104
+ ): void {
105
+ for (const record of manifest.tasks) {
106
+ const task = getTaskById(record.taskId);
107
+ if (!task) continue;
108
+
109
+ const schedule = parseCronExpression(task.schedule);
110
+
111
+ if (record.status === "running") {
112
+ // Crashed during execution — treat as failure
113
+ record.status = "retrying";
114
+ record.currentAttempt++;
115
+ record.consecutiveFailures++;
116
+ record.lastFailureAt = now.toISOString();
117
+
118
+ if (record.currentAttempt >= task.retryPolicy.maxAttempts) {
119
+ record.status = "dead-lettered";
120
+ addToDeadLetter(manifest, {
121
+ taskId: record.taskId,
122
+ deadLetteredAt: now.toISOString(),
123
+ failureTimestamps: [now.toISOString()],
124
+ errorMessages: ["Daemon crashed during execution"],
125
+ attemptCount: record.currentAttempt,
126
+ });
127
+ record.currentAttempt = 0;
128
+ }
129
+ }
130
+
131
+ // For idle/retrying tasks, check if they need schedule recalculation
132
+ if (record.status === "idle" && record.lastRunAt) {
133
+ const lastRun = new Date(record.lastRunAt);
134
+ if (isInCurrentPeriod(schedule, lastRun, now)) {
135
+ // Already ran in current period — advance to next
136
+ record.nextRunAt = nextRunAfter(schedule, lastRun).toISOString();
137
+ } else {
138
+ // Missed the window — due now
139
+ record.nextRunAt = now.toISOString();
140
+ }
141
+ } else if (record.status === "idle" && !record.lastRunAt) {
142
+ // Never ran — recalculate next run
143
+ record.nextRunAt = nextRunAfter(schedule, now).toISOString();
144
+ }
145
+ }
146
+ }
147
+
148
+ // ── Scheduler ───────────────────────────────────────────────────────────────
149
+
150
+ export interface Scheduler {
151
+ start(): void;
152
+ stop(): void;
153
+ runTask(taskId: string): Promise<void>;
154
+ getHealth(): HealthStatus;
155
+ getManifest(): SchedulerManifest;
156
+ }
157
+
158
+ export function createScheduler(
159
+ projectCwd: string,
160
+ options: {
161
+ tickMs?: number;
162
+ heartbeatMs?: number;
163
+ startedAt?: Date;
164
+ } = {}
165
+ ): Scheduler {
166
+ const tickMs = options.tickMs ?? 60_000;
167
+ const heartbeatMs = options.heartbeatMs ?? 30 * 60 * 1000;
168
+ const startedAt = options.startedAt ?? new Date();
169
+
170
+ let tickInterval: ReturnType<typeof setInterval> | null = null;
171
+ let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
172
+ let manifest: SchedulerManifest;
173
+ let activeTasks: string[] = [];
174
+ let ticking = false;
175
+
176
+ // Initialize manifest
177
+ manifest = getOrCreateManifest(projectCwd, startedAt);
178
+ recoverManifest(manifest, startedAt);
179
+ saveManifest(projectCwd, manifest);
180
+
181
+ async function tick(): Promise<void> {
182
+ if (ticking) return; // Prevent overlapping ticks
183
+ ticking = true;
184
+
185
+ try {
186
+ const now = new Date();
187
+ const queue: string[] = [];
188
+
189
+ for (const record of manifest.tasks) {
190
+ const task = getTaskById(record.taskId);
191
+ if (!task || !task.enabled) continue;
192
+ if (record.status === "dead-lettered") continue;
193
+
194
+ if (record.status === "retrying") {
195
+ // Check if backoff delay has elapsed
196
+ const retryAfter =
197
+ new Date(record.lastFailureAt!).getTime() +
198
+ calculateBackoffMs(
199
+ task.retryPolicy.baseDelayMs,
200
+ record.currentAttempt - 1
201
+ );
202
+ if (now.getTime() >= retryAfter) {
203
+ queue.push(record.taskId);
204
+ }
205
+ } else if (
206
+ record.status === "idle" &&
207
+ now.getTime() >= new Date(record.nextRunAt).getTime()
208
+ ) {
209
+ queue.push(record.taskId);
210
+ }
211
+ }
212
+
213
+ // Execute sequentially, sorted by task ID for determinism
214
+ queue.sort();
215
+
216
+ for (const taskId of queue) {
217
+ await executeTaskWithRetry(taskId, now);
218
+ }
219
+ } finally {
220
+ ticking = false;
221
+ }
222
+ }
223
+
224
+ async function executeTaskWithRetry(
225
+ taskId: string,
226
+ now: Date
227
+ ): Promise<void> {
228
+ const task = getTaskById(taskId);
229
+ if (!task) return;
230
+
231
+ const record = manifest.tasks.find((r) => r.taskId === taskId);
232
+ if (!record) return;
233
+
234
+ record.status = "running";
235
+ record.lastRunAt = now.toISOString();
236
+ activeTasks.push(taskId);
237
+ saveManifest(projectCwd, manifest);
238
+
239
+ try {
240
+ await executeTask(taskId, projectCwd);
241
+
242
+ // Success
243
+ record.status = "idle";
244
+ record.lastSuccessAt = now.toISOString();
245
+ record.consecutiveFailures = 0;
246
+ record.currentAttempt = 0;
247
+ const schedule = parseCronExpression(task.schedule);
248
+ record.nextRunAt = nextRunAfter(schedule, now).toISOString();
249
+
250
+ console.log(`[mink] task ${taskId} completed successfully`);
251
+ } catch (err) {
252
+ const errorMsg =
253
+ err instanceof Error ? err.message : String(err);
254
+ record.currentAttempt++;
255
+ record.consecutiveFailures++;
256
+ record.lastFailureAt = now.toISOString();
257
+
258
+ console.error(`[mink] task ${taskId} failed: ${errorMsg}`);
259
+
260
+ if (record.currentAttempt >= task.retryPolicy.maxAttempts) {
261
+ record.status = "dead-lettered";
262
+ addToDeadLetter(manifest, {
263
+ taskId,
264
+ deadLetteredAt: now.toISOString(),
265
+ failureTimestamps: [now.toISOString()],
266
+ errorMessages: [errorMsg],
267
+ attemptCount: record.currentAttempt,
268
+ });
269
+ record.currentAttempt = 0;
270
+ console.error(
271
+ `[mink] task ${taskId} moved to dead letter queue after ${task.retryPolicy.maxAttempts} failures`
272
+ );
273
+ } else {
274
+ record.status = "retrying";
275
+ console.log(
276
+ `[mink] task ${taskId} will retry (attempt ${record.currentAttempt}/${task.retryPolicy.maxAttempts})`
277
+ );
278
+ }
279
+ } finally {
280
+ activeTasks = activeTasks.filter((id) => id !== taskId);
281
+ saveManifest(projectCwd, manifest);
282
+ }
283
+ }
284
+
285
+ function emitHeartbeat(): void {
286
+ manifest.lastHeartbeat = new Date().toISOString();
287
+ saveManifest(projectCwd, manifest);
288
+ console.log(`[mink] heartbeat at ${manifest.lastHeartbeat}`);
289
+ }
290
+
291
+ return {
292
+ start(): void {
293
+ tickInterval = setInterval(() => {
294
+ tick().catch((err) => {
295
+ console.error(`[mink] scheduler tick error: ${err}`);
296
+ });
297
+ }, tickMs);
298
+
299
+ heartbeatInterval = setInterval(emitHeartbeat, heartbeatMs);
300
+
301
+ // Emit initial heartbeat
302
+ emitHeartbeat();
303
+
304
+ // Run first tick immediately
305
+ tick().catch((err) => {
306
+ console.error(`[mink] scheduler initial tick error: ${err}`);
307
+ });
308
+
309
+ console.log("[mink] scheduler started");
310
+ },
311
+
312
+ stop(): void {
313
+ if (tickInterval) {
314
+ clearInterval(tickInterval);
315
+ tickInterval = null;
316
+ }
317
+ if (heartbeatInterval) {
318
+ clearInterval(heartbeatInterval);
319
+ heartbeatInterval = null;
320
+ }
321
+ console.log("[mink] scheduler stopped");
322
+ },
323
+
324
+ async runTask(taskId: string): Promise<void> {
325
+ const task = getTaskById(taskId);
326
+ if (!task) {
327
+ throw new Error(`Unknown task: ${taskId}`);
328
+ }
329
+ // Reload manifest to get latest state
330
+ const fresh = loadManifest(projectCwd);
331
+ if (fresh) manifest = fresh;
332
+
333
+ await executeTaskWithRetry(taskId, new Date());
334
+ },
335
+
336
+ getHealth(): HealthStatus {
337
+ return {
338
+ pid: process.pid,
339
+ startedAt: startedAt.toISOString(),
340
+ lastHeartbeatAt: manifest.lastHeartbeat,
341
+ uptimeMs: Date.now() - startedAt.getTime(),
342
+ activeTasks: [...activeTasks],
343
+ deadLetterCount: manifest.deadLetterQueue.length,
344
+ taskCount: manifest.tasks.length,
345
+ };
346
+ },
347
+
348
+ getManifest(): SchedulerManifest {
349
+ return manifest;
350
+ },
351
+ };
352
+ }