@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.
@@ -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
+ }
@@ -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
+ }