@virtengine/openfleet 0.25.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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
package/task-store.mjs ADDED
@@ -0,0 +1,753 @@
1
+ /**
2
+ * task-store.mjs — Internal JSON kanban store (source of truth for all task state)
3
+ *
4
+ * Stores data in .cache/kanban-state.json relative to this file.
5
+ * Provides an in-memory cache with auto-persist on every mutation.
6
+ */
7
+
8
+ import { resolve, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import {
11
+ readFileSync,
12
+ writeFileSync,
13
+ mkdirSync,
14
+ renameSync,
15
+ existsSync,
16
+ } from "node:fs";
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+ const TAG = "[task-store]";
21
+
22
+ function inferRepoRoot(startDir) {
23
+ let current = resolve(startDir || process.cwd());
24
+ while (true) {
25
+ if (existsSync(resolve(current, ".git"))) {
26
+ return current;
27
+ }
28
+ const parent = dirname(current);
29
+ if (parent === current) {
30
+ return null;
31
+ }
32
+ current = parent;
33
+ }
34
+ }
35
+
36
+ function resolveDefaultStorePath() {
37
+ const repoRoot =
38
+ inferRepoRoot(process.cwd()) || resolve(__dirname, "..", "..");
39
+ return resolve(repoRoot, ".openfleet", ".cache", "kanban-state.json");
40
+ }
41
+
42
+ let storePath = resolveDefaultStorePath();
43
+ let storeTmpPath = storePath + ".tmp";
44
+ const MAX_STATUS_HISTORY = 50;
45
+ const MAX_AGENT_OUTPUT = 2000;
46
+ const MAX_ERROR_LENGTH = 1000;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // In-memory state
50
+ // ---------------------------------------------------------------------------
51
+
52
+ let _store = null; // { _meta: {...}, tasks: { [id]: Task } }
53
+ let _loaded = false;
54
+ let _writeChain = Promise.resolve(); // simple write lock
55
+
56
+ export function configureTaskStore(options = {}) {
57
+ const baseDir = options.baseDir ? resolve(options.baseDir) : null;
58
+ const nextPath = options.storePath
59
+ ? resolve(baseDir || process.cwd(), options.storePath)
60
+ : resolve(
61
+ baseDir ||
62
+ inferRepoRoot(process.cwd()) ||
63
+ resolve(__dirname, "..", ".."),
64
+ ".openfleet",
65
+ ".cache",
66
+ "kanban-state.json",
67
+ );
68
+
69
+ if (nextPath !== storePath) {
70
+ storePath = nextPath;
71
+ storeTmpPath = storePath + ".tmp";
72
+ _store = null;
73
+ _loaded = false;
74
+ _writeChain = Promise.resolve();
75
+ }
76
+
77
+ return storePath;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ function now() {
85
+ return new Date().toISOString();
86
+ }
87
+
88
+ function truncate(str, max) {
89
+ if (str == null) return null;
90
+ const s = String(str);
91
+ return s.length > max ? s.slice(0, max) : s;
92
+ }
93
+
94
+ function normalizeTags(raw) {
95
+ if (!raw) return [];
96
+ const values = Array.isArray(raw)
97
+ ? raw
98
+ : String(raw || "")
99
+ .split(",")
100
+ .map((entry) => entry.trim())
101
+ .filter(Boolean);
102
+ const seen = new Set();
103
+ const tags = [];
104
+ for (const value of values) {
105
+ const normalized = String(value || "")
106
+ .trim()
107
+ .toLowerCase();
108
+ if (!normalized || seen.has(normalized)) continue;
109
+ seen.add(normalized);
110
+ tags.push(normalized);
111
+ }
112
+ return tags;
113
+ }
114
+
115
+ function defaultMeta() {
116
+ return {
117
+ version: 1,
118
+ projectId: null,
119
+ lastFullSync: null,
120
+ taskCount: 0,
121
+ stats: {
122
+ draft: 0,
123
+ todo: 0,
124
+ inprogress: 0,
125
+ inreview: 0,
126
+ done: 0,
127
+ blocked: 0,
128
+ },
129
+ };
130
+ }
131
+
132
+ function defaultTask(overrides = {}) {
133
+ const ts = now();
134
+ return {
135
+ id: null,
136
+ title: "",
137
+ description: "",
138
+ status: "todo",
139
+ externalStatus: null,
140
+ externalId: null,
141
+ externalBackend: null,
142
+ assignee: null,
143
+ priority: null,
144
+ tags: [],
145
+ draft: false,
146
+ projectId: null,
147
+ baseBranch: null,
148
+ branchName: null,
149
+ prNumber: null,
150
+ prUrl: null,
151
+
152
+ createdAt: ts,
153
+ updatedAt: ts,
154
+ lastActivityAt: ts,
155
+ statusHistory: [],
156
+
157
+ agentAttempts: 0,
158
+ consecutiveNoCommits: 0,
159
+ lastAgentOutput: null,
160
+ lastError: null,
161
+ errorPattern: null,
162
+
163
+ reviewStatus: null,
164
+ reviewIssues: null,
165
+ reviewedAt: null,
166
+
167
+ cooldownUntil: null,
168
+ blockedReason: null,
169
+
170
+ lastSyncedAt: null,
171
+ syncDirty: false,
172
+
173
+ meta: {},
174
+ ...overrides,
175
+ };
176
+ }
177
+
178
+ function recalcStats() {
179
+ const stats = {
180
+ draft: 0,
181
+ todo: 0,
182
+ inprogress: 0,
183
+ inreview: 0,
184
+ done: 0,
185
+ blocked: 0,
186
+ };
187
+ for (const t of Object.values(_store.tasks)) {
188
+ if (t.status === "blocked") {
189
+ stats.blocked++;
190
+ } else if (stats[t.status] !== undefined) {
191
+ stats[t.status]++;
192
+ }
193
+ }
194
+ _store._meta.taskCount = Object.keys(_store.tasks).length;
195
+ _store._meta.stats = stats;
196
+ }
197
+
198
+ function ensureLoaded() {
199
+ if (!_loaded) {
200
+ loadStore();
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Store management
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Load store from disk. Called automatically on first access.
210
+ */
211
+ export function loadStore() {
212
+ try {
213
+ if (existsSync(storePath)) {
214
+ const raw = readFileSync(storePath, "utf-8");
215
+ const data = JSON.parse(raw);
216
+ _store = {
217
+ _meta: { ...defaultMeta(), ...(data._meta || {}) },
218
+ tasks: data.tasks || {},
219
+ };
220
+ console.log(
221
+ TAG,
222
+ `Loaded ${Object.keys(_store.tasks).length} tasks from disk`,
223
+ );
224
+ } else {
225
+ _store = { _meta: defaultMeta(), tasks: {} };
226
+ console.log(TAG, "No store file found — initialised empty store");
227
+ }
228
+ } catch (err) {
229
+ console.error(TAG, "Failed to load store, starting fresh:", err.message);
230
+ _store = { _meta: defaultMeta(), tasks: {} };
231
+ }
232
+ _loaded = true;
233
+ }
234
+
235
+ /**
236
+ * Persist store to disk (atomic write via tmp+rename).
237
+ */
238
+ export function saveStore() {
239
+ ensureLoaded();
240
+ recalcStats();
241
+
242
+ _writeChain = _writeChain
243
+ .then(() => {
244
+ try {
245
+ const dir = dirname(storePath);
246
+ if (!existsSync(dir)) {
247
+ mkdirSync(dir, { recursive: true });
248
+ }
249
+ const json = JSON.stringify(_store, null, 2);
250
+ writeFileSync(storeTmpPath, json, "utf-8");
251
+ renameSync(storeTmpPath, storePath);
252
+ } catch (err) {
253
+ console.error(TAG, "Failed to save store:", err.message);
254
+ }
255
+ })
256
+ .catch((err) => {
257
+ console.error(TAG, "Write chain error:", err.message);
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Return the resolved path of the store file.
263
+ */
264
+ export function getStorePath() {
265
+ return storePath;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Core CRUD
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Get a single task by ID. Returns null if not found.
274
+ */
275
+ export function getTask(taskId) {
276
+ ensureLoaded();
277
+ if (!taskId) return null;
278
+ return _store.tasks[taskId] ?? null;
279
+ }
280
+
281
+ /**
282
+ * Get all tasks as an array.
283
+ */
284
+ export function getAllTasks() {
285
+ ensureLoaded();
286
+ return Object.values(_store.tasks);
287
+ }
288
+
289
+ /**
290
+ * Get tasks filtered by status.
291
+ */
292
+ export function getTasksByStatus(status) {
293
+ ensureLoaded();
294
+ return Object.values(_store.tasks).filter((t) => t.status === status);
295
+ }
296
+
297
+ /**
298
+ * Partial update of a task. Auto-sets updatedAt and syncDirty.
299
+ * Returns the updated task or null if not found.
300
+ */
301
+ export function updateTask(taskId, updates) {
302
+ ensureLoaded();
303
+ const task = _store.tasks[taskId];
304
+ if (!task) {
305
+ console.warn(TAG, `updateTask: task ${taskId} not found`);
306
+ return null;
307
+ }
308
+
309
+ // Apply updates (shallow merge)
310
+ for (const [k, v] of Object.entries(updates)) {
311
+ if (k === "id") continue; // never overwrite id
312
+ if (k === "lastAgentOutput") {
313
+ task[k] = truncate(v, MAX_AGENT_OUTPUT);
314
+ } else if (k === "lastError") {
315
+ task[k] = truncate(v, MAX_ERROR_LENGTH);
316
+ } else if (k === "tags") {
317
+ task[k] = normalizeTags(v);
318
+ } else {
319
+ task[k] = v;
320
+ }
321
+ }
322
+
323
+ if (typeof updates.draft === "boolean") {
324
+ task.draft = updates.draft;
325
+ if (updates.draft && task.status !== "draft") {
326
+ task.status = "draft";
327
+ } else if (!updates.draft && task.status === "draft") {
328
+ task.status = "todo";
329
+ }
330
+ }
331
+ if (task.status === "draft") {
332
+ task.draft = true;
333
+ } else if (task.draft && updates.draft == null) {
334
+ task.draft = false;
335
+ }
336
+
337
+ task.updatedAt = now();
338
+ task.syncDirty = true;
339
+
340
+ saveStore();
341
+ return { ...task };
342
+ }
343
+
344
+ /**
345
+ * Add a new task to the store. Sets createdAt.
346
+ * Returns the created task.
347
+ */
348
+ export function addTask(taskData) {
349
+ ensureLoaded();
350
+ const task = defaultTask(taskData);
351
+ if (!task.id) {
352
+ console.error(TAG, "addTask: task must have an id");
353
+ return null;
354
+ }
355
+ task.tags = normalizeTags(task.tags);
356
+ task.draft = Boolean(task.draft || task.status === "draft");
357
+ if (task.draft) task.status = "draft";
358
+ task.lastAgentOutput = truncate(task.lastAgentOutput, MAX_AGENT_OUTPUT);
359
+ task.lastError = truncate(task.lastError, MAX_ERROR_LENGTH);
360
+
361
+ _store.tasks[task.id] = task;
362
+ console.log(TAG, `Added task ${task.id}: ${task.title}`);
363
+
364
+ saveStore();
365
+ return { ...task };
366
+ }
367
+
368
+ /**
369
+ * Remove a task from the store. Returns true if removed, false if not found.
370
+ */
371
+ export function removeTask(taskId) {
372
+ ensureLoaded();
373
+ if (!_store.tasks[taskId]) return false;
374
+ delete _store.tasks[taskId];
375
+ console.log(TAG, `Removed task ${taskId}`);
376
+ saveStore();
377
+ return true;
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Status management
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /**
385
+ * Set task status with source tracking. Appends to statusHistory.
386
+ * source: "agent" | "orchestrator" | "external" | "review"
387
+ */
388
+ export function setTaskStatus(taskId, status, source) {
389
+ ensureLoaded();
390
+ const task = _store.tasks[taskId];
391
+ if (!task) {
392
+ console.warn(TAG, `setTaskStatus: task ${taskId} not found`);
393
+ return null;
394
+ }
395
+
396
+ const prev = task.status;
397
+ task.status = status;
398
+ task.updatedAt = now();
399
+ task.lastActivityAt = now();
400
+
401
+ // Append to history (FIFO, max 50)
402
+ task.statusHistory.push({
403
+ status,
404
+ timestamp: now(),
405
+ source: source || "unknown",
406
+ });
407
+ if (task.statusHistory.length > MAX_STATUS_HISTORY) {
408
+ task.statusHistory = task.statusHistory.slice(-MAX_STATUS_HISTORY);
409
+ }
410
+
411
+ // Mark dirty unless change came from external source
412
+ if (source !== "external") {
413
+ task.syncDirty = true;
414
+ }
415
+
416
+ console.log(
417
+ TAG,
418
+ `Task ${taskId} status: ${prev} → ${status} (source: ${source})`,
419
+ );
420
+
421
+ saveStore();
422
+ return { ...task };
423
+ }
424
+
425
+ /**
426
+ * Get the status history for a task.
427
+ */
428
+ export function getTaskHistory(taskId) {
429
+ ensureLoaded();
430
+ const task = _store.tasks[taskId];
431
+ if (!task) return [];
432
+ return [...task.statusHistory];
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Agent tracking
437
+ // ---------------------------------------------------------------------------
438
+
439
+ /**
440
+ * Record an agent attempt on a task.
441
+ * @param {string} taskId
442
+ * @param {{ output?: string, error?: string, hasCommits?: boolean }} info
443
+ */
444
+ export function recordAgentAttempt(taskId, { output, error, hasCommits } = {}) {
445
+ ensureLoaded();
446
+ const task = _store.tasks[taskId];
447
+ if (!task) {
448
+ console.warn(TAG, `recordAgentAttempt: task ${taskId} not found`);
449
+ return null;
450
+ }
451
+
452
+ task.agentAttempts = (task.agentAttempts || 0) + 1;
453
+ task.lastActivityAt = now();
454
+ task.updatedAt = now();
455
+
456
+ if (output !== undefined) {
457
+ task.lastAgentOutput = truncate(output, MAX_AGENT_OUTPUT);
458
+ }
459
+ if (error !== undefined) {
460
+ task.lastError = truncate(error, MAX_ERROR_LENGTH);
461
+ }
462
+
463
+ if (hasCommits) {
464
+ task.consecutiveNoCommits = 0;
465
+ } else {
466
+ task.consecutiveNoCommits = (task.consecutiveNoCommits || 0) + 1;
467
+ }
468
+
469
+ task.syncDirty = true;
470
+ saveStore();
471
+ return { ...task };
472
+ }
473
+
474
+ /**
475
+ * Record a classified error pattern on a task.
476
+ * @param {string} taskId
477
+ * @param {string|null} pattern - "plan_stuck" | "rate_limit" | "token_overflow" | "api_error" | null
478
+ */
479
+ export function recordErrorPattern(taskId, pattern) {
480
+ ensureLoaded();
481
+ const task = _store.tasks[taskId];
482
+ if (!task) {
483
+ console.warn(TAG, `recordErrorPattern: task ${taskId} not found`);
484
+ return null;
485
+ }
486
+
487
+ task.errorPattern = pattern;
488
+ task.updatedAt = now();
489
+ task.syncDirty = true;
490
+
491
+ saveStore();
492
+ return { ...task };
493
+ }
494
+
495
+ /**
496
+ * Set a cooldown on a task (prevents re-scheduling until timestamp).
497
+ */
498
+ export function setTaskCooldown(taskId, untilTimestamp, reason) {
499
+ ensureLoaded();
500
+ const task = _store.tasks[taskId];
501
+ if (!task) {
502
+ console.warn(TAG, `setTaskCooldown: task ${taskId} not found`);
503
+ return null;
504
+ }
505
+
506
+ task.cooldownUntil = untilTimestamp;
507
+ task.blockedReason = reason || null;
508
+ task.updatedAt = now();
509
+ task.syncDirty = true;
510
+
511
+ console.log(
512
+ TAG,
513
+ `Task ${taskId} cooldown until ${untilTimestamp}: ${reason}`,
514
+ );
515
+
516
+ saveStore();
517
+ return { ...task };
518
+ }
519
+
520
+ /**
521
+ * Clear the cooldown on a task.
522
+ */
523
+ export function clearTaskCooldown(taskId) {
524
+ ensureLoaded();
525
+ const task = _store.tasks[taskId];
526
+ if (!task) {
527
+ console.warn(TAG, `clearTaskCooldown: task ${taskId} not found`);
528
+ return null;
529
+ }
530
+
531
+ task.cooldownUntil = null;
532
+ task.blockedReason = null;
533
+ task.updatedAt = now();
534
+ task.syncDirty = true;
535
+
536
+ saveStore();
537
+ return { ...task };
538
+ }
539
+
540
+ /**
541
+ * Check if a task is currently cooling down.
542
+ */
543
+ export function isTaskCoolingDown(taskId) {
544
+ ensureLoaded();
545
+ const task = _store.tasks[taskId];
546
+ if (!task || !task.cooldownUntil) return false;
547
+ return new Date(task.cooldownUntil) > new Date();
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Review tracking
552
+ // ---------------------------------------------------------------------------
553
+
554
+ /**
555
+ * Set the review result for a task.
556
+ * @param {string} taskId
557
+ * @param {{ approved: boolean, issues?: Array<{severity: string, description: string}> }} result
558
+ */
559
+ export function setReviewResult(taskId, { approved, issues } = {}) {
560
+ ensureLoaded();
561
+ const task = _store.tasks[taskId];
562
+ if (!task) {
563
+ console.warn(TAG, `setReviewResult: task ${taskId} not found`);
564
+ return null;
565
+ }
566
+
567
+ task.reviewStatus = approved ? "approved" : "changes_requested";
568
+ task.reviewIssues = issues || null;
569
+ task.reviewedAt = now();
570
+ task.updatedAt = now();
571
+ task.lastActivityAt = now();
572
+ task.syncDirty = true;
573
+
574
+ console.log(
575
+ TAG,
576
+ `Task ${taskId} review: ${task.reviewStatus}${issues ? ` (${issues.length} issues)` : ""}`,
577
+ );
578
+
579
+ saveStore();
580
+ return { ...task };
581
+ }
582
+
583
+ /**
584
+ * Get tasks that are pending review (status === "inreview").
585
+ */
586
+ export function getTasksPendingReview() {
587
+ ensureLoaded();
588
+ return Object.values(_store.tasks).filter((t) => t.status === "inreview");
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // Sync helpers
593
+ // ---------------------------------------------------------------------------
594
+
595
+ /**
596
+ * Get all tasks that need syncing to external backend.
597
+ */
598
+ export function getDirtyTasks() {
599
+ ensureLoaded();
600
+ return Object.values(_store.tasks).filter((t) => t.syncDirty);
601
+ }
602
+
603
+ /**
604
+ * Mark a task as synced (clears syncDirty, sets lastSyncedAt).
605
+ */
606
+ export function markSynced(taskId) {
607
+ ensureLoaded();
608
+ const task = _store.tasks[taskId];
609
+ if (!task) return;
610
+
611
+ task.syncDirty = false;
612
+ task.lastSyncedAt = now();
613
+
614
+ saveStore();
615
+ }
616
+
617
+ /**
618
+ * Add or update a task from an external source.
619
+ * Only overrides fields the external backend controls.
620
+ * Sets syncDirty = false for the imported data.
621
+ */
622
+ export function upsertFromExternal(externalTask) {
623
+ ensureLoaded();
624
+ if (!externalTask || !externalTask.id) {
625
+ console.warn(TAG, "upsertFromExternal: task must have an id");
626
+ return null;
627
+ }
628
+
629
+ const existing = _store.tasks[externalTask.id];
630
+
631
+ if (existing) {
632
+ const externalBaseBranch =
633
+ externalTask.baseBranch ??
634
+ externalTask.base_branch ??
635
+ externalTask.meta?.base_branch ??
636
+ externalTask.meta?.baseBranch;
637
+ // Update only externally-controlled fields
638
+ if (externalTask.title !== undefined) existing.title = externalTask.title;
639
+ if (externalTask.description !== undefined)
640
+ existing.description = externalTask.description;
641
+ if (externalTask.assignee !== undefined)
642
+ existing.assignee = externalTask.assignee;
643
+ if (externalTask.priority !== undefined)
644
+ existing.priority = externalTask.priority;
645
+ if (externalTask.projectId !== undefined)
646
+ existing.projectId = externalTask.projectId;
647
+ if (externalBaseBranch !== undefined)
648
+ existing.baseBranch = externalBaseBranch;
649
+ if (externalTask.branchName !== undefined)
650
+ existing.branchName = externalTask.branchName;
651
+ if (externalTask.prNumber !== undefined)
652
+ existing.prNumber = externalTask.prNumber;
653
+ if (externalTask.prUrl !== undefined) existing.prUrl = externalTask.prUrl;
654
+ if (externalTask.meta !== undefined)
655
+ existing.meta = { ...existing.meta, ...externalTask.meta };
656
+
657
+ // Update external tracking fields
658
+ if (externalTask.externalId !== undefined)
659
+ existing.externalId = externalTask.externalId;
660
+ if (externalTask.externalBackend !== undefined)
661
+ existing.externalBackend = externalTask.externalBackend;
662
+
663
+ // Only update status if external changed it (human override)
664
+ if (
665
+ externalTask.status !== undefined &&
666
+ externalTask.status !== existing.externalStatus
667
+ ) {
668
+ existing.externalStatus = externalTask.status;
669
+ // If the external status differs from our status, adopt it
670
+ if (externalTask.status !== existing.status) {
671
+ existing.status = externalTask.status;
672
+ existing.statusHistory.push({
673
+ status: externalTask.status,
674
+ timestamp: now(),
675
+ source: "external",
676
+ });
677
+ if (existing.statusHistory.length > MAX_STATUS_HISTORY) {
678
+ existing.statusHistory =
679
+ existing.statusHistory.slice(-MAX_STATUS_HISTORY);
680
+ }
681
+ }
682
+ } else if (externalTask.status !== undefined) {
683
+ existing.externalStatus = externalTask.status;
684
+ }
685
+
686
+ existing.updatedAt = now();
687
+ existing.syncDirty = false;
688
+ existing.lastSyncedAt = now();
689
+
690
+ saveStore();
691
+ return { ...existing };
692
+ }
693
+
694
+ // New task from external — create it
695
+ const externalBaseBranch =
696
+ externalTask.baseBranch ??
697
+ externalTask.base_branch ??
698
+ externalTask.meta?.base_branch ??
699
+ externalTask.meta?.baseBranch;
700
+ const task = defaultTask({
701
+ ...externalTask,
702
+ ...(externalBaseBranch !== undefined ? { baseBranch: externalBaseBranch } : {}),
703
+ externalStatus: externalTask.status || null,
704
+ syncDirty: false,
705
+ lastSyncedAt: now(),
706
+ });
707
+ task.lastAgentOutput = truncate(task.lastAgentOutput, MAX_AGENT_OUTPUT);
708
+ task.lastError = truncate(task.lastError, MAX_ERROR_LENGTH);
709
+
710
+ _store.tasks[task.id] = task;
711
+ console.log(TAG, `Upserted external task ${task.id}: ${task.title}`);
712
+
713
+ saveStore();
714
+ return { ...task };
715
+ }
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // Statistics
719
+ // ---------------------------------------------------------------------------
720
+
721
+ /**
722
+ * Get aggregate stats across all tasks.
723
+ */
724
+ export function getStats() {
725
+ ensureLoaded();
726
+ recalcStats();
727
+ return {
728
+ ..._store._meta.stats,
729
+ total: _store._meta.taskCount,
730
+ };
731
+ }
732
+
733
+ /**
734
+ * Get tasks that have been "inprogress" for longer than maxAgeMs.
735
+ */
736
+ export function getStaleInProgressTasks(maxAgeMs) {
737
+ ensureLoaded();
738
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
739
+ return Object.values(_store.tasks).filter(
740
+ (t) => t.status === "inprogress" && t.lastActivityAt < cutoff,
741
+ );
742
+ }
743
+
744
+ /**
745
+ * Get tasks that have been "inreview" for longer than maxAgeMs.
746
+ */
747
+ export function getStaleInReviewTasks(maxAgeMs) {
748
+ ensureLoaded();
749
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
750
+ return Object.values(_store.tasks).filter(
751
+ (t) => t.status === "inreview" && t.lastActivityAt < cutoff,
752
+ );
753
+ }