granclaw 0.0.1-beta.97 → 0.0.1-beta.99

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.
@@ -1,9 +1,44 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  exports.registerBrowserProvider = registerBrowserProvider;
40
+ exports.registerBrowserKiller = registerBrowserKiller;
41
+ exports.killBrowser = killBrowser;
7
42
  exports._resetBrowserProvidersForTests = _resetBrowserProvidersForTests;
8
43
  exports.buildArgv = buildArgv;
9
44
  exports.cdpNavigate = cdpNavigate;
@@ -16,8 +51,31 @@ const providers = [];
16
51
  function registerBrowserProvider(provider) {
17
52
  providers.push(provider);
18
53
  }
54
+ const killers = [];
55
+ function registerBrowserKiller(killer) {
56
+ killers.push(killer);
57
+ }
58
+ async function killBrowser(agentId) {
59
+ for (const killer of killers) {
60
+ try {
61
+ await killer(agentId);
62
+ }
63
+ catch { }
64
+ }
65
+ try {
66
+ fs_1.default.unlinkSync(`/tmp/granclaw-cdp-${agentId}.url`);
67
+ }
68
+ catch { }
69
+ try {
70
+ const { execFileSync } = await Promise.resolve().then(() => __importStar(require('child_process')));
71
+ const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
72
+ execFileSync(bin, ['--session', agentId, 'close'], { timeout: 5000, stdio: 'pipe' });
73
+ }
74
+ catch { }
75
+ }
19
76
  function _resetBrowserProvidersForTests() {
20
77
  providers.length = 0;
78
+ killers.length = 0;
21
79
  }
22
80
  function buildArgv(res, command, args) {
23
81
  return [...res.preCommandArgs, command, ...args, ...res.postCommandArgs];
@@ -579,19 +579,31 @@ async function runAgent(agent, message, onChunk, options) {
579
579
  pi.registerTool({
580
580
  name: 'list_tasks',
581
581
  label: 'List Tasks',
582
- description: 'List tasks from the kanban board. Optionally filter by status.',
582
+ description: 'List tasks from the kanban board. Optionally filter by status, search text, or tags.',
583
583
  promptSnippet: 'List tasks',
584
- promptGuidelines: ['Use to see what is in backlog, in_progress, to_review, or done.'],
584
+ promptGuidelines: [
585
+ 'Use to see tasks across columns. Filter by column status, search title/description, or filter by tags.',
586
+ 'Default columns are to_do, in_progress, done — but custom columns may exist.',
587
+ ],
585
588
  parameters: {
586
589
  type: 'object',
587
590
  properties: {
588
- status: { type: 'string', enum: ['backlog', 'in_progress', 'scheduled', 'to_review', 'done', 'cancelled'], description: 'Filter by status (omit for all tasks)' },
591
+ status: { type: 'string', description: 'Filter by column status (e.g. to_do, in_progress, done)' },
592
+ search: { type: 'string', description: 'Search text — matches title or description' },
593
+ tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags — tasks must have ALL listed tags' },
589
594
  },
590
595
  },
591
596
  async execute(_id, params) {
592
597
  try {
593
- const url = params.status ? `${taskBase()}?status=${params.status}` : taskBase();
594
- const data = await fetchJson(url);
598
+ const qp = new URLSearchParams();
599
+ if (params.status)
600
+ qp.set('status', params.status);
601
+ if (params.search)
602
+ qp.set('search', params.search);
603
+ if (params.tags?.length)
604
+ qp.set('tags', params.tags.join(','));
605
+ const qs = qp.toString() ? `?${qp.toString()}` : '';
606
+ const data = await fetchJson(`${taskBase()}${qs}`);
595
607
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
596
608
  }
597
609
  catch (e) {
@@ -629,14 +641,15 @@ async function runAgent(agent, message, onChunk, options) {
629
641
  promptSnippet: 'Create a task',
630
642
  promptGuidelines: [
631
643
  'Use when breaking down work into subtasks or tracking a new action item.',
632
- 'Status defaults to backlog. Use markdown in description.',
644
+ 'Status defaults to to_do. Use markdown in description. Tags help organize tasks.',
633
645
  ],
634
646
  parameters: {
635
647
  type: 'object',
636
648
  properties: {
637
649
  title: { type: 'string', description: 'Short task title (under 80 chars)' },
638
650
  description: { type: 'string', description: 'Full description in markdown (optional)' },
639
- status: { type: 'string', enum: ['backlog', 'in_progress', 'scheduled', 'to_review', 'done'], description: 'Initial status (default: backlog)' },
651
+ status: { type: 'string', description: 'Column status (default: to_do)' },
652
+ tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization (optional)' },
640
653
  },
641
654
  required: ['title'],
642
655
  },
@@ -657,11 +670,11 @@ async function runAgent(agent, message, onChunk, options) {
657
670
  pi.registerTool({
658
671
  name: 'update_task',
659
672
  label: 'Update Task',
660
- description: 'Update a task\'s title, description, or status.',
673
+ description: 'Update a task\'s title, description, status, or tags.',
661
674
  promptSnippet: 'Update a task',
662
675
  promptGuidelines: [
663
676
  'Only send fields you want to change.',
664
- 'Move to in_progress when starting, to_review when done and awaiting human review.',
677
+ 'Move to in_progress when starting, done when complete.',
665
678
  ],
666
679
  parameters: {
667
680
  type: 'object',
@@ -669,7 +682,8 @@ async function runAgent(agent, message, onChunk, options) {
669
682
  taskId: { type: 'string', description: 'Task ID, e.g. TSK-001' },
670
683
  title: { type: 'string', description: 'New title (optional)' },
671
684
  description: { type: 'string', description: 'New description in markdown (optional)' },
672
- status: { type: 'string', enum: ['backlog', 'in_progress', 'scheduled', 'to_review', 'done'], description: 'New status (optional)' },
685
+ status: { type: 'string', description: 'New column status (optional)' },
686
+ tags: { type: 'array', items: { type: 'string' }, description: 'Replace tags (optional)' },
673
687
  },
674
688
  required: ['taskId'],
675
689
  },
@@ -838,6 +852,41 @@ async function runAgent(agent, message, onChunk, options) {
838
852
  },
839
853
  });
840
854
  });
855
+ extensionFactories.push((pi) => {
856
+ pi.registerTool({
857
+ name: 'browser_restart',
858
+ label: 'Restart Browser',
859
+ description: 'Kill the browser process and force a fresh start on the next browser command. ' +
860
+ 'Cookies, logins, and profile data are preserved — only the process is restarted. ' +
861
+ 'Use when the browser is hung, unresponsive, showing stale state, or after a proxy change.',
862
+ promptSnippet: 'Kill and respawn the browser daemon (cookies survive)',
863
+ promptGuidelines: [
864
+ 'Use when browser commands timeout repeatedly or the browser appears hung.',
865
+ 'Use after you receive BROWSER_BLOCKED if you want a clean process before retrying.',
866
+ 'Cookies and saved logins persist — only the process is restarted.',
867
+ 'The next browser command after this will spawn a fresh browser automatically.',
868
+ ],
869
+ parameters: { type: 'object', properties: {} },
870
+ async execute() {
871
+ try {
872
+ if (browserState.handle) {
873
+ try {
874
+ await (0, session_manager_js_1.finalizeSession)(browserState.handle, 'closed');
875
+ }
876
+ catch { }
877
+ browserState.handle = null;
878
+ }
879
+ await (0, browser_bin_js_1.killBrowser)(agent.id);
880
+ return { content: [{ type: 'text', text: 'Browser killed. Cookies and logins are preserved. The next browser command will spawn a fresh instance.' }] };
881
+ }
882
+ catch (err) {
883
+ browserState.handle = null;
884
+ const msg = err instanceof Error ? err.message : String(err);
885
+ return { content: [{ type: 'text', text: `browser_restart partial: ${msg} — next browser call will still spawn fresh.` }] };
886
+ }
887
+ },
888
+ });
889
+ });
841
890
  extensionFactories.push((pi) => {
842
891
  pi.registerTool({
843
892
  name: 'request_human_browser_takeover',
@@ -722,6 +722,51 @@ function createServer() {
722
722
  content,
723
723
  });
724
724
  });
725
+ app.get('/agents/:id/task-columns', (req, res) => {
726
+ const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
727
+ if (!managed) {
728
+ res.status(404).json({ error: 'Agent not found' });
729
+ return;
730
+ }
731
+ res.json((0, tasks_db_js_1.listColumns)(req.params.id));
732
+ });
733
+ app.post('/agents/:id/task-columns', (req, res) => {
734
+ const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
735
+ if (!managed) {
736
+ res.status(404).json({ error: 'Agent not found' });
737
+ return;
738
+ }
739
+ const { label } = req.body;
740
+ if (!label) {
741
+ res.status(400).json({ error: 'label required' });
742
+ return;
743
+ }
744
+ try {
745
+ const column = (0, tasks_db_js_1.createColumn)(req.params.id, { label });
746
+ res.status(201).json(column);
747
+ }
748
+ catch (e) {
749
+ res.status(409).json({ error: e.message });
750
+ }
751
+ });
752
+ app.delete('/agents/:id/task-columns/:columnId', (req, res) => {
753
+ const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
754
+ if (!managed) {
755
+ res.status(404).json({ error: 'Agent not found' });
756
+ return;
757
+ }
758
+ try {
759
+ const deleted = (0, tasks_db_js_1.deleteColumn)(req.params.id, req.params.columnId);
760
+ if (!deleted) {
761
+ res.status(404).json({ error: 'Column not found' });
762
+ return;
763
+ }
764
+ res.json({ ok: true });
765
+ }
766
+ catch (e) {
767
+ res.status(400).json({ error: e.message });
768
+ }
769
+ });
725
770
  app.get('/agents/:id/tasks', (req, res) => {
726
771
  const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
727
772
  if (!managed) {
@@ -729,7 +774,10 @@ function createServer() {
729
774
  return;
730
775
  }
731
776
  const status = req.query.status;
732
- res.json((0, tasks_db_js_1.listTasks)(req.params.id, status));
777
+ const search = req.query.search;
778
+ const tagsParam = req.query.tags;
779
+ const tags = tagsParam ? tagsParam.split(',') : undefined;
780
+ res.json((0, tasks_db_js_1.listTasks)(req.params.id, { status, search, tags }));
733
781
  });
734
782
  app.post('/agents/:id/tasks', (req, res) => {
735
783
  const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
@@ -737,14 +785,23 @@ function createServer() {
737
785
  res.status(404).json({ error: 'Agent not found' });
738
786
  return;
739
787
  }
740
- const { title, description, status } = req.body;
788
+ const { title, description, status, tags } = req.body;
741
789
  if (!title) {
742
790
  res.status(400).json({ error: 'title required' });
743
791
  return;
744
792
  }
745
- const task = (0, tasks_db_js_1.createTask)(req.params.id, { title, description, status: status });
793
+ const task = (0, tasks_db_js_1.createTask)(req.params.id, { title, description, status, tags });
746
794
  res.status(201).json(task);
747
795
  });
796
+ app.delete('/agents/:id/tasks', (req, res) => {
797
+ const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
798
+ if (!managed) {
799
+ res.status(404).json({ error: 'Agent not found' });
800
+ return;
801
+ }
802
+ const count = (0, tasks_db_js_1.clearTasks)(req.params.id);
803
+ res.json({ ok: true, deleted: count });
804
+ });
748
805
  app.get('/agents/:id/tasks/:taskId', (req, res) => {
749
806
  const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
750
807
  if (!managed) {
@@ -765,8 +822,8 @@ function createServer() {
765
822
  res.status(404).json({ error: 'Agent not found' });
766
823
  return;
767
824
  }
768
- const { title, description, status } = req.body;
769
- const task = (0, tasks_db_js_1.updateTask)(req.params.id, req.params.taskId, { title, description, status: status });
825
+ const { title, description, status, tags } = req.body;
826
+ const task = (0, tasks_db_js_1.updateTask)(req.params.id, req.params.taskId, { title, description, status, tags });
770
827
  if (!task) {
771
828
  res.status(404).json({ error: 'Task not found' });
772
829
  return;
@@ -1530,6 +1587,7 @@ function createServer() {
1530
1587
  (0, loader_js_1.loadExtensions)({
1531
1588
  app,
1532
1589
  registerBrowserProvider: browser_bin_js_1.registerBrowserProvider,
1590
+ registerBrowserKiller: browser_bin_js_1.registerBrowserKiller,
1533
1591
  registerCdpSession: browser_live_js_1.registerExternalCdpSession,
1534
1592
  removeCdpSession: browser_live_js_1.removeExternalCdpSession,
1535
1593
  registerTakeoverResolvedListener: takeover_listeners_js_1.registerTakeoverResolvedListener,
@@ -8,6 +8,10 @@ exports.getTask = getTask;
8
8
  exports.createTask = createTask;
9
9
  exports.updateTask = updateTask;
10
10
  exports.deleteTask = deleteTask;
11
+ exports.clearTasks = clearTasks;
12
+ exports.listColumns = listColumns;
13
+ exports.createColumn = createColumn;
14
+ exports.deleteColumn = deleteColumn;
11
15
  exports.listComments = listComments;
12
16
  exports.createComment = createComment;
13
17
  exports.closeTasksDb = closeTasksDb;
@@ -22,11 +26,13 @@ function getDb(agentId) {
22
26
  return (0, workspace_pool_js_1.getWorkspaceDb)(path_1.default.resolve(config_js_1.REPO_ROOT, agent.workspaceDir));
23
27
  }
24
28
  function rowToTask(r) {
29
+ const tagsStr = r.tags || '';
25
30
  return {
26
31
  id: r.id,
27
32
  title: r.title,
28
33
  description: r.description,
29
34
  status: r.status,
35
+ tags: tagsStr ? tagsStr.split(',') : [],
30
36
  source: r.source,
31
37
  updatedBy: r.updated_by ?? null,
32
38
  createdAt: r.created_at,
@@ -42,16 +48,42 @@ function rowToComment(r) {
42
48
  createdAt: r.created_at,
43
49
  };
44
50
  }
51
+ function rowToColumn(r) {
52
+ return {
53
+ id: r.id,
54
+ label: r.label,
55
+ position: r.position,
56
+ createdAt: r.created_at,
57
+ };
58
+ }
59
+ function slugify(label) {
60
+ return label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'column';
61
+ }
45
62
  function nextTaskId(db) {
46
63
  const row = db.prepare(`SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 0) + 1 AS next FROM tasks`).get();
47
64
  return `TSK-${String(row.next).padStart(3, '0')}`;
48
65
  }
49
- function listTasks(agentId, status) {
66
+ function listTasks(agentId, opts) {
50
67
  const db = getDb(agentId);
51
- if (status) {
52
- return db.prepare(`SELECT * FROM tasks WHERE status = ? ORDER BY created_at`).all(status).map(rowToTask);
68
+ const conditions = [];
69
+ const params = [];
70
+ if (opts?.status) {
71
+ conditions.push('status = ?');
72
+ params.push(opts.status);
73
+ }
74
+ if (opts?.search) {
75
+ conditions.push('(title LIKE ? OR description LIKE ?)');
76
+ const term = `%${opts.search}%`;
77
+ params.push(term, term);
78
+ }
79
+ if (opts?.tags?.length) {
80
+ for (const tag of opts.tags) {
81
+ conditions.push("(',' || tags || ',' LIKE ?)");
82
+ params.push(`%,${tag},%`);
83
+ }
53
84
  }
54
- return db.prepare(`SELECT * FROM tasks ORDER BY created_at`).all().map(rowToTask);
85
+ const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
86
+ return db.prepare(`SELECT * FROM tasks${where} ORDER BY created_at`).all(...params).map(rowToTask);
55
87
  }
56
88
  function getTask(agentId, taskId) {
57
89
  const db = getDb(agentId);
@@ -62,10 +94,11 @@ function createTask(agentId, data) {
62
94
  const db = getDb(agentId);
63
95
  const id = nextTaskId(db);
64
96
  const now = Math.floor(Date.now() / 1000);
97
+ const tagsStr = (data.tags ?? []).join(',');
65
98
  db.prepare(`
66
- INSERT INTO tasks (id, title, description, status, source, created_at, updated_at)
67
- VALUES (?, ?, ?, ?, 'human', ?, ?)
68
- `).run(id, data.title, data.description ?? '', data.status ?? 'backlog', now, now);
99
+ INSERT INTO tasks (id, title, description, status, tags, source, created_at, updated_at)
100
+ VALUES (?, ?, ?, ?, ?, 'human', ?, ?)
101
+ `).run(id, data.title, data.description ?? '', data.status ?? 'to_do', tagsStr, now, now);
69
102
  return getTask(agentId, id);
70
103
  }
71
104
  function updateTask(agentId, taskId, data) {
@@ -74,10 +107,11 @@ function updateTask(agentId, taskId, data) {
74
107
  if (!existing)
75
108
  return null;
76
109
  const now = Math.floor(Date.now() / 1000);
110
+ const tagsStr = data.tags !== undefined ? data.tags.join(',') : existing.tags.join(',');
77
111
  db.prepare(`
78
- UPDATE tasks SET title = ?, description = ?, status = ?, updated_by = 'human', updated_at = ?
112
+ UPDATE tasks SET title = ?, description = ?, status = ?, tags = ?, updated_by = 'human', updated_at = ?
79
113
  WHERE id = ?
80
- `).run(data.title ?? existing.title, data.description ?? existing.description, data.status ?? existing.status, now, taskId);
114
+ `).run(data.title ?? existing.title, data.description ?? existing.description, data.status ?? existing.status, tagsStr, now, taskId);
81
115
  return getTask(agentId, taskId);
82
116
  }
83
117
  function deleteTask(agentId, taskId) {
@@ -85,6 +119,38 @@ function deleteTask(agentId, taskId) {
85
119
  const result = db.prepare(`DELETE FROM tasks WHERE id = ?`).run(taskId);
86
120
  return result.changes > 0;
87
121
  }
122
+ function clearTasks(agentId) {
123
+ const db = getDb(agentId);
124
+ const result = db.prepare('DELETE FROM tasks').run();
125
+ return result.changes;
126
+ }
127
+ function listColumns(agentId) {
128
+ const db = getDb(agentId);
129
+ return db.prepare('SELECT * FROM task_columns ORDER BY position').all().map(rowToColumn);
130
+ }
131
+ function createColumn(agentId, data) {
132
+ const db = getDb(agentId);
133
+ const id = slugify(data.label);
134
+ const existing = db.prepare('SELECT id FROM task_columns WHERE id = ?').get(id);
135
+ if (existing)
136
+ throw new Error(`Column "${id}" already exists`);
137
+ const maxPos = db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS next FROM task_columns').get().next;
138
+ const now = Math.floor(Date.now() / 1000);
139
+ db.prepare('INSERT INTO task_columns (id, label, position, created_at) VALUES (?, ?, ?, ?)').run(id, data.label, maxPos, now);
140
+ return { id, label: data.label, position: maxPos, createdAt: now };
141
+ }
142
+ function deleteColumn(agentId, columnId) {
143
+ const db = getDb(agentId);
144
+ const count = db.prepare('SELECT COUNT(*) as n FROM task_columns').get().n;
145
+ if (count <= 1)
146
+ throw new Error('Cannot delete the last column');
147
+ const firstCol = db.prepare('SELECT id FROM task_columns WHERE id != ? ORDER BY position LIMIT 1').get(columnId);
148
+ if (firstCol) {
149
+ db.prepare('UPDATE tasks SET status = ? WHERE status = ?').run(firstCol.id, columnId);
150
+ }
151
+ const result = db.prepare('DELETE FROM task_columns WHERE id = ?').run(columnId);
152
+ return result.changes > 0;
153
+ }
88
154
  function listComments(agentId, taskId) {
89
155
  const db = getDb(agentId);
90
156
  return db.prepare(`SELECT * FROM comments WHERE task_id = ? ORDER BY created_at ASC`).all(taskId).map(rowToComment);
@@ -49,8 +49,8 @@ function getWorkspaceDb(workspaceDir) {
49
49
  id TEXT PRIMARY KEY,
50
50
  title TEXT NOT NULL,
51
51
  description TEXT NOT NULL DEFAULT '',
52
- status TEXT NOT NULL DEFAULT 'backlog'
53
- CHECK(status IN ('backlog','in_progress','scheduled','to_review','done','cancelled')),
52
+ status TEXT NOT NULL DEFAULT 'to_do',
53
+ tags TEXT NOT NULL DEFAULT '',
54
54
  source TEXT NOT NULL DEFAULT 'agent'
55
55
  CHECK(source IN ('agent','human')),
56
56
  updated_by TEXT DEFAULT NULL
@@ -65,6 +65,12 @@ function getWorkspaceDb(workspaceDir) {
65
65
  source TEXT NOT NULL CHECK(source IN ('agent','human')),
66
66
  created_at INTEGER NOT NULL
67
67
  );
68
+ CREATE TABLE IF NOT EXISTS task_columns (
69
+ id TEXT PRIMARY KEY,
70
+ label TEXT NOT NULL,
71
+ position INTEGER NOT NULL,
72
+ created_at INTEGER NOT NULL
73
+ );
68
74
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
69
75
  CREATE INDEX IF NOT EXISTS idx_comments_task ON comments(task_id, created_at);
70
76
  `);
@@ -122,15 +128,17 @@ function getWorkspaceDb(workspaceDir) {
122
128
  db.exec(`ALTER TABLE run_steps ADD COLUMN events TEXT`);
123
129
  console.log('[workspace-pool] migrated run_steps table (added events column)');
124
130
  }
125
- const tasksSchema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'`).get();
126
- if (tasksSchema?.sql && !tasksSchema.sql.includes('cancelled')) {
131
+ const taskCols = db.pragma('table_info(tasks)').map(c => c.name);
132
+ if (taskCols.length > 0 && !taskCols.includes('tags')) {
127
133
  db.exec(`
128
- CREATE TABLE tasks_new (
134
+ DROP TABLE IF EXISTS comments;
135
+ DROP TABLE IF EXISTS tasks;
136
+ CREATE TABLE tasks (
129
137
  id TEXT PRIMARY KEY,
130
138
  title TEXT NOT NULL,
131
139
  description TEXT NOT NULL DEFAULT '',
132
- status TEXT NOT NULL DEFAULT 'backlog'
133
- CHECK(status IN ('backlog','in_progress','scheduled','to_review','done','cancelled')),
140
+ status TEXT NOT NULL DEFAULT 'to_do',
141
+ tags TEXT NOT NULL DEFAULT '',
134
142
  source TEXT NOT NULL DEFAULT 'agent'
135
143
  CHECK(source IN ('agent','human')),
136
144
  updated_by TEXT DEFAULT NULL
@@ -138,12 +146,24 @@ function getWorkspaceDb(workspaceDir) {
138
146
  created_at INTEGER NOT NULL,
139
147
  updated_at INTEGER NOT NULL
140
148
  );
141
- INSERT INTO tasks_new SELECT * FROM tasks;
142
- DROP TABLE tasks;
143
- ALTER TABLE tasks_new RENAME TO tasks;
144
- CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
149
+ CREATE TABLE comments (
150
+ id TEXT PRIMARY KEY,
151
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
152
+ body TEXT NOT NULL,
153
+ source TEXT NOT NULL CHECK(source IN ('agent','human')),
154
+ created_at INTEGER NOT NULL
155
+ );
156
+ CREATE INDEX idx_tasks_status ON tasks(status);
157
+ CREATE INDEX idx_comments_task ON comments(task_id, created_at);
145
158
  `);
146
- console.log('[workspace-pool] migrated tasks table (added cancelled status)');
159
+ console.log('[workspace-pool] recreated tasks table (v2: tags + custom columns)');
160
+ }
161
+ const colCount = db.prepare('SELECT COUNT(*) as n FROM task_columns').get().n;
162
+ if (colCount === 0) {
163
+ const seedNow = Math.floor(Date.now() / 1000);
164
+ db.prepare('INSERT INTO task_columns (id, label, position, created_at) VALUES (?, ?, ?, ?)').run('to_do', 'To Do', 0, seedNow);
165
+ db.prepare('INSERT INTO task_columns (id, label, position, created_at) VALUES (?, ?, ?, ?)').run('in_progress', 'In Progress', 1, seedNow);
166
+ db.prepare('INSERT INTO task_columns (id, label, position, created_at) VALUES (?, ?, ?, ?)').run('done', 'Done', 2, seedNow);
147
167
  }
148
168
  const stepsSchema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='steps'`).get();
149
169
  if (stepsSchema?.sql && !stepsSchema.sql.includes('agent')) {