@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 ADDED
@@ -0,0 +1,100 @@
1
+ # stask
2
+
3
+ SQLite-backed task lifecycle CLI with Slack sync — designed for AI agent teams.
4
+
5
+ stask enforces a spec-first workflow where tasks flow through defined statuses (To-Do, In-Progress, Testing, Ready for Human Review, Done) with guards that prevent illegal transitions. A human approves specs and merges PRs; AI agents (Lead, Workers, QA) handle everything in between. Every mutation syncs bidirectionally with a Slack List.
6
+
7
+ ## Prerequisites
8
+
9
+ - Node.js 20+
10
+ - [GitHub CLI](https://cli.github.com/) (`gh`)
11
+ - A Slack app with Lists API access (`SLACK_TOKEN`)
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g stask
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ 1. Create the global data directory:
22
+
23
+ ```bash
24
+ mkdir -p ~/.stask
25
+ ```
26
+
27
+ 2. Copy the example config and customize it:
28
+
29
+ ```bash
30
+ cp config.example.json ~/.stask/config.json
31
+ # Edit ~/.stask/config.json with your paths, Slack IDs, and agent names
32
+ ```
33
+
34
+ 3. Create a `.env` file with your Slack credentials:
35
+
36
+ ```bash
37
+ cat > ~/.stask/.env << 'EOF'
38
+ SLACK_TOKEN=xoxb-your-slack-bot-token
39
+ LIST_ID=your-slack-list-id
40
+ EOF
41
+ ```
42
+
43
+ 4. Run any command to initialize the database:
44
+
45
+ ```bash
46
+ stask list
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ ```bash
52
+ # Create a task (uploads spec to Slack)
53
+ stask create --spec specs/my-feature.md --name "Add login page"
54
+
55
+ # Human approves (or via Slack checkbox)
56
+ stask approve T-001
57
+
58
+ # Lead creates subtasks and starts work
59
+ stask subtask create --parent T-001 --name "Build form component" --assign worker-1
60
+ stask transition T-001 In-Progress
61
+
62
+ # Worker marks subtask done (after commit + push)
63
+ stask subtask done T-001.1
64
+
65
+ # QA submits verdict
66
+ stask qa T-001 --report qa-reports/t001.md --verdict PASS
67
+
68
+ # Lead creates PR, transitions to review
69
+ stask transition T-001 "Ready for Human Review"
70
+
71
+ # Human merges PR on GitHub -> task auto-completes
72
+ ```
73
+
74
+ ## Agent Integration
75
+
76
+ The `skills/` folder contains role-specific documentation for AI agents:
77
+
78
+ - **`skills/stask-general.md`** — Full framework overview, lifecycle, guards, CLI reference
79
+ - **`skills/stask-lead.md`** — Lead agent workflow and decision trees
80
+ - **`skills/stask-worker.md`** — Worker agent workflow and worktree rules
81
+ - **`skills/stask-qa.md`** — QA agent testing workflow and report format
82
+
83
+ Add the relevant skill file to your agent's context to teach it the stask workflow.
84
+
85
+ ## Config
86
+
87
+ Config lives at `~/.stask/config.json`. See `config.example.json` for the full schema.
88
+
89
+ | Field | Description |
90
+ |-------|-------------|
91
+ | `specsDir` | Directory where spec markdown files live |
92
+ | `projectRepoPath` | Git repository for worktrees and PRs |
93
+ | `worktreeBaseDir` | Where task worktrees are created |
94
+ | `human` | Human reviewer (name, Slack ID, GitHub username) |
95
+ | `agents` | Agent definitions (name, role, Slack user ID) |
96
+ | `slack` | Slack List column IDs, status option IDs, type option IDs |
97
+
98
+ ## License
99
+
100
+ MIT
package/bin/stask.mjs ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stask — Unified task + Slack CLI for the OpenClaw agent team.
4
+ *
5
+ * Usage: stask <command> [args...]
6
+ *
7
+ * Commands:
8
+ * create Create a new task (uploads spec to Slack)
9
+ * approve Approve a task spec (reassign to Lead)
10
+ * transition Transition task status
11
+ * subtask create Create a subtask under a parent
12
+ * subtask done Mark a subtask as Done
13
+ * qa Submit QA verdict
14
+ * heartbeat Get pending work for an agent
15
+ * list List tasks (filterable)
16
+ * show Show task details
17
+ * log View audit log
18
+ * pr-status Poll PR for comments/merge
19
+ * spec-update Re-upload edited spec
20
+ * session Manage session locks (claim/release/status)
21
+ */
22
+
23
+ import fs from 'fs';
24
+ import path from 'path';
25
+ import { fileURLToPath } from 'url';
26
+ import { loadEnv, CONFIG } from '../lib/env.mjs';
27
+
28
+ // Auto-load env before anything else
29
+ loadEnv();
30
+
31
+ // ─── Auto-start sync daemon (lazy guardian) ───────────────────────
32
+
33
+ function ensureSyncDaemon() {
34
+ const pidFile = path.resolve(CONFIG.staskHome, 'sync-daemon.pid');
35
+ try {
36
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
37
+ process.kill(pid, 0); // Check if alive — throws if dead
38
+ } catch (_) {
39
+ // Not running — start it
40
+ import('../commands/sync-daemon.mjs').then(mod => {
41
+ mod.startDaemon();
42
+ }).catch(() => {});
43
+ }
44
+ }
45
+
46
+ // Don't auto-start for sync-daemon commands (avoid recursion) or read-only queries
47
+ const _cmd = process.argv[2];
48
+ if (_cmd && _cmd !== 'sync-daemon' && _cmd !== '--help' && _cmd !== '-h') {
49
+ ensureSyncDaemon();
50
+ }
51
+
52
+ // ─── Subcommand dispatch ───────────────────────────────────────────
53
+
54
+ const COMMANDS = {
55
+ 'create': () => import('../commands/create.mjs'),
56
+ 'approve': () => import('../commands/approve.mjs'),
57
+ 'transition': () => import('../commands/transition.mjs'),
58
+ 'subtask': null, // nested — handled below
59
+ 'qa': () => import('../commands/qa.mjs'),
60
+ 'heartbeat': () => import('../commands/heartbeat.mjs'),
61
+ 'list': () => import('../commands/list.mjs'),
62
+ 'show': () => import('../commands/show.mjs'),
63
+ 'log': () => import('../commands/log.mjs'),
64
+ 'pr-status': () => import('../commands/pr-status.mjs'),
65
+ 'spec-update': () => import('../commands/spec-update.mjs'),
66
+ 'session': () => import('../commands/session.mjs'),
67
+ 'delete': () => import('../commands/delete.mjs'),
68
+ 'assign': () => import('../commands/assign.mjs'),
69
+ 'sync': () => import('../commands/sync.mjs'),
70
+ 'sync-daemon': () => import('../commands/sync-daemon.mjs'),
71
+ };
72
+
73
+ const SUBTASK_COMMANDS = {
74
+ 'create': () => import('../commands/subtask-create.mjs'),
75
+ 'done': () => import('../commands/subtask-done.mjs'),
76
+ };
77
+
78
+ async function main() {
79
+ const args = process.argv.slice(2);
80
+ const cmd = args[0];
81
+
82
+ if (!cmd || cmd === '--help' || cmd === '-h') {
83
+ printHelp();
84
+ process.exit(0);
85
+ }
86
+
87
+ // Handle nested "subtask" commands
88
+ if (cmd === 'subtask') {
89
+ const subCmd = args[1];
90
+ if (!subCmd || !SUBTASK_COMMANDS[subCmd]) {
91
+ console.error(`Usage: stask subtask <create|done> [args...]`);
92
+ process.exit(1);
93
+ }
94
+ const mod = await SUBTASK_COMMANDS[subCmd]();
95
+ await mod.run(args.slice(2));
96
+ return;
97
+ }
98
+
99
+ // Run tests via `stask test [suite]`
100
+ if (cmd === 'test') {
101
+ const { execFileSync } = await import('child_process');
102
+ const { fileURLToPath } = await import('url');
103
+ const testDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../test');
104
+ const suite = args[1];
105
+ const pattern = suite ? `${testDir}/${suite}.test.mjs` : `${testDir}/*.test.mjs`;
106
+ try {
107
+ execFileSync(process.execPath, ['--test', pattern], { stdio: 'inherit' });
108
+ } catch (err) {
109
+ process.exit(err.status || 1);
110
+ }
111
+ return;
112
+ }
113
+
114
+ const loader = COMMANDS[cmd];
115
+ if (!loader) {
116
+ console.error(`Unknown command: ${cmd}`);
117
+ console.error(`Run "stask --help" for usage.`);
118
+ process.exit(1);
119
+ }
120
+
121
+ const mod = await loader();
122
+ await mod.run(args.slice(1));
123
+ }
124
+
125
+ function printHelp() {
126
+ console.log(`stask — Unified task + Slack CLI
127
+
128
+ Usage: stask <command> [args...]
129
+
130
+ Mutation commands (DB + Slack transaction):
131
+ create --spec <path> --name "..." [--type Feature|Bug|Task]
132
+ approve <task-id>
133
+ transition <task-id> <status>
134
+ subtask create --parent <id> --name "..." --assign <agent>
135
+ subtask done <subtask-id>
136
+ qa <task-id> --report <path> --verdict PASS|FAIL
137
+ assign <task-id> <name>
138
+ spec-update <task-id> --spec <path>
139
+
140
+ Read-only commands:
141
+ list [--status X] [--assignee Y] [--parent Z] [--json]
142
+ show <task-id> [--log]
143
+ log [<task-id>] [--limit N]
144
+ heartbeat <agent-name>
145
+ pr-status <task-id>
146
+ session claim|release|status <task-id> [--agent X] [--session-id Y]
147
+
148
+ Sync commands:
149
+ sync Run one bidirectional sync cycle
150
+ sync-daemon start|stop|status Manage background sync daemon
151
+ `);
152
+ }
153
+
154
+ main().catch(err => {
155
+ console.error(`FATAL: ${err.message}`);
156
+ process.exit(1);
157
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * stask approve — Human approves task spec, reassigns to Lead.
3
+ *
4
+ * Usage: stask approve <task-id>
5
+ */
6
+
7
+ import { CONFIG, getWorkspaceLibs } from '../lib/env.mjs';
8
+ import { withTransaction } from '../lib/tx.mjs';
9
+ import { syncTaskToSlack } from '../lib/slack-row.mjs';
10
+ import { getLeadAgent } from '../lib/roles.mjs';
11
+
12
+ export async function run(argv) {
13
+ const taskId = argv[0];
14
+
15
+ if (!taskId) {
16
+ console.error('Usage: stask approve <task-id>');
17
+ process.exit(1);
18
+ }
19
+
20
+ const leadName = getLeadAgent();
21
+ if (!leadName) { console.error('ERROR: No agent with role "lead" in config.'); process.exit(1); }
22
+
23
+ const result = await withTransaction(
24
+ (db, libs) => {
25
+ const task = libs.trackerDb.findTask(taskId);
26
+ if (!task) throw new Error(`Task ${taskId} not found`);
27
+ if (task['Status'] !== 'To-Do') throw new Error(`${taskId} is "${task['Status']}". Must be "To-Do" to approve.`);
28
+ if (task['Assigned To'] !== CONFIG.human.name) throw new Error(`${taskId} is assigned to ${task['Assigned To']}, not ${CONFIG.human.name}.`);
29
+
30
+ libs.trackerDb.updateTask(taskId, { assigned_to: leadName });
31
+ libs.trackerDb.addLogEntry(taskId, `${taskId} "${task['Task Name']}": Spec approved by ${CONFIG.human.name}. Assigned: ${leadName}.`);
32
+
33
+ const updated = libs.trackerDb.findTask(taskId);
34
+ return { taskId, taskRow: updated, taskName: task['Task Name'] };
35
+ },
36
+ async ({ taskRow }, db) => {
37
+ const { slackOps } = await syncTaskToSlack(db, taskRow);
38
+ return slackOps;
39
+ }
40
+ );
41
+
42
+ console.log(`${taskId}: "${result.taskName}" | Spec approved | Assigned: ${leadName}`);
43
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * assign.mjs — Reassign a task to an agent or human.
3
+ *
4
+ * Usage: stask assign <task-id> <name>
5
+ *
6
+ * Useful for assigning to bot/app users (Richard, Gilfoyle, etc.)
7
+ * which can't be done through Slack's UI user picker.
8
+ * Syncs the change to Slack automatically.
9
+ */
10
+
11
+ import { CONFIG, getWorkspaceLibs } from '../lib/env.mjs';
12
+ import { getSlackUserId } from '../lib/roles.mjs';
13
+ import { syncTaskToSlack, getSlackRowId } from '../lib/slack-row.mjs';
14
+
15
+ export async function run(argv) {
16
+ const taskId = argv[0];
17
+ const name = argv[1];
18
+
19
+ if (!taskId || !name) {
20
+ console.error('Usage: stask assign <task-id> <name>');
21
+ console.error('');
22
+ console.error('Available names:');
23
+ console.error(` ${CONFIG.human.name} (human)`);
24
+ for (const [agent, info] of Object.entries(CONFIG.agents)) {
25
+ console.error(` ${agent.charAt(0).toUpperCase() + agent.slice(1)} (${info.role})`);
26
+ }
27
+ process.exit(1);
28
+ }
29
+
30
+ const libs = await getWorkspaceLibs();
31
+ const db = libs.trackerDb.getDb();
32
+
33
+ const task = libs.trackerDb.findTask(taskId);
34
+ if (!task) {
35
+ console.error(`ERROR: Task ${taskId} not found`);
36
+ process.exit(1);
37
+ }
38
+
39
+ // Validate the name resolves to a known Slack user
40
+ const displayName = name.charAt(0).toUpperCase() + name.slice(1);
41
+ const slackUserId = getSlackUserId(displayName);
42
+ if (!slackUserId) {
43
+ console.error(`ERROR: Unknown user "${name}". Available: ${CONFIG.human.name}, ${Object.keys(CONFIG.agents).map(n => n.charAt(0).toUpperCase() + n.slice(1)).join(', ')}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ const oldAssignee = task['Assigned To'];
48
+ if (oldAssignee === displayName) {
49
+ console.log(`${taskId} is already assigned to ${displayName}`);
50
+ return;
51
+ }
52
+
53
+ // Update DB
54
+ libs.trackerDb.updateTaskDirect(taskId, { assigned_to: displayName });
55
+ libs.trackerDb.addLogEntry(taskId, `Reassigned: ${oldAssignee} → ${displayName}`);
56
+
57
+ // Push to Slack
58
+ const updatedTask = libs.trackerDb.findTask(taskId);
59
+ const parentId = updatedTask['Parent'];
60
+ const parentRowId = (parentId && parentId !== 'None') ? getSlackRowId(db, parentId) : null;
61
+ await syncTaskToSlack(db, updatedTask, parentRowId);
62
+
63
+ console.log(`${taskId}: ${oldAssignee} → ${displayName} (synced to Slack)`);
64
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * stask create — Create a new task (uploads spec to Slack).
3
+ *
4
+ * Usage: stask create --spec <spec-path> --name "Task Name" [--type Feature|Task|Bug]
5
+ */
6
+
7
+ import path from 'path';
8
+ import fs from 'fs';
9
+ import { createHash } from 'crypto';
10
+ import { CONFIG, getWorkspaceLibs } from '../lib/env.mjs';
11
+ import { withTransaction } from '../lib/tx.mjs';
12
+ import { syncTaskToSlack } from '../lib/slack-row.mjs';
13
+
14
+ function parseArgs(argv) {
15
+ const args = {};
16
+ for (let i = 0; i < argv.length; i++) {
17
+ if (argv[i] === '--spec' && argv[i + 1]) args.spec = argv[++i];
18
+ else if (argv[i] === '--name' && argv[i + 1]) args.name = argv[++i];
19
+ else if (argv[i] === '--type' && argv[i + 1]) args.type = argv[++i];
20
+ }
21
+ return args;
22
+ }
23
+
24
+ export async function run(argv) {
25
+ const args = parseArgs(argv);
26
+
27
+ if (!args.spec || !args.name) {
28
+ console.error('Usage: stask create --spec <spec-path> --name "Task Name" [--type Feature|Task|Bug]');
29
+ process.exit(1);
30
+ }
31
+
32
+ const libs = await getWorkspaceLibs();
33
+ const ws = CONFIG.specsDir;
34
+ const relPath = args.spec.startsWith('shared/') ? args.spec : path.relative(ws, path.resolve(args.spec));
35
+ const fullPath = path.resolve(ws, relPath);
36
+
37
+ // Validate spec exists
38
+ libs.validate.validateSpecExists(relPath);
39
+
40
+ // Ensure spec is uploaded to Slack
41
+ const registry = libs.fileUploader.loadRegistry(CONFIG.registryPath);
42
+ let fileId;
43
+ const content = fs.readFileSync(fullPath, 'utf-8');
44
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
45
+ const existing = registry.files[relPath];
46
+
47
+ if (existing && existing.hash === hash && existing.fileId) {
48
+ fileId = existing.fileId;
49
+ } else {
50
+ const filename = path.basename(relPath);
51
+ fileId = await libs.slackApi.uploadFile(filename, content);
52
+ registry.files[relPath] = {
53
+ fileId, hash, title: filename,
54
+ uploadedAt: new Date().toISOString(),
55
+ sizeBytes: Buffer.byteLength(content, 'utf-8'),
56
+ };
57
+ libs.fileUploader.saveRegistry(CONFIG.registryPath, registry);
58
+ console.error(`Uploaded spec to Slack: ${fileId}`);
59
+ }
60
+
61
+ const result = await withTransaction(
62
+ (db, libs) => {
63
+ const taskId = libs.trackerDb.getNextTaskId();
64
+ const specName = relPath.replace(/^shared\//, '');
65
+ const specValue = libs.validate.formatSpecValue(specName, fileId);
66
+
67
+ libs.trackerDb.insertTask({
68
+ task_id: taskId,
69
+ task_name: args.name,
70
+ status: 'To-Do',
71
+ assigned_to: CONFIG.human.name,
72
+ spec: specValue,
73
+ type: args.type || 'Feature',
74
+ });
75
+
76
+ libs.trackerDb.addLogEntry(taskId, `${taskId} "${args.name}" created. Spec: ${specValue}. Status: To-Do → ${CONFIG.human.name}.`);
77
+
78
+ const taskRow = libs.trackerDb.findTask(taskId);
79
+ return { taskId, taskRow, specValue };
80
+ },
81
+ async ({ taskRow }, db) => {
82
+ const { slackOps } = await syncTaskToSlack(db, taskRow);
83
+ return slackOps;
84
+ }
85
+ );
86
+
87
+ console.log(`Created ${result.taskId}: "${args.name}" | Status: To-Do | Assigned: ${CONFIG.human.name} | Spec: ${fileId}`);
88
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * stask delete — Delete a task (and its subtasks) from DB and Slack.
3
+ *
4
+ * Usage: stask delete <task-id> [--force]
5
+ *
6
+ * Deletes the task, its subtasks, all log entries, session claims,
7
+ * and Slack rows — atomically. Refuses to delete In-Progress or Testing
8
+ * tasks unless --force is passed.
9
+ */
10
+
11
+ import { CONFIG, getWorkspaceLibs } from '../lib/env.mjs';
12
+ import { getSlackRowId } from '../lib/slack-row.mjs';
13
+
14
+ export async function run(argv) {
15
+ const taskId = argv[0];
16
+ const force = argv.includes('--force');
17
+
18
+ if (!taskId) {
19
+ console.error('Usage: stask delete <task-id> [--force]');
20
+ process.exit(1);
21
+ }
22
+
23
+ const libs = await getWorkspaceLibs();
24
+ const db = libs.trackerDb.getDb();
25
+ const listId = CONFIG.slack.listId;
26
+
27
+ const task = libs.trackerDb.findTask(taskId);
28
+ if (!task) { console.error(`ERROR: Task ${taskId} not found`); process.exit(1); }
29
+
30
+ // Safety check — don't delete active work without --force
31
+ const activeStatuses = ['In-Progress', 'Testing', 'Ready for Human Review'];
32
+ if (activeStatuses.includes(task['Status']) && !force) {
33
+ console.error(`ERROR: ${taskId} is "${task['Status']}". Use --force to delete active tasks.`);
34
+ process.exit(1);
35
+ }
36
+
37
+ // Collect all task IDs to delete (parent + subtasks)
38
+ const subtasks = libs.trackerDb.getSubtasks(taskId);
39
+ const allIds = [taskId, ...subtasks.map(s => s['Task ID'])];
40
+
41
+ // Phase 1: Delete Slack rows (while we still have row IDs)
42
+ let slackDeleted = 0;
43
+ for (const id of allIds) {
44
+ const rowId = getSlackRowId(db, id);
45
+ if (rowId) {
46
+ try {
47
+ await libs.slackApi.deleteListRow(listId, rowId);
48
+ slackDeleted++;
49
+ } catch (err) {
50
+ console.error(`WARNING: Could not delete Slack row for ${id}: ${err.message}`);
51
+ }
52
+ }
53
+ }
54
+
55
+ // Phase 2: Delete from DB (drop triggers temporarily)
56
+ db.exec('DROP TRIGGER IF EXISTS validate_status_transition');
57
+ db.exec('DROP TRIGGER IF EXISTS log_no_delete');
58
+ db.exec('DROP TRIGGER IF EXISTS log_no_update');
59
+ db.exec('DROP TRIGGER IF EXISTS enforce_in_progress_requirements');
60
+ db.exec('DROP TRIGGER IF EXISTS enforce_ready_for_review_requirements');
61
+ db.exec('DROP TRIGGER IF EXISTS update_timestamp');
62
+
63
+ try {
64
+ db.exec('BEGIN');
65
+ for (const id of allIds) {
66
+ db.prepare('DELETE FROM slack_row_ids WHERE task_id = ?').run(id);
67
+ db.prepare('DELETE FROM active_sessions WHERE task_id = ?').run(id);
68
+ db.prepare('DELETE FROM log WHERE task_id = ?').run(id);
69
+ }
70
+ // Delete subtasks first (FK constraint), then parent
71
+ for (const sub of subtasks) {
72
+ db.prepare('DELETE FROM tasks WHERE task_id = ?').run(sub['Task ID']);
73
+ }
74
+ db.prepare('DELETE FROM tasks WHERE task_id = ?').run(taskId);
75
+ db.exec('COMMIT');
76
+ } catch (err) {
77
+ db.exec('ROLLBACK');
78
+ throw err;
79
+ } finally {
80
+ // Restore all triggers
81
+ db.exec(`
82
+ CREATE TRIGGER IF NOT EXISTS validate_status_transition
83
+ BEFORE UPDATE OF status ON tasks WHEN OLD.status != NEW.status
84
+ BEGIN SELECT CASE
85
+ WHEN OLD.status = 'Done' THEN RAISE(ABORT, 'Cannot transition from Done (terminal state)')
86
+ WHEN OLD.status = 'To-Do' AND NEW.status NOT IN ('In-Progress','Blocked') THEN RAISE(ABORT, 'To-Do can only transition to In-Progress or Blocked')
87
+ WHEN OLD.status = 'In-Progress' AND NEW.status NOT IN ('Testing','Blocked') THEN RAISE(ABORT, 'In-Progress can only transition to Testing or Blocked')
88
+ WHEN OLD.status = 'Testing' AND NEW.status NOT IN ('Ready for Human Review','In-Progress','Blocked') THEN RAISE(ABORT, 'Testing can only transition to Ready for Human Review, In-Progress, or Blocked')
89
+ WHEN OLD.status = 'Ready for Human Review' AND NEW.status NOT IN ('Done','In-Progress','Blocked') THEN RAISE(ABORT, 'Ready for Human Review can only transition to Done, In-Progress, or Blocked')
90
+ WHEN OLD.status = 'Blocked' AND NEW.status NOT IN ('To-Do','In-Progress','Testing','Ready for Human Review') THEN RAISE(ABORT, 'Blocked can transition to To-Do, In-Progress, Testing, or Ready for Human Review')
91
+ END; END;
92
+
93
+ CREATE TRIGGER IF NOT EXISTS log_no_update BEFORE UPDATE ON log
94
+ BEGIN SELECT RAISE(ABORT, 'Log entries are immutable'); END;
95
+
96
+ CREATE TRIGGER IF NOT EXISTS log_no_delete BEFORE DELETE ON log
97
+ BEGIN SELECT RAISE(ABORT, 'Log entries cannot be deleted'); END;
98
+
99
+ CREATE TRIGGER IF NOT EXISTS enforce_in_progress_requirements
100
+ BEFORE UPDATE OF status ON tasks
101
+ WHEN NEW.status = 'In-Progress' AND OLD.status = 'To-Do' AND NEW.parent_id IS NULL
102
+ BEGIN SELECT CASE
103
+ WHEN NEW.worktree IS NULL OR NEW.worktree = '' THEN
104
+ RAISE(ABORT, 'Parent task requires a worktree before moving to In-Progress')
105
+ END; END;
106
+
107
+ CREATE TRIGGER IF NOT EXISTS enforce_ready_for_review_requirements
108
+ BEFORE UPDATE OF status ON tasks
109
+ WHEN NEW.status = 'Ready for Human Review' AND NEW.parent_id IS NULL
110
+ BEGIN SELECT CASE
111
+ WHEN NEW.qa_fail_count = 0 AND (NEW.qa_report_1 IS NULL OR NEW.qa_report_1 = '') THEN RAISE(ABORT, 'QA Report (attempt 1) required before Ready for Human Review')
112
+ WHEN NEW.qa_fail_count = 1 AND (NEW.qa_report_2 IS NULL OR NEW.qa_report_2 = '') THEN RAISE(ABORT, 'QA Report (attempt 2) required before Ready for Human Review')
113
+ WHEN NEW.qa_fail_count = 2 AND (NEW.qa_report_3 IS NULL OR NEW.qa_report_3 = '') THEN RAISE(ABORT, 'QA Report (attempt 3) required before Ready for Human Review')
114
+ WHEN NEW.worktree IS NULL OR NEW.worktree = '' THEN RAISE(ABORT, 'Worktree required before Ready for Human Review')
115
+ WHEN NEW.pr IS NULL OR NEW.pr = '' THEN RAISE(ABORT, 'Draft PR required before Ready for Human Review')
116
+ END; END;
117
+
118
+ CREATE TRIGGER IF NOT EXISTS update_timestamp
119
+ AFTER UPDATE ON tasks
120
+ BEGIN UPDATE tasks SET updated_at = datetime('now') WHERE task_id = NEW.task_id; END;
121
+ `);
122
+ }
123
+
124
+ const subCount = subtasks.length;
125
+ const subMsg = subCount > 0 ? ` + ${subCount} subtask(s)` : '';
126
+ console.log(`Deleted ${taskId}: "${task['Task Name']}"${subMsg} | DB: ${allIds.length} row(s) | Slack: ${slackDeleted} row(s)`);
127
+ }