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