@web42/stask 0.1.5
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.
- package/README.md +100 -0
- package/bin/stask.mjs +157 -0
- package/commands/approve.mjs +43 -0
- package/commands/assign.mjs +64 -0
- package/commands/create.mjs +88 -0
- package/commands/delete.mjs +127 -0
- package/commands/heartbeat.mjs +375 -0
- package/commands/list.mjs +60 -0
- package/commands/log.mjs +34 -0
- package/commands/pr-status.mjs +33 -0
- package/commands/qa.mjs +183 -0
- package/commands/session.mjs +76 -0
- package/commands/show.mjs +61 -0
- package/commands/spec-update.mjs +61 -0
- package/commands/subtask-create.mjs +62 -0
- package/commands/subtask-done.mjs +80 -0
- package/commands/sync-daemon.mjs +134 -0
- package/commands/sync.mjs +36 -0
- package/commands/transition.mjs +143 -0
- package/config.example.json +55 -0
- package/lib/env.mjs +118 -0
- package/lib/file-uploader.mjs +179 -0
- package/lib/guards.mjs +261 -0
- package/lib/pr-create.mjs +133 -0
- package/lib/pr-status.mjs +119 -0
- package/lib/roles.mjs +85 -0
- package/lib/session-tracker.mjs +150 -0
- package/lib/slack-api.mjs +198 -0
- package/lib/slack-row.mjs +257 -0
- package/lib/slack-sync.mjs +388 -0
- package/lib/sync-daemon.mjs +117 -0
- package/lib/tracker-db.mjs +473 -0
- package/lib/tx.mjs +84 -0
- package/lib/validate.mjs +90 -0
- package/lib/worktree-cleanup.mjs +91 -0
- package/lib/worktree-create.mjs +127 -0
- package/package.json +34 -0
- package/skills/stask-general.md +113 -0
- package/skills/stask-lead.md +72 -0
- package/skills/stask-qa.md +99 -0
- package/skills/stask-worker.md +61 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tracker-db.mjs — SQLite-backed task tracker.
|
|
3
|
+
* Replaces tracker-io.mjs. The database enforces all lifecycle rules
|
|
4
|
+
* via CHECK constraints and triggers — the DB can never be in an illegal state.
|
|
5
|
+
*
|
|
6
|
+
* Uses better-sqlite3 (synchronous API, WAL mode for concurrent access).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Database from 'better-sqlite3';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
// Resolve paths from STASK_HOME directly to avoid circular deps with env.mjs
|
|
15
|
+
import os from 'os';
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const STASK_HOME = process.env.STASK_HOME || path.join(os.homedir(), '.stask');
|
|
18
|
+
const STASK_ROOT = path.resolve(__dirname, '..'); // Package install dir
|
|
19
|
+
const _configRaw = JSON.parse(fs.readFileSync(path.join(STASK_HOME, 'config.json'), 'utf-8'));
|
|
20
|
+
const WORKSPACE_DIR = _configRaw.specsDir;
|
|
21
|
+
const TASKS_DIR = STASK_HOME; // Runtime data lives in ~/.stask/
|
|
22
|
+
const DB_PATH = path.join(STASK_HOME, 'tracker.db');
|
|
23
|
+
|
|
24
|
+
// ─── Schema ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const SCHEMA = `
|
|
27
|
+
PRAGMA journal_mode = WAL;
|
|
28
|
+
PRAGMA foreign_keys = ON;
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
31
|
+
task_id TEXT PRIMARY KEY,
|
|
32
|
+
task_name TEXT NOT NULL,
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'To-Do'
|
|
34
|
+
CHECK (status IN ('To-Do','In-Progress','Testing',
|
|
35
|
+
'Ready for Human Review','Blocked','Done')),
|
|
36
|
+
assigned_to TEXT,
|
|
37
|
+
spec TEXT NOT NULL,
|
|
38
|
+
qa_report_1 TEXT,
|
|
39
|
+
qa_report_2 TEXT,
|
|
40
|
+
qa_report_3 TEXT,
|
|
41
|
+
type TEXT NOT NULL DEFAULT 'Task'
|
|
42
|
+
CHECK (type IN ('Feature','Bug','Task','Improvement','Research')),
|
|
43
|
+
parent_id TEXT REFERENCES tasks(task_id),
|
|
44
|
+
blocker TEXT,
|
|
45
|
+
worktree TEXT,
|
|
46
|
+
pr TEXT,
|
|
47
|
+
qa_fail_count INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
pr_status TEXT,
|
|
49
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
50
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS log (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
task_id TEXT NOT NULL,
|
|
56
|
+
message TEXT NOT NULL,
|
|
57
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
58
|
+
);
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const TRIGGERS = `
|
|
62
|
+
-- Prevent log tampering
|
|
63
|
+
CREATE TRIGGER IF NOT EXISTS log_no_update BEFORE UPDATE ON log
|
|
64
|
+
BEGIN SELECT RAISE(ABORT, 'Log entries are immutable'); END;
|
|
65
|
+
|
|
66
|
+
CREATE TRIGGER IF NOT EXISTS log_no_delete BEFORE DELETE ON log
|
|
67
|
+
BEGIN SELECT RAISE(ABORT, 'Log entries cannot be deleted'); END;
|
|
68
|
+
|
|
69
|
+
-- Status transition validation
|
|
70
|
+
CREATE TRIGGER IF NOT EXISTS validate_status_transition
|
|
71
|
+
BEFORE UPDATE OF status ON tasks
|
|
72
|
+
WHEN OLD.status != NEW.status
|
|
73
|
+
BEGIN
|
|
74
|
+
SELECT CASE
|
|
75
|
+
WHEN OLD.status = 'Done' THEN
|
|
76
|
+
RAISE(ABORT, 'Cannot transition from Done (terminal state)')
|
|
77
|
+
WHEN OLD.status = 'To-Do' AND NEW.status NOT IN ('In-Progress','Blocked') THEN
|
|
78
|
+
RAISE(ABORT, 'To-Do can only transition to In-Progress or Blocked')
|
|
79
|
+
WHEN OLD.status = 'In-Progress' AND NEW.status NOT IN ('Testing','Blocked') THEN
|
|
80
|
+
RAISE(ABORT, 'In-Progress can only transition to Testing or Blocked')
|
|
81
|
+
WHEN OLD.status = 'Testing' AND NEW.status NOT IN ('Ready for Human Review','In-Progress','Blocked') THEN
|
|
82
|
+
RAISE(ABORT, 'Testing can only transition to Ready for Human Review, In-Progress, or Blocked')
|
|
83
|
+
WHEN OLD.status = 'Ready for Human Review' AND NEW.status NOT IN ('Done','In-Progress','Blocked') THEN
|
|
84
|
+
RAISE(ABORT, 'Ready for Human Review can only transition to Done, In-Progress, or Blocked')
|
|
85
|
+
WHEN OLD.status = 'Blocked' AND NEW.status NOT IN ('To-Do','In-Progress','Testing','Ready for Human Review') THEN
|
|
86
|
+
RAISE(ABORT, 'Blocked can transition to To-Do, In-Progress, Testing, or Ready for Human Review')
|
|
87
|
+
END;
|
|
88
|
+
END;
|
|
89
|
+
|
|
90
|
+
-- Worktree required for parent tasks going to In-Progress from To-Do
|
|
91
|
+
CREATE TRIGGER IF NOT EXISTS enforce_in_progress_requirements
|
|
92
|
+
BEFORE UPDATE OF status ON tasks
|
|
93
|
+
WHEN NEW.status = 'In-Progress' AND OLD.status = 'To-Do'
|
|
94
|
+
AND NEW.parent_id IS NULL
|
|
95
|
+
BEGIN
|
|
96
|
+
SELECT CASE
|
|
97
|
+
WHEN NEW.worktree IS NULL OR NEW.worktree = '' THEN
|
|
98
|
+
RAISE(ABORT, 'Parent task requires a worktree before moving to In-Progress')
|
|
99
|
+
END;
|
|
100
|
+
END;
|
|
101
|
+
|
|
102
|
+
-- QA report + worktree required for Ready for Human Review (parent tasks only)
|
|
103
|
+
CREATE TRIGGER IF NOT EXISTS enforce_ready_for_review_requirements
|
|
104
|
+
BEFORE UPDATE OF status ON tasks
|
|
105
|
+
WHEN NEW.status = 'Ready for Human Review' AND NEW.parent_id IS NULL
|
|
106
|
+
BEGIN
|
|
107
|
+
SELECT CASE
|
|
108
|
+
WHEN NEW.qa_fail_count = 0 AND (NEW.qa_report_1 IS NULL OR NEW.qa_report_1 = '') THEN
|
|
109
|
+
RAISE(ABORT, 'QA Report (attempt 1) required before Ready for Human Review')
|
|
110
|
+
WHEN NEW.qa_fail_count = 1 AND (NEW.qa_report_2 IS NULL OR NEW.qa_report_2 = '') THEN
|
|
111
|
+
RAISE(ABORT, 'QA Report (attempt 2) required before Ready for Human Review')
|
|
112
|
+
WHEN NEW.qa_fail_count = 2 AND (NEW.qa_report_3 IS NULL OR NEW.qa_report_3 = '') THEN
|
|
113
|
+
RAISE(ABORT, 'QA Report (attempt 3) required before Ready for Human Review')
|
|
114
|
+
WHEN NEW.worktree IS NULL OR NEW.worktree = '' THEN
|
|
115
|
+
RAISE(ABORT, 'Worktree required before Ready for Human Review')
|
|
116
|
+
WHEN NEW.pr IS NULL OR NEW.pr = '' THEN
|
|
117
|
+
RAISE(ABORT, 'Draft PR required before Ready for Human Review')
|
|
118
|
+
END;
|
|
119
|
+
END;
|
|
120
|
+
|
|
121
|
+
-- Auto-update timestamp
|
|
122
|
+
CREATE TRIGGER IF NOT EXISTS update_timestamp
|
|
123
|
+
AFTER UPDATE ON tasks
|
|
124
|
+
BEGIN
|
|
125
|
+
UPDATE tasks SET updated_at = datetime('now') WHERE task_id = NEW.task_id;
|
|
126
|
+
END;
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
// ─── Singleton DB ───────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
let _db = null;
|
|
132
|
+
|
|
133
|
+
export function getDb() {
|
|
134
|
+
if (_db) return _db;
|
|
135
|
+
const isNew = !fs.existsSync(DB_PATH);
|
|
136
|
+
_db = new Database(DB_PATH);
|
|
137
|
+
_db.pragma('journal_mode = WAL');
|
|
138
|
+
_db.pragma('foreign_keys = ON');
|
|
139
|
+
if (isNew) {
|
|
140
|
+
_db.exec(SCHEMA);
|
|
141
|
+
_db.exec(TRIGGERS);
|
|
142
|
+
} else {
|
|
143
|
+
// Ensure triggers exist (idempotent)
|
|
144
|
+
_db.exec(TRIGGERS);
|
|
145
|
+
// Migrate: add pr_status column if missing (renamed from review_flag)
|
|
146
|
+
const cols = _db.pragma('table_info(tasks)').map(c => c.name);
|
|
147
|
+
if (!cols.includes('pr_status')) {
|
|
148
|
+
if (cols.includes('review_flag')) {
|
|
149
|
+
_db.exec('ALTER TABLE tasks RENAME COLUMN review_flag TO pr_status');
|
|
150
|
+
} else {
|
|
151
|
+
_db.exec('ALTER TABLE tasks ADD COLUMN pr_status TEXT');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Migrate: expand type CHECK constraint to include Improvement, Research
|
|
156
|
+
// SQLite can't alter CHECK constraints, so we recreate the table
|
|
157
|
+
const typeCheck = _db.prepare(
|
|
158
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'"
|
|
159
|
+
).get();
|
|
160
|
+
if (typeCheck?.sql && !typeCheck.sql.includes('Improvement')) {
|
|
161
|
+
_db.exec('PRAGMA foreign_keys = OFF');
|
|
162
|
+
_db.exec(`
|
|
163
|
+
CREATE TABLE tasks_new (
|
|
164
|
+
task_id TEXT PRIMARY KEY,
|
|
165
|
+
task_name TEXT NOT NULL,
|
|
166
|
+
status TEXT NOT NULL DEFAULT 'To-Do'
|
|
167
|
+
CHECK (status IN ('To-Do','In-Progress','Testing',
|
|
168
|
+
'Ready for Human Review','Blocked','Done')),
|
|
169
|
+
assigned_to TEXT,
|
|
170
|
+
spec TEXT NOT NULL,
|
|
171
|
+
qa_report_1 TEXT,
|
|
172
|
+
qa_report_2 TEXT,
|
|
173
|
+
qa_report_3 TEXT,
|
|
174
|
+
type TEXT NOT NULL DEFAULT 'Task'
|
|
175
|
+
CHECK (type IN ('Feature','Bug','Task','Improvement','Research')),
|
|
176
|
+
parent_id TEXT REFERENCES tasks(task_id),
|
|
177
|
+
blocker TEXT,
|
|
178
|
+
worktree TEXT,
|
|
179
|
+
pr TEXT,
|
|
180
|
+
qa_fail_count INTEGER NOT NULL DEFAULT 0,
|
|
181
|
+
pr_status TEXT,
|
|
182
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
183
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
184
|
+
);
|
|
185
|
+
INSERT INTO tasks_new SELECT
|
|
186
|
+
task_id, task_name, status, assigned_to, spec,
|
|
187
|
+
qa_report_1, qa_report_2, qa_report_3, type, parent_id,
|
|
188
|
+
blocker, worktree, pr, qa_fail_count, pr_status,
|
|
189
|
+
COALESCE(created_at, datetime('now')),
|
|
190
|
+
COALESCE(updated_at, datetime('now'))
|
|
191
|
+
FROM tasks;
|
|
192
|
+
DROP TABLE tasks;
|
|
193
|
+
ALTER TABLE tasks_new RENAME TO tasks;
|
|
194
|
+
`);
|
|
195
|
+
_db.exec('PRAGMA foreign_keys = ON');
|
|
196
|
+
// Re-create triggers on the new table
|
|
197
|
+
_db.exec(TRIGGERS);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return _db;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function closeDb() {
|
|
204
|
+
if (_db) { _db.close(); _db = null; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Sync state table ──────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const SYNC_STATE_SQL = `
|
|
210
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
211
|
+
task_id TEXT PRIMARY KEY REFERENCES tasks(task_id),
|
|
212
|
+
last_slack_ts INTEGER NOT NULL DEFAULT 0,
|
|
213
|
+
last_db_ts TEXT NOT NULL DEFAULT '',
|
|
214
|
+
last_synced TEXT NOT NULL DEFAULT ''
|
|
215
|
+
);
|
|
216
|
+
`;
|
|
217
|
+
|
|
218
|
+
let _syncStateCreated = false;
|
|
219
|
+
|
|
220
|
+
export function ensureSyncStateTable() {
|
|
221
|
+
if (_syncStateCreated) return;
|
|
222
|
+
const db = getDb();
|
|
223
|
+
db.exec(SYNC_STATE_SQL);
|
|
224
|
+
_syncStateCreated = true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function getSyncState(taskId) {
|
|
228
|
+
ensureSyncStateTable();
|
|
229
|
+
const db = getDb();
|
|
230
|
+
return db.prepare('SELECT * FROM sync_state WHERE task_id = ?').get(taskId) || null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function setSyncState(taskId, slackTs, dbTs) {
|
|
234
|
+
ensureSyncStateTable();
|
|
235
|
+
const db = getDb();
|
|
236
|
+
db.prepare(`
|
|
237
|
+
INSERT INTO sync_state (task_id, last_slack_ts, last_db_ts, last_synced)
|
|
238
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
239
|
+
ON CONFLICT(task_id) DO UPDATE SET
|
|
240
|
+
last_slack_ts = excluded.last_slack_ts,
|
|
241
|
+
last_db_ts = excluded.last_db_ts,
|
|
242
|
+
last_synced = datetime('now')
|
|
243
|
+
`).run(taskId, slackTs, dbTs);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Direct field update — bypasses status transition triggers.
|
|
248
|
+
* Used by Slack→DB sync where human authority supersedes the state machine.
|
|
249
|
+
*/
|
|
250
|
+
export function updateTaskDirect(taskId, fields) {
|
|
251
|
+
const db = getDb();
|
|
252
|
+
// Temporarily disable the status transition trigger for human overrides
|
|
253
|
+
// We do this by updating fields one-by-one, with status handled specially
|
|
254
|
+
if (fields.status) {
|
|
255
|
+
// Drop and recreate the transition trigger temporarily
|
|
256
|
+
db.exec('DROP TRIGGER IF EXISTS validate_status_transition');
|
|
257
|
+
db.exec('DROP TRIGGER IF EXISTS enforce_in_progress_requirements');
|
|
258
|
+
db.exec('DROP TRIGGER IF EXISTS enforce_ready_for_review_requirements');
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
|
|
262
|
+
const sql = `UPDATE tasks SET ${sets} WHERE task_id = ?`;
|
|
263
|
+
const result = db.prepare(sql).run(...Object.values(fields), taskId);
|
|
264
|
+
if (result.changes === 0) throw new Error(`Task ${taskId} not found`);
|
|
265
|
+
} finally {
|
|
266
|
+
if (fields.status) {
|
|
267
|
+
// Re-enable triggers (TRIGGERS constant contains CREATE IF NOT EXISTS)
|
|
268
|
+
db.exec(TRIGGERS);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Task CRUD ──────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get all tasks as an array of objects.
|
|
277
|
+
* Keys use the original TRACKER.md column names for backward compatibility
|
|
278
|
+
* with tracker-sync.mjs and other consumers.
|
|
279
|
+
*/
|
|
280
|
+
export function getAllTasks() {
|
|
281
|
+
const db = getDb();
|
|
282
|
+
const rows = db.prepare('SELECT * FROM tasks ORDER BY task_id').all();
|
|
283
|
+
return rows.map(rowToTask);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Find a single task by ID. Returns null if not found.
|
|
288
|
+
*/
|
|
289
|
+
export function findTask(taskId) {
|
|
290
|
+
const db = getDb();
|
|
291
|
+
const row = db.prepare('SELECT * FROM tasks WHERE task_id = ?').get(taskId);
|
|
292
|
+
return row ? rowToTask(row) : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get all subtasks for a parent task ID.
|
|
297
|
+
*/
|
|
298
|
+
export function getSubtasks(parentId) {
|
|
299
|
+
const db = getDb();
|
|
300
|
+
return db.prepare('SELECT * FROM tasks WHERE parent_id = ?').all(parentId).map(rowToTask);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get the next available top-level task ID (T-NNN).
|
|
305
|
+
*/
|
|
306
|
+
export function getNextTaskId() {
|
|
307
|
+
const db = getDb();
|
|
308
|
+
const row = db.prepare(`
|
|
309
|
+
SELECT task_id FROM tasks
|
|
310
|
+
WHERE task_id GLOB 'T-[0-9][0-9][0-9]'
|
|
311
|
+
ORDER BY task_id DESC LIMIT 1
|
|
312
|
+
`).get();
|
|
313
|
+
if (!row) return 'T-001';
|
|
314
|
+
const num = parseInt(row.task_id.slice(2), 10);
|
|
315
|
+
return `T-${String(num + 1).padStart(3, '0')}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get the next subtask ID for a parent (T-NNN.M).
|
|
320
|
+
*/
|
|
321
|
+
export function getNextSubtaskId(parentId) {
|
|
322
|
+
const db = getDb();
|
|
323
|
+
const row = db.prepare(`
|
|
324
|
+
SELECT task_id FROM tasks
|
|
325
|
+
WHERE parent_id = ?
|
|
326
|
+
ORDER BY task_id DESC LIMIT 1
|
|
327
|
+
`).get(parentId);
|
|
328
|
+
if (!row) return `${parentId}.1`;
|
|
329
|
+
const suffix = row.task_id.split('.').pop();
|
|
330
|
+
return `${parentId}.${parseInt(suffix, 10) + 1}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Insert a new task. Throws on constraint violation.
|
|
335
|
+
*/
|
|
336
|
+
export function insertTask(fields) {
|
|
337
|
+
const db = getDb();
|
|
338
|
+
const cols = Object.keys(fields);
|
|
339
|
+
const placeholders = cols.map(() => '?').join(', ');
|
|
340
|
+
const sql = `INSERT INTO tasks (${cols.join(', ')}) VALUES (${placeholders})`;
|
|
341
|
+
db.prepare(sql).run(...Object.values(fields));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Update specific fields on a task. DB triggers enforce rules.
|
|
346
|
+
* Throws if the update violates any constraint.
|
|
347
|
+
*/
|
|
348
|
+
export function updateTask(taskId, fields) {
|
|
349
|
+
const db = getDb();
|
|
350
|
+
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
|
|
351
|
+
const sql = `UPDATE tasks SET ${sets} WHERE task_id = ?`;
|
|
352
|
+
const result = db.prepare(sql).run(...Object.values(fields), taskId);
|
|
353
|
+
if (result.changes === 0) {
|
|
354
|
+
throw new Error(`Task ${taskId} not found`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get tasks by status.
|
|
360
|
+
*/
|
|
361
|
+
export function getTasksByStatus(status) {
|
|
362
|
+
const db = getDb();
|
|
363
|
+
return db.prepare('SELECT * FROM tasks WHERE status = ?').all(status).map(rowToTask);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get tasks by assignee.
|
|
368
|
+
*/
|
|
369
|
+
export function getTasksByAssignee(name) {
|
|
370
|
+
const db = getDb();
|
|
371
|
+
return db.prepare('SELECT * FROM tasks WHERE assigned_to = ?').all(name).map(rowToTask);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Log ────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Add a log entry. Log entries are immutable (triggers prevent update/delete).
|
|
378
|
+
*/
|
|
379
|
+
export function addLogEntry(taskId, message) {
|
|
380
|
+
const db = getDb();
|
|
381
|
+
db.prepare('INSERT INTO log (task_id, message) VALUES (?, ?)').run(taskId, message);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Get log entries, newest first.
|
|
386
|
+
*/
|
|
387
|
+
export function getLog(limit = 100) {
|
|
388
|
+
const db = getDb();
|
|
389
|
+
return db.prepare('SELECT * FROM log ORDER BY id DESC LIMIT ?').all(limit);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get log entries for a specific task.
|
|
394
|
+
*/
|
|
395
|
+
export function getLogForTask(taskId) {
|
|
396
|
+
const db = getDb();
|
|
397
|
+
return db.prepare('SELECT * FROM log WHERE task_id = ? ORDER BY id DESC').all(taskId);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Count QA failures in the log for a task (for escalation logic).
|
|
402
|
+
*/
|
|
403
|
+
export function getQaFailCount(taskId) {
|
|
404
|
+
const db = getDb();
|
|
405
|
+
const task = db.prepare('SELECT qa_fail_count FROM tasks WHERE task_id = ?').get(taskId);
|
|
406
|
+
return task ? task.qa_fail_count : 0;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─── Worktree/PR helpers ────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get the worktree for a task (or its parent if it's a subtask).
|
|
413
|
+
*/
|
|
414
|
+
export function getParentWorktree(taskId) {
|
|
415
|
+
const task = findTask(taskId);
|
|
416
|
+
if (!task) return null;
|
|
417
|
+
|
|
418
|
+
const wt = parseWorktreeValue(task['Worktree']);
|
|
419
|
+
if (wt) return wt;
|
|
420
|
+
|
|
421
|
+
if (task['Parent'] && task['Parent'] !== 'None') {
|
|
422
|
+
const parent = findTask(task['Parent']);
|
|
423
|
+
if (parent) return parseWorktreeValue(parent['Worktree']);
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Parse "branch (path)" → { branch, path } or null.
|
|
430
|
+
*/
|
|
431
|
+
export function parseWorktreeValue(value) {
|
|
432
|
+
if (!value) return null;
|
|
433
|
+
const match = value.match(/^(.+?)\s+\((.+)\)$/);
|
|
434
|
+
if (match) return { branch: match[1].trim(), path: match[2].trim() };
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Format branch + path into worktree column value.
|
|
440
|
+
*/
|
|
441
|
+
export function formatWorktreeValue(branch, worktreePath) {
|
|
442
|
+
return `${branch} (${worktreePath})`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── Internal helpers ───────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Map a SQLite row to a task object with TRACKER.md-compatible keys.
|
|
449
|
+
* This ensures backward compat with tracker-sync.mjs and agent-heartbeat.mjs.
|
|
450
|
+
*/
|
|
451
|
+
function rowToTask(row) {
|
|
452
|
+
return {
|
|
453
|
+
'Task ID': row.task_id,
|
|
454
|
+
'Task Name': row.task_name,
|
|
455
|
+
'Status': row.status,
|
|
456
|
+
'Assigned To': row.assigned_to || 'None',
|
|
457
|
+
'Spec': row.spec,
|
|
458
|
+
'QA Report 1': row.qa_report_1 || 'None',
|
|
459
|
+
'QA Report 2': row.qa_report_2 || 'None',
|
|
460
|
+
'QA Report 3': row.qa_report_3 || 'None',
|
|
461
|
+
'Type': row.type,
|
|
462
|
+
'Parent': row.parent_id || 'None',
|
|
463
|
+
'Blocker': row.blocker || 'None',
|
|
464
|
+
'Worktree': row.worktree || 'None',
|
|
465
|
+
'PR': row.pr || 'None',
|
|
466
|
+
'PR Status': row.pr_status || '',
|
|
467
|
+
'qa_fail_count': row.qa_fail_count,
|
|
468
|
+
'created_at': row.created_at,
|
|
469
|
+
'updated_at': row.updated_at,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export { WORKSPACE_DIR, TASKS_DIR, DB_PATH };
|
package/lib/tx.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tx.mjs — DB + Slack transactional wrapper with rollback.
|
|
3
|
+
*
|
|
4
|
+
* Every stask mutation command uses this to ensure DB and Slack stay in sync.
|
|
5
|
+
* If the Slack sync fails, the DB rolls back and inverse Slack ops execute.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getWorkspaceLibs, CONFIG } from './env.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute a mutation as an atomic DB + Slack transaction.
|
|
12
|
+
*
|
|
13
|
+
* @param {Function} mutationFn - (db, libs) => result
|
|
14
|
+
* Mutate DB (tasks, log, slack_row_ids). Called inside BEGIN/COMMIT.
|
|
15
|
+
*
|
|
16
|
+
* @param {Function} slackSyncFn - (result, db) => slackOps[]
|
|
17
|
+
* Sync changes to Slack. Returns slackOps for rollback.
|
|
18
|
+
* If this throws, DB rolls back + inverse Slack ops fire.
|
|
19
|
+
*
|
|
20
|
+
* @returns {Object} The mutationFn result.
|
|
21
|
+
*/
|
|
22
|
+
export async function withTransaction(mutationFn, slackSyncFn) {
|
|
23
|
+
const libs = await getWorkspaceLibs();
|
|
24
|
+
const db = libs.trackerDb.getDb();
|
|
25
|
+
|
|
26
|
+
let mutationResult;
|
|
27
|
+
let slackOps = [];
|
|
28
|
+
|
|
29
|
+
db.exec('BEGIN IMMEDIATE');
|
|
30
|
+
try {
|
|
31
|
+
// Step 1: Mutate DB
|
|
32
|
+
mutationResult = mutationFn(db, libs);
|
|
33
|
+
|
|
34
|
+
// Step 2: Sync to Slack (while transaction is open)
|
|
35
|
+
if (slackSyncFn) {
|
|
36
|
+
slackOps = await slackSyncFn(mutationResult, db);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Step 3: Commit
|
|
40
|
+
db.exec('COMMIT');
|
|
41
|
+
} catch (err) {
|
|
42
|
+
// Rollback DB
|
|
43
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
44
|
+
|
|
45
|
+
// Best-effort rollback Slack changes
|
|
46
|
+
if (slackOps.length > 0) {
|
|
47
|
+
await rollbackSlack(slackOps, libs);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return mutationResult;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort rollback of Slack operations.
|
|
58
|
+
* Created rows → deleted. Updates → warning (would need snapshot for full undo).
|
|
59
|
+
*/
|
|
60
|
+
async function rollbackSlack(slackOps, libs) {
|
|
61
|
+
const { deleteListRow } = libs.slackApi;
|
|
62
|
+
const listId = CONFIG.slack.listId;
|
|
63
|
+
|
|
64
|
+
for (const op of slackOps.reverse()) {
|
|
65
|
+
try {
|
|
66
|
+
if (op.type === 'create' && op.rowId) {
|
|
67
|
+
await deleteListRow(listId, op.rowId);
|
|
68
|
+
console.error(`ROLLBACK: Deleted Slack row ${op.rowId}`);
|
|
69
|
+
} else if (op.type === 'update') {
|
|
70
|
+
console.error(`ROLLBACK: Cannot undo cell update on row ${op.rowId}. Manual fix may be needed.`);
|
|
71
|
+
}
|
|
72
|
+
} catch (rollbackErr) {
|
|
73
|
+
console.error(`ROLLBACK FAILED for ${op.type} on ${op.rowId}: ${rollbackErr.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run a read-only query. No transaction, no Slack sync.
|
|
80
|
+
*/
|
|
81
|
+
export async function withDb(queryFn) {
|
|
82
|
+
const libs = await getWorkspaceLibs();
|
|
83
|
+
return queryFn(libs.trackerDb.getDb(), libs);
|
|
84
|
+
}
|
package/lib/validate.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate.mjs — Shared validation helpers for the task framework.
|
|
3
|
+
* Most lifecycle rules are now enforced by SQLite triggers in tracker-db.mjs.
|
|
4
|
+
* This module keeps only app-layer helpers that can't live in the DB.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { WORKSPACE_DIR } from './tracker-db.mjs';
|
|
10
|
+
|
|
11
|
+
// ─── Status definitions (reference only — DB enforces via CHECK) ────
|
|
12
|
+
|
|
13
|
+
export const STATUSES = [
|
|
14
|
+
'To-Do',
|
|
15
|
+
'In-Progress',
|
|
16
|
+
'Testing',
|
|
17
|
+
'Ready for Human Review',
|
|
18
|
+
'Blocked',
|
|
19
|
+
'Done',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Auto-assignment rules: when transitioning TO a status, assign to this person.
|
|
24
|
+
* null means keep current assignee.
|
|
25
|
+
*/
|
|
26
|
+
export const AUTO_ASSIGN = {
|
|
27
|
+
'To-Do': 'Yan',
|
|
28
|
+
'In-Progress': null,
|
|
29
|
+
'Testing': 'Jared',
|
|
30
|
+
'Ready for Human Review': 'Yan',
|
|
31
|
+
'Blocked': 'Yan',
|
|
32
|
+
'Done': null,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ─── Spec helpers ───────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check that a spec file exists on disk.
|
|
39
|
+
*/
|
|
40
|
+
export function validateSpecExists(specPath) {
|
|
41
|
+
const fullPath = path.resolve(WORKSPACE_DIR, specPath);
|
|
42
|
+
if (!fs.existsSync(fullPath)) {
|
|
43
|
+
throw new Error(`Spec file not found: ${specPath} (resolved: ${fullPath})`);
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a Spec column value into { filename, fileId }.
|
|
50
|
+
* Format: "specs/name.md (F0XXXXXXXXX)"
|
|
51
|
+
*/
|
|
52
|
+
export function parseSpecValue(specValue) {
|
|
53
|
+
if (!specValue || specValue === 'None' || specValue === 'N/A') return null;
|
|
54
|
+
const match = specValue.match(/^(.+?)\s*\((\w+)\)$/);
|
|
55
|
+
if (match) {
|
|
56
|
+
return { filename: match[1].trim(), fileId: match[2] };
|
|
57
|
+
}
|
|
58
|
+
return { filename: specValue.trim(), fileId: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format a Spec column value.
|
|
63
|
+
*/
|
|
64
|
+
export function formatSpecValue(filename, fileId) {
|
|
65
|
+
return `${filename} (${fileId})`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Git branch helpers ─────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert a task name to a branch-safe slug (kebab-case).
|
|
72
|
+
*/
|
|
73
|
+
export function slugifyTaskName(name) {
|
|
74
|
+
return name
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
77
|
+
.replace(/^-+|-+$/g, '')
|
|
78
|
+
.slice(0, 60);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map a task Type to the git branch prefix per GIT.md conventions.
|
|
83
|
+
*/
|
|
84
|
+
export function branchPrefixForType(type) {
|
|
85
|
+
switch ((type || '').toLowerCase()) {
|
|
86
|
+
case 'feature': return 'feature';
|
|
87
|
+
case 'bug': return 'fix';
|
|
88
|
+
default: return 'chore';
|
|
89
|
+
}
|
|
90
|
+
}
|