@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,183 @@
1
+ /**
2
+ * stask qa — Submit QA verdict for a task.
3
+ *
4
+ * Usage: stask qa <task-id> --report <path> [--screenshots <dir>] [--verdict PASS|FAIL|PASS_WITH_ISSUES]
5
+ */
6
+
7
+ import path from 'path';
8
+ import fs from 'fs';
9
+ import { createHash } from 'crypto';
10
+ import { execSync } from 'child_process';
11
+ import { CONFIG, getWorkspaceLibs } from '../lib/env.mjs';
12
+ import { withTransaction } from '../lib/tx.mjs';
13
+ import { syncTaskToSlack } from '../lib/slack-row.mjs';
14
+ import { getAutoAssign, getLeadAgent } from '../lib/roles.mjs';
15
+
16
+ const TRIGGER_SQL = `
17
+ CREATE TRIGGER validate_status_transition
18
+ BEFORE UPDATE OF status ON tasks WHEN OLD.status != NEW.status
19
+ BEGIN SELECT CASE
20
+ WHEN OLD.status = 'Done' THEN RAISE(ABORT, 'Cannot transition from Done')
21
+ WHEN OLD.status = 'To-Do' AND NEW.status NOT IN ('In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid from To-Do')
22
+ WHEN OLD.status = 'In-Progress' AND NEW.status NOT IN ('Testing','Blocked') THEN RAISE(ABORT, 'Invalid from In-Progress')
23
+ WHEN OLD.status = 'Testing' AND NEW.status NOT IN ('Ready for Human Review','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid from Testing')
24
+ WHEN OLD.status = 'Ready for Human Review' AND NEW.status NOT IN ('Done','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid from RHR')
25
+ WHEN OLD.status = 'Blocked' AND NEW.status NOT IN ('To-Do','In-Progress','Testing','Ready for Human Review') THEN RAISE(ABORT, 'Invalid from Blocked')
26
+ END; END;
27
+ `;
28
+
29
+ function parseArgs(argv) {
30
+ const args = { taskId: argv[0] };
31
+ for (let i = 1; i < argv.length; i++) {
32
+ if (argv[i] === '--report' && argv[i + 1]) args.report = argv[++i];
33
+ else if (argv[i] === '--screenshots' && argv[i + 1]) args.screenshots = argv[++i];
34
+ else if (argv[i] === '--verdict' && argv[i + 1]) args.verdict = argv[++i];
35
+ }
36
+ return args;
37
+ }
38
+
39
+ export async function run(argv) {
40
+ const args = parseArgs(argv);
41
+
42
+ if (!args.taskId || !args.report) {
43
+ console.error('Usage: stask qa <task-id> --report <path> [--screenshots <dir>] [--verdict PASS|FAIL]');
44
+ process.exit(1);
45
+ }
46
+
47
+ const verdict = (args.verdict || 'PASS').toUpperCase();
48
+ if (!['PASS', 'FAIL', 'PASS_WITH_ISSUES'].includes(verdict)) {
49
+ console.error('ERROR: Verdict must be PASS, FAIL, or PASS_WITH_ISSUES');
50
+ process.exit(1);
51
+ }
52
+
53
+ const libs = await getWorkspaceLibs();
54
+ const ws = CONFIG.specsDir;
55
+
56
+ const task = libs.trackerDb.findTask(args.taskId);
57
+ if (!task) { console.error(`ERROR: Task ${args.taskId} not found`); process.exit(1); }
58
+ if (task['Status'] !== 'Testing') { console.error(`ERROR: ${args.taskId} is "${task['Status']}". Must be "Testing".`); process.exit(1); }
59
+
60
+ // Upload QA report
61
+ const registry = libs.fileUploader.loadRegistry(CONFIG.registryPath);
62
+
63
+ async function uploadAndRegister(filePath) {
64
+ const relPath = filePath.startsWith('shared/') ? filePath : path.relative(ws, path.resolve(filePath));
65
+ const fullPath = path.resolve(ws, relPath);
66
+ if (!fs.existsSync(fullPath)) return null;
67
+ const content = fs.readFileSync(fullPath);
68
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
69
+ const existing = registry.files[relPath];
70
+ if (existing && existing.hash === hash && existing.fileId) return { fileId: existing.fileId, relPath };
71
+ const filename = path.basename(relPath);
72
+ const fileId = await libs.slackApi.uploadFile(filename, content);
73
+ registry.files[relPath] = { fileId, hash, title: filename, uploadedAt: new Date().toISOString(), sizeBytes: content.length };
74
+ return { fileId, relPath };
75
+ }
76
+
77
+ const reportResult = await uploadAndRegister(args.report);
78
+ if (!reportResult) { console.error(`ERROR: QA report not found: ${args.report}`); process.exit(1); }
79
+ console.log(`Uploaded report: ${reportResult.fileId}`);
80
+
81
+ // Bundle screenshots
82
+ const allFileIds = [reportResult.fileId];
83
+ const screenshotsDir = args.screenshots || path.join(path.dirname(path.resolve(ws, reportResult.relPath)), 'screenshots');
84
+ const screenshotsDirFull = path.resolve(ws, screenshotsDir.startsWith('shared/') ? screenshotsDir : path.relative(ws, path.resolve(screenshotsDir)));
85
+
86
+ if (fs.existsSync(screenshotsDirFull)) {
87
+ const imageFiles = fs.readdirSync(screenshotsDirFull).filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f)).sort();
88
+ if (imageFiles.length > 0) {
89
+ const reportFullPath = path.resolve(ws, reportResult.relPath);
90
+ const zipName = `${args.taskId}-qa-bundle.zip`;
91
+ const zipPath = path.join(path.dirname(reportFullPath), zipName);
92
+ try {
93
+ if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
94
+ execSync(`zip -j "${zipPath}" "${reportFullPath}"`, { stdio: 'pipe' });
95
+ const imgPaths = imageFiles.map(f => `"${path.join(screenshotsDirFull, f)}"`).join(' ');
96
+ execSync(`zip -j "${zipPath}" ${imgPaths}`, { stdio: 'pipe' });
97
+ const zipContent = fs.readFileSync(zipPath);
98
+ const zipFileId = await libs.slackApi.uploadFile(zipName, zipContent, 'application/zip');
99
+ allFileIds.push(zipFileId);
100
+ const zipRelPath = path.relative(ws, zipPath);
101
+ const zipHash = createHash('sha256').update(zipContent).digest('hex').slice(0, 16);
102
+ registry.files[zipRelPath] = { fileId: zipFileId, hash: zipHash, title: zipName, uploadedAt: new Date().toISOString(), sizeBytes: zipContent.length };
103
+ } catch (err) { console.error(`WARNING: Failed to create zip: ${err.message}`); }
104
+ }
105
+ }
106
+
107
+ libs.fileUploader.saveRegistry(CONFIG.registryPath, registry);
108
+
109
+ // Determine outcome
110
+ const currentFailCount = task['qa_fail_count'] || 0;
111
+ const reportRef = allFileIds.map(id => `(${id})`).join(' ');
112
+ let newStatus, newAssignee;
113
+ const updates = {};
114
+
115
+ if (verdict !== 'FAIL') {
116
+ updates[`qa_report_${currentFailCount + 1}`] = reportRef;
117
+ // QA PASS → stays in Testing, reassigned to Lead.
118
+ // Lead creates a rich PR with full context, then transitions to RHR.
119
+ newStatus = 'Testing';
120
+ newAssignee = getLeadAgent();
121
+ } else if (currentFailCount + 1 >= CONFIG.maxQaRetries) {
122
+ updates[`qa_report_${currentFailCount + 1}`] = reportRef;
123
+ updates.qa_fail_count = currentFailCount + 1;
124
+ newStatus = 'Blocked';
125
+ newAssignee = CONFIG.human.name;
126
+ console.error(`QA failed ${currentFailCount + 1} times. Escalating to ${CONFIG.human.name}.`);
127
+ } else {
128
+ updates[`qa_report_${currentFailCount + 1}`] = reportRef;
129
+ updates.qa_fail_count = currentFailCount + 1;
130
+ newStatus = 'In-Progress';
131
+ newAssignee = getLeadAgent();
132
+ console.error(`QA failure #${currentFailCount + 1}/${CONFIG.maxQaRetries}. Back to ${newAssignee} for fixes.`);
133
+ }
134
+
135
+ updates.status = newStatus;
136
+ if (newAssignee) updates.assigned_to = newAssignee;
137
+
138
+ await withTransaction(
139
+ (db, libs) => {
140
+ libs.trackerDb.updateTask(args.taskId, updates);
141
+
142
+ // Cascade to subtasks
143
+ const subtasks = libs.trackerDb.getSubtasks(args.taskId);
144
+ const cascaded = [];
145
+ if (subtasks.length > 0) {
146
+ db.exec('DROP TRIGGER IF EXISTS validate_status_transition');
147
+ try {
148
+ for (const sub of subtasks) {
149
+ if (sub['Status'] === 'Done' || sub['Status'] === newStatus) continue;
150
+ const subUpdates = [newStatus];
151
+ let sql = 'UPDATE tasks SET status = ?';
152
+ if (newAssignee && newStatus !== 'In-Progress') { sql += ', assigned_to = ?'; subUpdates.push(newAssignee); }
153
+ sql += ' WHERE task_id = ?';
154
+ subUpdates.push(sub['Task ID']);
155
+ db.prepare(sql).run(...subUpdates);
156
+ cascaded.push(sub['Task ID']);
157
+ }
158
+ } finally {
159
+ db.exec(TRIGGER_SQL);
160
+ }
161
+ }
162
+
163
+ const bundleNote = allFileIds.length > 1 ? ' + bundle zip' : '';
164
+ libs.trackerDb.addLogEntry(args.taskId, `${args.taskId} "${task['Task Name']}": QA ${verdict} by QA. Report: ${reportResult.fileId}${bundleNote}. Testing → ${newStatus}.`);
165
+
166
+ const updatedTask = libs.trackerDb.findTask(args.taskId);
167
+ const cascadedTasks = cascaded.map(id => libs.trackerDb.findTask(id));
168
+ return { taskRow: updatedTask, cascadedTasks };
169
+ },
170
+ async ({ taskRow, cascadedTasks }, db) => {
171
+ const allOps = [];
172
+ const { slackOps } = await syncTaskToSlack(db, taskRow);
173
+ allOps.push(...slackOps);
174
+ for (const sub of cascadedTasks) {
175
+ if (!sub) continue;
176
+ try { const { slackOps: subOps } = await syncTaskToSlack(db, sub); allOps.push(...subOps); } catch {}
177
+ }
178
+ return allOps;
179
+ }
180
+ );
181
+
182
+ console.log(`${args.taskId}: QA ${verdict} | Testing → ${newStatus} | Attachments: ${allFileIds.length}`);
183
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * stask session — Manage session locks.
3
+ *
4
+ * Usage:
5
+ * stask session claim <task-id> --agent <name> --session-id <id>
6
+ * stask session release <task-id> [--session-id <id>]
7
+ * stask session status [<task-id>]
8
+ */
9
+
10
+ import { withDb } from '../lib/tx.mjs';
11
+ import { claimTask, releaseTask, getSessionStatus, cleanStaleSessions } from '../lib/session-tracker.mjs';
12
+
13
+ function parseArgs(argv) {
14
+ const args = { subcommand: argv[0] };
15
+ for (let i = 1; i < argv.length; i++) {
16
+ if (argv[i] === '--agent' && argv[i + 1]) args.agent = argv[++i];
17
+ else if (argv[i] === '--session-id' && argv[i + 1]) args.sessionId = argv[++i];
18
+ else if (!argv[i].startsWith('-') && !args.taskId) args.taskId = argv[i];
19
+ }
20
+ return args;
21
+ }
22
+
23
+ export async function run(argv) {
24
+ const args = parseArgs(argv);
25
+
26
+ if (!args.subcommand || !['claim', 'release', 'status'].includes(args.subcommand)) {
27
+ console.error('Usage: stask session <claim|release|status> [<task-id>] [--agent X] [--session-id Y]');
28
+ process.exit(1);
29
+ }
30
+
31
+ await withDb((db) => {
32
+ if (args.subcommand === 'claim') {
33
+ if (!args.taskId || !args.agent || !args.sessionId) {
34
+ console.error('Usage: stask session claim <task-id> --agent <name> --session-id <id>');
35
+ process.exit(1);
36
+ }
37
+ const result = claimTask(db, args.taskId, args.agent, args.sessionId);
38
+ if (result.ok) {
39
+ console.log(result.message);
40
+ } else {
41
+ console.error(`BLOCKED: ${result.message}`);
42
+ process.exit(1);
43
+ }
44
+ }
45
+
46
+ else if (args.subcommand === 'release') {
47
+ if (!args.taskId) {
48
+ console.error('Usage: stask session release <task-id> [--session-id <id>]');
49
+ process.exit(1);
50
+ }
51
+ const result = releaseTask(db, args.taskId, args.sessionId);
52
+ console.log(result.message);
53
+ }
54
+
55
+ else if (args.subcommand === 'status') {
56
+ // Clean stale sessions first
57
+ const cleaned = cleanStaleSessions(db);
58
+ if (cleaned > 0) console.error(`Cleaned ${cleaned} stale session(s)`);
59
+
60
+ const sessions = getSessionStatus(db, args.taskId);
61
+ if (sessions.length === 0) {
62
+ console.log(args.taskId ? `No active session for ${args.taskId}.` : 'No active sessions.');
63
+ return;
64
+ }
65
+
66
+ console.log('Task ID Agent Session ID Age Stale');
67
+ console.log('─'.repeat(75));
68
+ for (const s of sessions) {
69
+ const staleFlag = s.isStale ? 'YES' : '';
70
+ console.log(
71
+ `${s.task_id.padEnd(12)}${s.agent.padEnd(12)}${s.session_id.padEnd(33)}${String(s.ageMinutes + 'm').padEnd(6)}${staleFlag}`
72
+ );
73
+ }
74
+ }
75
+ });
76
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * stask show — Show task details.
3
+ *
4
+ * Usage: stask show <task-id> [--log]
5
+ */
6
+
7
+ import { withDb } from '../lib/tx.mjs';
8
+
9
+ export async function run(argv) {
10
+ const taskId = argv[0];
11
+ const showLog = argv.includes('--log');
12
+
13
+ if (!taskId) {
14
+ console.error('Usage: stask show <task-id> [--log]');
15
+ process.exit(1);
16
+ }
17
+
18
+ await withDb((db, libs) => {
19
+ const task = libs.trackerDb.findTask(taskId);
20
+ if (!task) { console.error(`ERROR: Task ${taskId} not found`); process.exit(1); }
21
+
22
+ console.log(`Task ${task['Task ID']}: ${task['Task Name']}`);
23
+ console.log(`${'─'.repeat(60)}`);
24
+ console.log(` Status: ${task['Status']}`);
25
+ console.log(` Assigned To: ${task['Assigned To']}`);
26
+ console.log(` Type: ${task['Type']}`);
27
+ console.log(` Parent: ${task['Parent']}`);
28
+ console.log(` Spec: ${task['Spec']}`);
29
+ console.log(` Worktree: ${task['Worktree']}`);
30
+ console.log(` PR: ${task['PR']}`);
31
+
32
+ if (task['QA Report 1'] !== 'None') console.log(` QA Report 1: ${task['QA Report 1']}`);
33
+ if (task['QA Report 2'] !== 'None') console.log(` QA Report 2: ${task['QA Report 2']}`);
34
+ if (task['QA Report 3'] !== 'None') console.log(` QA Report 3: ${task['QA Report 3']}`);
35
+ if (task['Blocker'] !== 'None') console.log(` Blocker: ${task['Blocker']}`);
36
+
37
+ console.log(` Created: ${task['created_at']}`);
38
+ console.log(` Updated: ${task['updated_at']}`);
39
+
40
+ // Subtasks
41
+ const subtasks = libs.trackerDb.getSubtasks(taskId);
42
+ if (subtasks.length > 0) {
43
+ console.log(`\nSubtasks (${subtasks.length}):`);
44
+ for (const sub of subtasks) {
45
+ const marker = sub['Status'] === 'Done' ? '[x]' : '[ ]';
46
+ console.log(` ${marker} ${sub['Task ID']}: ${sub['Task Name']} (${sub['Status']}, ${sub['Assigned To']})`);
47
+ }
48
+ }
49
+
50
+ // Log
51
+ if (showLog) {
52
+ const entries = libs.trackerDb.getLogForTask(taskId);
53
+ if (entries.length > 0) {
54
+ console.log(`\nLog (${entries.length} entries):`);
55
+ for (const e of entries.reverse()) {
56
+ console.log(` [${e.created_at}] ${e.message}`);
57
+ }
58
+ }
59
+ }
60
+ });
61
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * stask spec-update — Re-upload edited spec and update DB.
3
+ *
4
+ * Usage: stask spec-update <task-id> --spec <path>
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
+ export async function run(argv) {
15
+ const taskId = argv[0];
16
+ let specPath;
17
+ for (let i = 1; i < argv.length; i++) {
18
+ if (argv[i] === '--spec' && argv[i + 1]) specPath = argv[++i];
19
+ }
20
+
21
+ if (!taskId || !specPath) {
22
+ console.error('Usage: stask spec-update <task-id> --spec <path>');
23
+ process.exit(1);
24
+ }
25
+
26
+ const libs = await getWorkspaceLibs();
27
+ const ws = CONFIG.specsDir;
28
+
29
+ const task = libs.trackerDb.findTask(taskId);
30
+ if (!task) { console.error(`ERROR: Task ${taskId} not found`); process.exit(1); }
31
+
32
+ const relPath = specPath.startsWith('shared/') ? specPath : path.relative(ws, path.resolve(specPath));
33
+ const fullPath = path.resolve(ws, relPath);
34
+ if (!fs.existsSync(fullPath)) { console.error(`ERROR: Spec not found: ${relPath}`); process.exit(1); }
35
+
36
+ const content = fs.readFileSync(fullPath, 'utf-8');
37
+ const registry = libs.fileUploader.loadRegistry(CONFIG.registryPath);
38
+ const filename = path.basename(relPath);
39
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
40
+ const fileId = await libs.slackApi.uploadFile(filename, content);
41
+ registry.files[relPath] = { fileId, hash, title: filename, uploadedAt: new Date().toISOString(), sizeBytes: Buffer.byteLength(content, 'utf-8') };
42
+ libs.fileUploader.saveRegistry(CONFIG.registryPath, registry);
43
+
44
+ const specName = relPath.replace(/^shared\//, '');
45
+ const specValue = libs.validate.formatSpecValue(specName, fileId);
46
+
47
+ await withTransaction(
48
+ (db, libs) => {
49
+ libs.trackerDb.updateTask(taskId, { spec: specValue });
50
+ libs.trackerDb.addLogEntry(taskId, `${taskId} spec updated: ${specValue}`);
51
+ const updated = libs.trackerDb.findTask(taskId);
52
+ return { taskRow: updated };
53
+ },
54
+ async ({ taskRow }, db) => {
55
+ const { slackOps } = await syncTaskToSlack(db, taskRow);
56
+ return slackOps;
57
+ }
58
+ );
59
+
60
+ console.log(`${taskId}: Spec updated → ${fileId}`);
61
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * stask subtask create — Create a subtask under a parent task.
3
+ *
4
+ * Usage: stask subtask create --parent <task-id> --name "..." --assign <agent> [--type Task|Bug]
5
+ */
6
+
7
+ import { getWorkspaceLibs } from '../lib/env.mjs';
8
+ import { withTransaction } from '../lib/tx.mjs';
9
+ import { syncSubtaskToSlack } from '../lib/slack-row.mjs';
10
+
11
+ function parseArgs(argv) {
12
+ const args = {};
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i] === '--parent' && argv[i + 1]) args.parent = argv[++i];
15
+ else if (argv[i] === '--name' && argv[i + 1]) args.name = argv[++i];
16
+ else if (argv[i] === '--assign' && argv[i + 1]) args.assign = argv[++i];
17
+ else if (argv[i] === '--type' && argv[i + 1]) args.type = argv[++i];
18
+ }
19
+ return args;
20
+ }
21
+
22
+ export async function run(argv) {
23
+ const args = parseArgs(argv);
24
+
25
+ if (!args.parent || !args.name || !args.assign) {
26
+ console.error('Usage: stask subtask create --parent <task-id> --name "Name" --assign <agent> [--type Task|Bug]');
27
+ process.exit(1);
28
+ }
29
+
30
+ const result = await withTransaction(
31
+ (db, libs) => {
32
+ const parent = libs.trackerDb.findTask(args.parent);
33
+ if (!parent) throw new Error(`Parent task ${args.parent} not found`);
34
+ if (parent['Status'] !== 'To-Do') throw new Error(`Parent ${args.parent} is "${parent['Status']}". Must be "To-Do" to create subtasks.`);
35
+
36
+ const subtaskId = libs.trackerDb.getNextSubtaskId(args.parent);
37
+ const parentSpec = parent['Spec'];
38
+
39
+ libs.trackerDb.insertTask({
40
+ task_id: subtaskId,
41
+ task_name: args.name,
42
+ status: 'To-Do',
43
+ assigned_to: args.assign,
44
+ spec: parentSpec,
45
+ type: args.type || 'Task',
46
+ parent_id: args.parent,
47
+ });
48
+
49
+ libs.trackerDb.addLogEntry(subtaskId, `${subtaskId} "${args.name}" created under ${args.parent}. Assigned: ${args.assign}.`);
50
+
51
+ const taskRow = libs.trackerDb.findTask(subtaskId);
52
+ return { subtaskId, taskRow, parentSpec };
53
+ },
54
+ async ({ taskRow }, db) => {
55
+ const { slackOps } = await syncSubtaskToSlack(db, taskRow);
56
+ return slackOps;
57
+ }
58
+ );
59
+
60
+ const specFileId = result.parentSpec.match(/\((\w+)\)$/)?.[1] || result.parentSpec;
61
+ console.log(`Created ${result.subtaskId}: "${args.name}" | Parent: ${args.parent} | Assigned: ${args.assign} | Spec: ${specFileId}`);
62
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * stask subtask done — Builder marks their subtask as Done.
3
+ *
4
+ * Usage: stask subtask done <subtask-id>
5
+ *
6
+ * If all siblings are Done → auto-transitions parent to Testing.
7
+ */
8
+
9
+ import { getWorkspaceLibs } from '../lib/env.mjs';
10
+ import { withTransaction } from '../lib/tx.mjs';
11
+ import { syncTaskToSlack } from '../lib/slack-row.mjs';
12
+
13
+ const TRIGGER_SQL = `
14
+ CREATE TRIGGER validate_status_transition
15
+ BEFORE UPDATE OF status ON tasks WHEN OLD.status != NEW.status
16
+ BEGIN SELECT CASE
17
+ WHEN OLD.status = 'Done' THEN RAISE(ABORT, 'Cannot transition from Done')
18
+ WHEN OLD.status = 'To-Do' AND NEW.status NOT IN ('In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from To-Do')
19
+ WHEN OLD.status = 'In-Progress' AND NEW.status NOT IN ('Testing','Blocked') THEN RAISE(ABORT, 'Invalid transition from In-Progress')
20
+ WHEN OLD.status = 'Testing' AND NEW.status NOT IN ('Ready for Human Review','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from Testing')
21
+ WHEN OLD.status = 'Ready for Human Review' AND NEW.status NOT IN ('Done','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from Ready for Human Review')
22
+ WHEN OLD.status = 'Blocked' AND NEW.status NOT IN ('To-Do','In-Progress','Testing','Ready for Human Review') THEN RAISE(ABORT, 'Invalid transition from Blocked')
23
+ END; END;
24
+ `;
25
+
26
+ export async function run(argv) {
27
+ const subtaskId = argv[0];
28
+
29
+ if (!subtaskId) {
30
+ console.error('Usage: stask subtask done <subtask-id>');
31
+ process.exit(1);
32
+ }
33
+
34
+ const libs = await getWorkspaceLibs();
35
+ const subtask = libs.trackerDb.findTask(subtaskId);
36
+
37
+ if (!subtask) { console.error(`ERROR: Task ${subtaskId} not found`); process.exit(1); }
38
+ if (subtask['Parent'] === 'None') { console.error(`ERROR: ${subtaskId} is not a subtask. Use "stask transition" for top-level tasks.`); process.exit(1); }
39
+ if (subtask['Status'] !== 'In-Progress') { console.error(`ERROR: ${subtaskId} is "${subtask['Status']}". Must be "In-Progress".`); process.exit(1); }
40
+
41
+ const parentId = subtask['Parent'];
42
+
43
+ await withTransaction(
44
+ (db, libs) => {
45
+ db.exec('DROP TRIGGER IF EXISTS validate_status_transition');
46
+ try {
47
+ db.prepare('UPDATE tasks SET status = ? WHERE task_id = ?').run('Done', subtaskId);
48
+ } finally {
49
+ db.exec(TRIGGER_SQL);
50
+ }
51
+
52
+ libs.trackerDb.addLogEntry(subtaskId, `${subtaskId} "${subtask['Task Name']}": In-Progress → Done. Marked complete by builder.`);
53
+ const updated = libs.trackerDb.findTask(subtaskId);
54
+ return { subtaskId, taskRow: updated };
55
+ },
56
+ async ({ taskRow }, db) => {
57
+ const { slackOps } = await syncTaskToSlack(db, taskRow);
58
+ return slackOps;
59
+ }
60
+ );
61
+
62
+ console.log(`${subtaskId}: "${subtask['Task Name']}" | In-Progress → Done`);
63
+
64
+ // Check siblings — auto-transition parent if all Done
65
+ const siblings = libs.trackerDb.getSubtasks(parentId);
66
+ const parent = libs.trackerDb.findTask(parentId);
67
+
68
+ if (!parent) { console.error(`WARNING: Parent ${parentId} not found.`); return; }
69
+
70
+ const allDone = siblings.length > 0 && siblings.every(s => s['Status'] === 'Done');
71
+
72
+ if (allDone) {
73
+ console.log(`All subtasks of ${parentId} are Done. Auto-transitioning parent to Testing...`);
74
+ const { run: runTransition } = await import('./transition.mjs');
75
+ await runTransition([parentId, 'Testing']);
76
+ } else {
77
+ const remaining = siblings.filter(s => s['Status'] !== 'Done');
78
+ console.log(`${remaining.length} subtask(s) still pending: ${remaining.map(s => `${s['Task ID']} (${s['Status']})`).join(', ')}`);
79
+ }
80
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * sync-daemon.mjs — Manage the background sync daemon.
3
+ *
4
+ * Usage:
5
+ * stask sync-daemon start — Start the daemon (fork detached)
6
+ * stask sync-daemon stop — Stop the daemon (SIGTERM via PID file)
7
+ * stask sync-daemon status — Check if daemon is running
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { spawn } from 'child_process';
13
+ import { CONFIG } from '../lib/env.mjs';
14
+
15
+ const STASK_HOME = CONFIG.staskHome;
16
+ const PID_FILE = path.resolve(STASK_HOME, 'sync-daemon.pid');
17
+ const DAEMON_SCRIPT = path.resolve(CONFIG.staskRoot, 'lib', 'sync-daemon.mjs');
18
+ const LOG_FILE = path.resolve(STASK_HOME, 'logs', 'sync-daemon.log');
19
+
20
+ export async function run(argv) {
21
+ const subCmd = argv[0];
22
+ switch (subCmd) {
23
+ case 'start': return start();
24
+ case 'stop': return stop();
25
+ case 'status': return status();
26
+ default:
27
+ console.error('Usage: stask sync-daemon <start|stop|status>');
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ // ─── Helpers ──────────────────────────────────────────────────────
33
+
34
+ function readPid() {
35
+ try {
36
+ return parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim(), 10);
37
+ } catch (_) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function isAlive(pid) {
43
+ if (!pid) return false;
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch (_) {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Start the daemon as a detached child process.
54
+ */
55
+ export function startDaemon() {
56
+ const pid = readPid();
57
+ if (isAlive(pid)) return pid; // Already running
58
+
59
+ // Clean up stale PID file
60
+ if (pid) {
61
+ try { fs.unlinkSync(PID_FILE); } catch (_) {}
62
+ }
63
+
64
+ // Ensure log dir
65
+ const logDir = path.dirname(LOG_FILE);
66
+ if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
67
+
68
+ // Open log file for stdout/stderr
69
+ const logFd = fs.openSync(LOG_FILE, 'a');
70
+
71
+ const child = spawn(process.execPath, [DAEMON_SCRIPT], {
72
+ detached: true,
73
+ stdio: ['ignore', logFd, logFd],
74
+ env: { ...process.env },
75
+ });
76
+ child.unref();
77
+
78
+ return child.pid;
79
+ }
80
+
81
+ // ─── Subcommands ──────────────────────────────────────────────────
82
+
83
+ function start() {
84
+ const pid = readPid();
85
+ if (isAlive(pid)) {
86
+ console.log(`Sync daemon already running (PID ${pid})`);
87
+ return;
88
+ }
89
+
90
+ const newPid = startDaemon();
91
+ console.log(`Sync daemon started (PID ${newPid})`);
92
+ }
93
+
94
+ function stop() {
95
+ const pid = readPid();
96
+ if (!pid || !isAlive(pid)) {
97
+ console.log('Sync daemon is not running');
98
+ try { fs.unlinkSync(PID_FILE); } catch (_) {}
99
+ return;
100
+ }
101
+
102
+ process.kill(pid, 'SIGTERM');
103
+ console.log(`Sent SIGTERM to sync daemon (PID ${pid})`);
104
+
105
+ // Wait briefly for cleanup
106
+ let tries = 10;
107
+ const check = () => {
108
+ if (!isAlive(pid) || --tries <= 0) {
109
+ if (isAlive(pid)) {
110
+ console.error('Daemon did not stop in time');
111
+ } else {
112
+ console.log('Sync daemon stopped');
113
+ }
114
+ return;
115
+ }
116
+ setTimeout(check, 200);
117
+ };
118
+ check();
119
+ }
120
+
121
+ function status() {
122
+ const pid = readPid();
123
+ if (pid && isAlive(pid)) {
124
+ const interval = CONFIG.syncIntervalSeconds || 60;
125
+ console.log(`Sync daemon is running (PID ${pid}, interval ${interval}s)`);
126
+ console.log(`Log: ${LOG_FILE}`);
127
+ } else {
128
+ console.log('Sync daemon is not running');
129
+ if (pid) {
130
+ // Stale PID file
131
+ try { fs.unlinkSync(PID_FILE); } catch (_) {}
132
+ }
133
+ }
134
+ }