flint-agent 1.14.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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,589 @@
1
+ // CRUD operations for persistent tasks
2
+ import { getDb } from "./db.js";
3
+
4
+ // -- Sessions --
5
+
6
+ export function touchSession(sessionId) {
7
+ if (!sessionId) return;
8
+ // Single upsert instead of SELECT+UPDATE — hot path fix
9
+ getDb().prepare(
10
+ "INSERT INTO sessions (id) VALUES (?) ON CONFLICT(id) DO UPDATE SET last_updated = datetime('now')"
11
+ ).run(sessionId);
12
+ }
13
+
14
+ export function getSession(sessionId) {
15
+ return getDb().prepare("SELECT * FROM sessions WHERE id = ?").get(sessionId);
16
+ }
17
+
18
+ export function setFocusedGoal(sessionId, goalId) {
19
+ if (!sessionId) return;
20
+ touchSession(sessionId);
21
+ getDb().prepare("UPDATE sessions SET focused_goal_id = ? WHERE id = ?").run(goalId, sessionId);
22
+ }
23
+
24
+ export function getFocusedGoal(sessionId) {
25
+ if (!sessionId) return null;
26
+ const session = getSession(sessionId);
27
+ if (!session || !session.focused_goal_id) return null;
28
+ const goal = getGoal(session.focused_goal_id);
29
+ // Only return if still active
30
+ return goal && goal.status === "active" ? goal : null;
31
+ }
32
+
33
+ export function getStaleSessions(days = 7) {
34
+ return getDb().prepare(
35
+ "SELECT * FROM sessions WHERE last_updated < datetime('now', ? || ' days')"
36
+ ).all(-days);
37
+ }
38
+
39
+ // -- Goals --
40
+
41
+ export function createGoal(title, project = "default", sessionId = null) {
42
+ const db = getDb();
43
+ const r = db.prepare(
44
+ "INSERT INTO goals (title, project, session_id) VALUES (?, ?, ?)"
45
+ ).run(title, project, sessionId);
46
+ return getGoal(r.lastInsertRowid);
47
+ }
48
+
49
+ export function getGoal(id) {
50
+ return getDb().prepare("SELECT * FROM goals WHERE id = ?").get(id);
51
+ }
52
+
53
+ /** Get the focused goal for a session, or most recent active goal */
54
+ export function getActiveGoal(project = null, sessionId = null) {
55
+ const db = getDb();
56
+ // If session has a focused goal, prefer it
57
+ if (sessionId) {
58
+ const focused = getFocusedGoal(sessionId);
59
+ if (focused) {
60
+ if (!project || focused.project === project) return focused;
61
+ }
62
+ }
63
+ if (project) {
64
+ return db.prepare(
65
+ "SELECT * FROM goals WHERE status = 'active' AND project = ? ORDER BY created_at DESC LIMIT 1"
66
+ ).get(project);
67
+ }
68
+ return db.prepare(
69
+ "SELECT * FROM goals WHERE status = 'active' ORDER BY created_at DESC LIMIT 1"
70
+ ).get();
71
+ }
72
+
73
+ /** Get ALL active goals (for multi-goal support) */
74
+ export function getActiveGoals() {
75
+ return getDb().prepare(
76
+ "SELECT * FROM goals WHERE status = 'active' ORDER BY created_at DESC"
77
+ ).all();
78
+ }
79
+
80
+ export function listGoals(status = null) {
81
+ const db = getDb();
82
+ if (status) {
83
+ return db.prepare("SELECT * FROM goals WHERE status = ? ORDER BY created_at DESC").all(status);
84
+ }
85
+ return db.prepare("SELECT * FROM goals ORDER BY created_at DESC").all();
86
+ }
87
+
88
+ export function completeGoal(id) {
89
+ getDb().prepare(
90
+ "UPDATE goals SET status = 'completed', completed_at = datetime('now') WHERE id = ?"
91
+ ).run(id);
92
+ }
93
+
94
+ export function abandonGoal(id) {
95
+ getDb().prepare(
96
+ "UPDATE goals SET status = 'abandoned', completed_at = datetime('now') WHERE id = ?"
97
+ ).run(id);
98
+ }
99
+
100
+ /** Abandon all active goals (used on /new to prevent plan leakage between sessions) */
101
+ export function abandonAllActiveGoals() {
102
+ getDb().prepare(
103
+ "UPDATE goals SET status = 'abandoned', completed_at = datetime('now') WHERE status = 'active'"
104
+ ).run();
105
+ }
106
+
107
+ // -- Tasks --
108
+
109
+ export function createTask(goalId, title, description = null, priority = 0, scope = "session") {
110
+ const db = getDb();
111
+ const r = db.prepare(
112
+ "INSERT INTO tasks (goal_id, title, description, priority, scope) VALUES (?, ?, ?, ?, ?)"
113
+ ).run(goalId, title, description, priority, scope);
114
+ return getTask(r.lastInsertRowid);
115
+ }
116
+
117
+ export function createSubtask(parentTaskId, title, description = null) {
118
+ const db = getDb();
119
+ const parent = getTask(parentTaskId);
120
+ if (!parent) return null;
121
+ const r = db.prepare(
122
+ "INSERT INTO tasks (goal_id, title, description, parent_task_id, scope) VALUES (?, ?, ?, ?, ?)"
123
+ ).run(parent.goal_id, title, description, parentTaskId, parent.scope || "session");
124
+ return getTask(r.lastInsertRowid);
125
+ }
126
+
127
+ export function getSubtasks(parentTaskId) {
128
+ return getDb().prepare(
129
+ "SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY id ASC"
130
+ ).all(parentTaskId);
131
+ }
132
+
133
+ export function getTask(id) {
134
+ return getDb().prepare("SELECT * FROM tasks WHERE id = ?").get(id);
135
+ }
136
+
137
+ export function getTasksByGoal(goalId) {
138
+ return getDb().prepare(
139
+ "SELECT * FROM tasks WHERE goal_id = ? AND parent_task_id IS NULL ORDER BY priority DESC, id ASC"
140
+ ).all(goalId);
141
+ }
142
+
143
+
144
+ export function updateTaskStatus(id, status, result = null) {
145
+ const db = getDb();
146
+ if (result != null) {
147
+ db.prepare(
148
+ "UPDATE tasks SET status = ?, result = ?, updated_at = datetime('now') WHERE id = ?"
149
+ ).run(status, result, id);
150
+ } else {
151
+ db.prepare(
152
+ "UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?"
153
+ ).run(status, id);
154
+ }
155
+ const task = getTask(id);
156
+ // Auto-complete parent if all subtasks done/skipped
157
+ if (task && task.parent_task_id && (status === "done" || status === "skipped")) {
158
+ autoCompleteParent(task.parent_task_id);
159
+ }
160
+ return task;
161
+ }
162
+
163
+ export function getNextPendingTask(goalId) {
164
+ return getDb().prepare(
165
+ "SELECT * FROM tasks WHERE goal_id = ? AND status IN ('pending', 'in_progress') ORDER BY priority DESC, id ASC LIMIT 1"
166
+ ).get(goalId);
167
+ }
168
+
169
+ export function getTaskStats(goalId) {
170
+ const row = getDb().prepare(`
171
+ SELECT
172
+ COUNT(*) as total,
173
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
174
+ SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
175
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
176
+ SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) as skipped
177
+ FROM tasks WHERE goal_id = ? AND parent_task_id IS NULL
178
+ `).get(goalId);
179
+ return row;
180
+ }
181
+
182
+ // -- Task Files --
183
+
184
+ export function linkFile(taskId, filePath, role = "related") {
185
+ const db = getDb();
186
+ // Avoid duplicates
187
+ const existing = db.prepare(
188
+ "SELECT id FROM task_files WHERE task_id = ? AND path = ?"
189
+ ).get(taskId, filePath);
190
+ if (existing) return existing;
191
+ const r = db.prepare(
192
+ "INSERT INTO task_files (task_id, path, role) VALUES (?, ?, ?)"
193
+ ).run(taskId, filePath, role);
194
+ return { id: r.lastInsertRowid };
195
+ }
196
+
197
+ export function getTaskFiles(taskId) {
198
+ return getDb().prepare(
199
+ "SELECT * FROM task_files WHERE task_id = ? ORDER BY added_at"
200
+ ).all(taskId);
201
+ }
202
+
203
+ // -- Task Notes --
204
+
205
+ export function addNote(taskId, note) {
206
+ const db = getDb();
207
+ const r = db.prepare(
208
+ "INSERT INTO task_notes (task_id, note) VALUES (?, ?)"
209
+ ).run(taskId, note);
210
+ return { id: r.lastInsertRowid };
211
+ }
212
+
213
+ export function getTaskNotes(taskId) {
214
+ return getDb().prepare(
215
+ "SELECT * FROM task_notes WHERE task_id = ? ORDER BY created_at"
216
+ ).all(taskId);
217
+ }
218
+
219
+ // -- Task-driven child agents --
220
+
221
+ export function claimTask(taskId, assignee, agentPort) {
222
+ const db = getDb();
223
+ db.prepare(
224
+ "UPDATE tasks SET status = 'in_progress', assignee = ?, agent_port = ?, updated_at = datetime('now') WHERE id = ?"
225
+ ).run(assignee, agentPort, taskId);
226
+ return getTask(taskId);
227
+ }
228
+
229
+ export function completeTaskWithResult(taskId, result) {
230
+ const db = getDb();
231
+ db.prepare(
232
+ "UPDATE tasks SET status = 'done', result = ?, updated_at = datetime('now') WHERE id = ?"
233
+ ).run(result, taskId);
234
+ const task = getTask(taskId);
235
+ // Auto-complete parent task if all subtasks done/skipped
236
+ if (task && task.parent_task_id) {
237
+ autoCompleteParent(task.parent_task_id);
238
+ }
239
+ // Auto-complete goal if all top-level tasks done/skipped
240
+ if (task && task.goal_id) {
241
+ const stats = getTaskStats(task.goal_id);
242
+ if (stats.pending === 0 && stats.in_progress === 0) {
243
+ completeGoal(task.goal_id);
244
+ }
245
+ }
246
+ return task;
247
+ }
248
+
249
+ export function failTask(taskId, reason) {
250
+ const db = getDb();
251
+ db.prepare(
252
+ "UPDATE tasks SET status = 'skipped', result = ?, updated_at = datetime('now') WHERE id = ?"
253
+ ).run(reason || "failed", taskId);
254
+ const task = getTask(taskId);
255
+ // Auto-complete parent task if all subtasks done/skipped
256
+ if (task && task.parent_task_id) {
257
+ autoCompleteParent(task.parent_task_id);
258
+ }
259
+ // Auto-complete goal if all top-level tasks done/skipped
260
+ if (task && task.goal_id) {
261
+ const stats = getTaskStats(task.goal_id);
262
+ if (stats.pending === 0 && stats.in_progress === 0) {
263
+ completeGoal(task.goal_id);
264
+ }
265
+ }
266
+ return task;
267
+ }
268
+
269
+ /** Auto-complete parent when all subtasks are done/skipped */
270
+ function autoCompleteParent(parentTaskId) {
271
+ const subs = getSubtasks(parentTaskId);
272
+ if (!subs.length) return;
273
+ const allDone = subs.every(s => s.status === "done" || s.status === "skipped");
274
+ if (allDone) {
275
+ const doneCount = subs.filter(s => s.status === "done").length;
276
+ const result = `All ${subs.length} subtasks completed (${doneCount} done, ${subs.length - doneCount} skipped)`;
277
+ getDb().prepare(
278
+ "UPDATE tasks SET status = 'done', result = ?, updated_at = datetime('now') WHERE id = ?"
279
+ ).run(result, parentTaskId);
280
+ // Cascade: check if goal should auto-complete too
281
+ const parent = getTask(parentTaskId);
282
+ if (parent && parent.goal_id) {
283
+ const goalStats = getTaskStats(parent.goal_id);
284
+ if (goalStats.pending === 0 && goalStats.in_progress === 0) {
285
+ completeGoal(parent.goal_id);
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ export function getTasksByGoalAndStatus(goalId, statuses) {
292
+ const db = getDb();
293
+ const placeholders = statuses.map(() => "?").join(",");
294
+ return db.prepare(
295
+ `SELECT * FROM tasks WHERE goal_id = ? AND status IN (${placeholders}) ORDER BY priority DESC, id ASC`
296
+ ).all(goalId, ...statuses);
297
+ }
298
+
299
+ // -- Dashboard --
300
+
301
+ export function getDashboard() {
302
+ const db = getDb();
303
+ const goals = db.prepare(
304
+ "SELECT * FROM goals WHERE status = 'active' ORDER BY created_at DESC"
305
+ ).all();
306
+ if (!goals.length) return [];
307
+
308
+ // Batch: fetch all tasks for active goals in one query
309
+ const goalIds = goals.map(g => g.id);
310
+ const placeholders = goalIds.map(() => "?").join(",");
311
+ const allTasks = db.prepare(
312
+ `SELECT * FROM tasks WHERE goal_id IN (${placeholders}) ORDER BY priority DESC, id ASC`
313
+ ).all(...goalIds);
314
+
315
+ // Group tasks by goal_id
316
+ const tasksByGoal = new Map();
317
+ for (const t of allTasks) {
318
+ if (!tasksByGoal.has(t.goal_id)) tasksByGoal.set(t.goal_id, []);
319
+ tasksByGoal.get(t.goal_id).push(t);
320
+ }
321
+
322
+ return goals.map(g => {
323
+ const tasks = tasksByGoal.get(g.id) || [];
324
+ return {
325
+ ...g,
326
+ tasks,
327
+ stats: {
328
+ total: tasks.length,
329
+ pending: tasks.filter(t => t.status === "pending").length,
330
+ in_progress: tasks.filter(t => t.status === "in_progress").length,
331
+ done: tasks.filter(t => t.status === "done").length,
332
+ skipped: tasks.filter(t => t.status === "skipped").length,
333
+ },
334
+ };
335
+ });
336
+ }
337
+
338
+ // -- Conversion helpers: goal+tasks → plan format (for store/UI compatibility) --
339
+
340
+ export function goalToPlan(goal, tasks) {
341
+ if (!goal) return null;
342
+ // Batch-fetch all subtasks for this goal in one query (avoids N+1)
343
+ const allSubs = getDb().prepare(
344
+ "SELECT * FROM tasks WHERE goal_id = ? AND parent_task_id IS NOT NULL ORDER BY id ASC"
345
+ ).all(goal.id);
346
+ const subsByParent = new Map();
347
+ for (const s of allSubs) {
348
+ if (!subsByParent.has(s.parent_task_id)) subsByParent.set(s.parent_task_id, []);
349
+ subsByParent.get(s.parent_task_id).push(s);
350
+ }
351
+
352
+ const topLevel = tasks.map(t => ({
353
+ id: t.id,
354
+ title: t.title,
355
+ description: t.description,
356
+ status: t.status,
357
+ result: t.result,
358
+ subtasks: (subsByParent.get(t.id) || []).map(s => ({
359
+ id: s.id,
360
+ title: s.title,
361
+ status: s.status,
362
+ result: s.result,
363
+ })),
364
+ }));
365
+ return {
366
+ goal: goal.title,
367
+ goalId: goal.id,
368
+ project: goal.project,
369
+ created: goal.created_at,
370
+ tasks: topLevel,
371
+ };
372
+ }
373
+
374
+ /** Alias for getActivePlan — used throughout codebase */
375
+ export function syncPlanToStore(store) {
376
+ return getActivePlan(store);
377
+ }
378
+
379
+ // -- Reminders (tasks with next_run) --
380
+
381
+ export function createReminder(title, nextRun, repeat = null, goalId = null, scope = "session") {
382
+ const db = getDb();
383
+ const r = db.prepare(
384
+ "INSERT INTO tasks (goal_id, title, status, next_run, repeat, scope) VALUES (?, ?, 'pending', ?, ?, ?)"
385
+ ).run(goalId, title, nextRun, repeat, scope);
386
+ return getTask(r.lastInsertRowid);
387
+ }
388
+
389
+ export function getDueReminders() {
390
+ return getDb().prepare(
391
+ "SELECT * FROM tasks WHERE next_run IS NOT NULL AND next_run <= datetime('now') AND status IN ('pending', 'in_progress') ORDER BY next_run ASC"
392
+ ).all();
393
+ }
394
+
395
+ export function fireReminder(id, repeat) {
396
+ const db = getDb();
397
+ if (repeat) {
398
+ // Recurring: advance next_run by interval
399
+ const ms = parseRepeat(repeat);
400
+ if (ms) {
401
+ const next = new Date(Date.now() + ms).toISOString();
402
+ db.prepare(
403
+ "UPDATE tasks SET next_run = ?, updated_at = datetime('now') WHERE id = ?"
404
+ ).run(next, id);
405
+ return getTask(id);
406
+ }
407
+ }
408
+ // One-time: mark done
409
+ db.prepare(
410
+ "UPDATE tasks SET status = 'done', next_run = NULL, updated_at = datetime('now') WHERE id = ?"
411
+ ).run(id);
412
+ return getTask(id);
413
+ }
414
+
415
+ export function cancelReminder(id) {
416
+ const db = getDb();
417
+ db.prepare(
418
+ "UPDATE tasks SET status = 'skipped', next_run = NULL, updated_at = datetime('now') WHERE id = ?"
419
+ ).run(id);
420
+ return getTask(id);
421
+ }
422
+
423
+ export function listReminders() {
424
+ return getDb().prepare(
425
+ "SELECT * FROM tasks WHERE next_run IS NOT NULL AND status IN ('pending', 'in_progress') ORDER BY next_run ASC"
426
+ ).all();
427
+ }
428
+
429
+ export function listRecentFired(hours = 24) {
430
+ return getDb().prepare(
431
+ "SELECT * FROM tasks WHERE next_run IS NULL AND repeat IS NULL AND status = 'done' AND updated_at >= datetime('now', ? || ' hours') ORDER BY updated_at DESC"
432
+ ).all(-hours);
433
+ }
434
+
435
+ function parseRepeat(repeat) {
436
+ const match = repeat.match(/^(\d+)\s*(s|m|h|d|w)$/i);
437
+ if (!match) return null;
438
+ const value = parseInt(match[1], 10);
439
+ const unit = match[2].toLowerCase();
440
+ const multipliers = { s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 };
441
+ return value * multipliers[unit];
442
+ }
443
+
444
+ /**
445
+ * Single source of truth: read focused plan from SQLite, update store, return plan.
446
+ * Call this everywhere instead of reading store.plan directly.
447
+ * With multi-goal: returns focused goal's plan (or most recent active).
448
+ */
449
+ export function getActivePlan(store) {
450
+ const sessionId = store ? store.getState().sessionId : null;
451
+ const goal = getActiveGoal(null, sessionId);
452
+ if (!goal) {
453
+ if (store) store.getState().setPlan(null);
454
+ return null;
455
+ }
456
+ const tasks = getTasksByGoal(goal.id);
457
+ const plan = goalToPlan(goal, tasks);
458
+ if (store) store.getState().setPlan(plan);
459
+ return plan;
460
+ }
461
+
462
+ /**
463
+ * Get all active plans with stats (for multi-goal prompt injection).
464
+ * Returns: [{goal, goalId, project, taskStats}, ...]
465
+ */
466
+ export function getAllActivePlans() {
467
+ const db = getDb();
468
+ // Single query for all active goals with stats (avoids N+1)
469
+ const rows = db.prepare(`
470
+ SELECT g.id as goalId, g.title as goal, g.project,
471
+ COUNT(t.id) as total,
472
+ SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
473
+ SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
474
+ SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) as done,
475
+ SUM(CASE WHEN t.status = 'skipped' THEN 1 ELSE 0 END) as skipped
476
+ FROM goals g LEFT JOIN tasks t ON t.goal_id = g.id AND t.parent_task_id IS NULL
477
+ WHERE g.status = 'active'
478
+ GROUP BY g.id
479
+ ORDER BY g.created_at DESC
480
+ `).all();
481
+ return rows.map(r => ({
482
+ goalId: r.goalId,
483
+ goal: r.goal,
484
+ project: r.project,
485
+ stats: { total: r.total, pending: r.pending, in_progress: r.in_progress, done: r.done, skipped: r.skipped },
486
+ }));
487
+ }
488
+
489
+ /**
490
+ * Aggregate stats across all projects or a specific project.
491
+ */
492
+ export function getProjectStats(project = null) {
493
+ const db = getDb();
494
+ // Exclude subtasks from counts (parent_task_id IS NULL)
495
+ if (project) {
496
+ return db.prepare(`
497
+ SELECT
498
+ COUNT(DISTINCT g.id) as goals,
499
+ COUNT(t.id) as tasks,
500
+ SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
501
+ SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
502
+ SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) as done
503
+ FROM goals g LEFT JOIN tasks t ON t.goal_id = g.id AND t.parent_task_id IS NULL
504
+ WHERE g.status = 'active' AND g.project = ?
505
+ `).get(project);
506
+ }
507
+ return db.prepare(`
508
+ SELECT
509
+ g.project,
510
+ COUNT(DISTINCT g.id) as goals,
511
+ COUNT(t.id) as tasks,
512
+ SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
513
+ SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
514
+ SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) as done
515
+ FROM goals g LEFT JOIN tasks t ON t.goal_id = g.id AND t.parent_task_id IS NULL
516
+ WHERE g.status = 'active'
517
+ GROUP BY g.project
518
+ `).all();
519
+ }
520
+
521
+ // -- Day Planning --
522
+
523
+ /** Mark task(s) for today's focus */
524
+ export function setDailyFocus(taskId, date = null) {
525
+ const d = date || new Date().toISOString().slice(0, 10);
526
+ getDb().prepare("UPDATE tasks SET daily_focus = ? WHERE id = ?").run(d, taskId);
527
+ }
528
+
529
+ /** Clear daily focus from a task */
530
+ export function clearDailyFocus(taskId) {
531
+ getDb().prepare("UPDATE tasks SET daily_focus = NULL WHERE id = ?").run(taskId);
532
+ }
533
+
534
+ /** Get tasks focused for today (across all goals/projects) */
535
+ export function getTodayTasks(date = null) {
536
+ const d = date || new Date().toISOString().slice(0, 10);
537
+ return getDb().prepare(`
538
+ SELECT t.*, g.title as goal_title, g.project
539
+ FROM tasks t LEFT JOIN goals g ON t.goal_id = g.id
540
+ WHERE t.daily_focus = ? AND t.status IN ('pending', 'in_progress')
541
+ ORDER BY t.priority DESC, t.id ASC
542
+ `).all(d);
543
+ }
544
+
545
+ // -- Session GC --
546
+
547
+ /**
548
+ * Garbage collect stale sessions. Moves tasks from sessions not updated
549
+ * in `days` to _orphaned session. Adds audit note to each moved task.
550
+ * Returns count of orphaned tasks.
551
+ */
552
+ export function gcStaleSessions(days = 7) {
553
+ const db = getDb();
554
+ const stale = getStaleSessions(days);
555
+ if (!stale.length) return 0;
556
+
557
+ // Ensure _orphaned session exists
558
+ touchSession("_orphaned");
559
+
560
+ let orphanedCount = 0;
561
+ for (const session of stale) {
562
+ // Find goals from this session
563
+ const goals = db.prepare(
564
+ "SELECT id FROM goals WHERE session_id = ? AND status = 'active'"
565
+ ).all(session.id);
566
+
567
+ for (const goal of goals) {
568
+ // Get tasks that are session-scoped and still active
569
+ const tasks = db.prepare(
570
+ "SELECT id, title FROM tasks WHERE goal_id = ? AND scope = 'session' AND status IN ('pending', 'in_progress')"
571
+ ).all(goal.id);
572
+
573
+ for (const task of tasks) {
574
+ addNote(task.id, `[GC] Moved from session ${session.id} to _orphaned (stale ${days}+ days)`);
575
+ // Change scope to prevent re-GC
576
+ db.prepare("UPDATE tasks SET scope = 'project' WHERE id = ? AND scope = 'session'").run(task.id);
577
+ orphanedCount++;
578
+ }
579
+
580
+ // Move goal to _orphaned session
581
+ db.prepare("UPDATE goals SET session_id = '_orphaned' WHERE id = ?").run(goal.id);
582
+ }
583
+
584
+ // Delete stale session record
585
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(session.id);
586
+ }
587
+
588
+ return orphanedCount;
589
+ }