feinai 0.5.0
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/CHANGELOG.md +41 -0
- package/README.md +230 -0
- package/package.json +60 -0
- package/skills/feinai-dispatch/SKILL.md +233 -0
- package/skills/feinai-implement/SKILL.md +133 -0
- package/skills/feinai-sdd/SKILL.md +291 -0
- package/skills/feinai-write-spec/SKILL.md +178 -0
- package/skills/feinai-write-tasks/SKILL.md +183 -0
- package/src/agents-status.ts +26 -0
- package/src/cli.ts +885 -0
- package/src/dashboard.html +1701 -0
- package/src/dashboard.ts +3 -0
- package/src/db.ts +221 -0
- package/src/format.ts +166 -0
- package/src/opengit.sh +117 -0
- package/src/server.ts +749 -0
- package/src/specs.ts +289 -0
- package/src/sqlite-adapter.ts +130 -0
- package/src/tasks.ts +415 -0
- package/src/worktree-status.ts +97 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readFileSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { userInfo } from "node:os";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { resolve, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { initDb, openDb, findDbPath } from "./db";
|
|
9
|
+
import {
|
|
10
|
+
listTasks,
|
|
11
|
+
getTask,
|
|
12
|
+
addTask,
|
|
13
|
+
takeTask,
|
|
14
|
+
doneTask,
|
|
15
|
+
failTask,
|
|
16
|
+
blockTask,
|
|
17
|
+
unblockTask,
|
|
18
|
+
releaseTask,
|
|
19
|
+
reopenTask,
|
|
20
|
+
editTask,
|
|
21
|
+
type EditTaskInput,
|
|
22
|
+
type TaskStatus,
|
|
23
|
+
} from "./tasks";
|
|
24
|
+
import {
|
|
25
|
+
listSpecs,
|
|
26
|
+
getSpec,
|
|
27
|
+
addSpec,
|
|
28
|
+
startSpec,
|
|
29
|
+
doneSpec,
|
|
30
|
+
setSpecContent,
|
|
31
|
+
addPlan,
|
|
32
|
+
getLatestPlan,
|
|
33
|
+
listPlans,
|
|
34
|
+
editSpec,
|
|
35
|
+
archiveSpec,
|
|
36
|
+
unarchiveSpec,
|
|
37
|
+
deleteSpec,
|
|
38
|
+
type SpecStatus,
|
|
39
|
+
} from "./specs";
|
|
40
|
+
import {
|
|
41
|
+
formatTask,
|
|
42
|
+
formatTaskList,
|
|
43
|
+
formatSpec,
|
|
44
|
+
formatSpecList,
|
|
45
|
+
formatPlan,
|
|
46
|
+
formatPlanList,
|
|
47
|
+
formatStatus,
|
|
48
|
+
type OutputFormat,
|
|
49
|
+
} from "./format";
|
|
50
|
+
|
|
51
|
+
const VERSION = "0.5.0";
|
|
52
|
+
|
|
53
|
+
interface ParsedArgs {
|
|
54
|
+
positional: string[];
|
|
55
|
+
flags: Record<string, boolean>;
|
|
56
|
+
options: Record<string, string>;
|
|
57
|
+
multi: Record<string, string[]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FLAG_NAMES = new Set([
|
|
61
|
+
"json",
|
|
62
|
+
"plain",
|
|
63
|
+
"pending",
|
|
64
|
+
"help",
|
|
65
|
+
"version",
|
|
66
|
+
"full",
|
|
67
|
+
"force",
|
|
68
|
+
"stdin",
|
|
69
|
+
"down",
|
|
70
|
+
"daemon",
|
|
71
|
+
"yes",
|
|
72
|
+
"clear-blocked-by",
|
|
73
|
+
]);
|
|
74
|
+
const MULTI_OPTIONS = new Set(["package", "gate", "blocked-by"]);
|
|
75
|
+
|
|
76
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
77
|
+
const result: ParsedArgs = {
|
|
78
|
+
positional: [],
|
|
79
|
+
flags: {},
|
|
80
|
+
options: {},
|
|
81
|
+
multi: {},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < argv.length; i++) {
|
|
85
|
+
const arg = argv[i];
|
|
86
|
+
if (typeof arg !== "string") continue;
|
|
87
|
+
|
|
88
|
+
if (arg === "-d") {
|
|
89
|
+
result.flags["daemon"] = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (arg.startsWith("--")) {
|
|
94
|
+
const key = arg.slice(2);
|
|
95
|
+
if (FLAG_NAMES.has(key)) {
|
|
96
|
+
result.flags[key] = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const next = argv[i + 1];
|
|
101
|
+
if (typeof next === "string" && !next.startsWith("--")) {
|
|
102
|
+
if (MULTI_OPTIONS.has(key)) {
|
|
103
|
+
(result.multi[key] ??= []).push(next);
|
|
104
|
+
} else {
|
|
105
|
+
result.options[key] = next;
|
|
106
|
+
}
|
|
107
|
+
i++;
|
|
108
|
+
} else {
|
|
109
|
+
result.flags[key] = true;
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
result.positional.push(arg);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function detectFormat(args: ParsedArgs): OutputFormat {
|
|
120
|
+
if (args.flags.json) return "json";
|
|
121
|
+
if (args.flags.plain) return "plain";
|
|
122
|
+
return process.stdout.isTTY ? "color" : "plain";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a stable identifier for whoever is invoking feina.
|
|
127
|
+
*
|
|
128
|
+
* Priority:
|
|
129
|
+
* 1. $FEINA_USER env var (explicit override — used by agents to identify themselves)
|
|
130
|
+
* 2. parent process name (on Linux via /proc/$PPID/comm) → "{parent_name}:{ppid}:{user}"
|
|
131
|
+
* 3. fallback: "{user}@{hostname}"
|
|
132
|
+
*
|
|
133
|
+
* The parent process name lets us distinguish "claude:12345:m" from "opencode:23456:m"
|
|
134
|
+
* from "bash:9999:m" in the events audit log without manual configuration.
|
|
135
|
+
*/
|
|
136
|
+
function getCurrentUser(): string {
|
|
137
|
+
const override = process.env.FEINA_USER;
|
|
138
|
+
if (override) return override;
|
|
139
|
+
|
|
140
|
+
const username = process.env.USER ?? userInfo().username ?? "unknown";
|
|
141
|
+
const ppid = process.ppid;
|
|
142
|
+
|
|
143
|
+
let parentName: string | null = null;
|
|
144
|
+
try {
|
|
145
|
+
if (process.platform === "linux") {
|
|
146
|
+
parentName = readFileSync(`/proc/${ppid}/comm`, "utf-8").trim();
|
|
147
|
+
} else if (process.platform === "darwin") {
|
|
148
|
+
// On macOS /proc doesn't exist; use ps if available, otherwise skip.
|
|
149
|
+
const proc = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(ppid)]);
|
|
150
|
+
if (proc.exitCode === 0) {
|
|
151
|
+
const out = proc.stdout.toString().trim();
|
|
152
|
+
parentName = out.split("/").pop() ?? null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
parentName = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (parentName) return `${parentName}:${ppid}:${username}`;
|
|
160
|
+
return `${username}@${process.env.HOSTNAME ?? "local"}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ensureDb(): ReturnType<typeof openDb> {
|
|
164
|
+
if (!findDbPath()) {
|
|
165
|
+
console.error(
|
|
166
|
+
"Error: no .tasca/tasca.db found. Run 'feina init' to create one.",
|
|
167
|
+
);
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
return openDb();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readStdin(): string {
|
|
174
|
+
return readFileSync(0, "utf-8");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readContentFromArgs(args: ParsedArgs): string | undefined {
|
|
178
|
+
if (args.flags.stdin) return readStdin();
|
|
179
|
+
if (args.options.file) return readFileSync(args.options.file, "utf-8");
|
|
180
|
+
if (args.options.content) return args.options.content;
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function showHelp(): void {
|
|
185
|
+
console.log(`feina v\${VERSION} — task & spec manager for AI agents and humans
|
|
186
|
+
|
|
187
|
+
USAGE:
|
|
188
|
+
feina <command> [options]
|
|
189
|
+
|
|
190
|
+
DB MANAGEMENT:
|
|
191
|
+
init [--force] Create .tasca/tasca.db in cwd
|
|
192
|
+
destroy [--yes] Delete .tasca/ entirely (prompts unless --yes)
|
|
193
|
+
(--force allows nesting under an existing DB)
|
|
194
|
+
status Summary of pending/in_progress/completed counts
|
|
195
|
+
|
|
196
|
+
TASKS:
|
|
197
|
+
list [filters] List tasks
|
|
198
|
+
--status <status> Filter by status
|
|
199
|
+
--pending Shortcut for --status pending
|
|
200
|
+
--spec <SPEC-ID> Filter by spec
|
|
201
|
+
--owner <name> Filter by owner
|
|
202
|
+
|
|
203
|
+
add <TASK-ID> <subject> Create new task
|
|
204
|
+
--spec <SPEC-ID> Link to spec
|
|
205
|
+
--desc <text> Description / workplan
|
|
206
|
+
--package <name> Add package (repeatable)
|
|
207
|
+
--gate <cmd> Add quality gate (repeatable)
|
|
208
|
+
--blocked-by <TASK-ID> Add dependency (repeatable)
|
|
209
|
+
|
|
210
|
+
show <TASK-ID> Show full task detail
|
|
211
|
+
take <TASK-ID> [--owner <name>] Atomically claim a pending task
|
|
212
|
+
(returns full task payload — single call)
|
|
213
|
+
done <TASK-ID> --result <text> Mark task completed
|
|
214
|
+
fail <TASK-ID> --error <text> Mark task failed
|
|
215
|
+
block <TASK-ID> --by <TASK-ID> Add a dependency
|
|
216
|
+
unblock <TASK-ID> --dep <TASK-ID> Remove a specific dependency
|
|
217
|
+
release <TASK-ID> Release back to pending (in_progress → pending)
|
|
218
|
+
reopen <TASK-ID> Reopen to pending (completed/failed → pending)
|
|
219
|
+
edit <TASK-ID> Edit task metadata (any status)
|
|
220
|
+
--subject <text> Replace subject
|
|
221
|
+
--desc <text> Replace description (also --stdin, --file)
|
|
222
|
+
--package <name> Replace packages array (repeatable)
|
|
223
|
+
--gate <cmd> Replace quality_gates array (repeatable)
|
|
224
|
+
--clear-blocked-by Clear all dependencies
|
|
225
|
+
|
|
226
|
+
SPECS:
|
|
227
|
+
spec list [--status <status>]
|
|
228
|
+
spec show <SPEC-ID> [--full]
|
|
229
|
+
spec add <SPEC-ID> <title> Create spec
|
|
230
|
+
--content <text> Inline markdown content
|
|
231
|
+
--file <path> Read content from file
|
|
232
|
+
--stdin Read content from stdin
|
|
233
|
+
spec content <SPEC-ID> Print spec markdown content to stdout
|
|
234
|
+
spec set-content <SPEC-ID> Replace spec content (same content flags as add)
|
|
235
|
+
spec start <SPEC-ID> Mark spec as in progress
|
|
236
|
+
spec done <SPEC-ID> Mark spec as completed
|
|
237
|
+
--pr <number>
|
|
238
|
+
--merged <YYYY-MM-DD>
|
|
239
|
+
spec edit <SPEC-ID> Edit spec metadata
|
|
240
|
+
--title <text> Replace title
|
|
241
|
+
|
|
242
|
+
PLANS:
|
|
243
|
+
plan add <SPEC-ID> Create new plan revision for a spec
|
|
244
|
+
--content <text> | --file <path> | --stdin
|
|
245
|
+
plan show <SPEC-ID> Print latest plan markdown to stdout
|
|
246
|
+
plan list <SPEC-ID> List all plan versions for a spec
|
|
247
|
+
|
|
248
|
+
SERVER:
|
|
249
|
+
server Start HTTP dashboard + REST API
|
|
250
|
+
--port <N> Port (default: 8272 — TASC on phone keypad)
|
|
251
|
+
--host <addr> Bind host (default: 127.0.0.1)
|
|
252
|
+
server --daemon / -d Start detached (no job control noise)
|
|
253
|
+
server --down Stop the running feina server (by port)
|
|
254
|
+
|
|
255
|
+
GLOBAL FLAGS:
|
|
256
|
+
--json Output as JSON
|
|
257
|
+
--plain Output without colors
|
|
258
|
+
--help Show this help
|
|
259
|
+
--version Show version
|
|
260
|
+
|
|
261
|
+
ENV:
|
|
262
|
+
FEINA_USER Override owner/actor identity used in audit log
|
|
263
|
+
|
|
264
|
+
\x1b[34m\x1b[1m── Agent Integration ──────────────────────────────────────────────\x1b[0m
|
|
265
|
+
\x1b[36mTeach your AI agents to use \x1b[1mfeina\x1b[0m\x1b[36m as their single source of truth for\x1b[0m
|
|
266
|
+
\x1b[36mspecs, tasks, and plans.\x1b[0m
|
|
267
|
+
\x1b[36mOr load \x1b[1mfeina skills\x1b[0m\x1b[36m from the Claude Code skills marketplace:\x1b[0m
|
|
268
|
+
\x1b[34m \x1b[1;94mtasca-sdd\x1b[0m \x1b[1;94mtasca-write-spec\x1b[0m \x1b[1;94mtasca-write-tasks\x1b[0m \x1b[1;94mtasca-dispatch\x1b[0m
|
|
269
|
+
\x1b[34m\x1b[1m────────────────────────────────────────────────────────────────────\x1b[0m
|
|
270
|
+
`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function main(): Promise<void> {
|
|
274
|
+
const args = parseArgs(process.argv.slice(2));
|
|
275
|
+
|
|
276
|
+
if (args.flags.version) {
|
|
277
|
+
console.log(VERSION);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (args.flags.help || args.positional.length === 0) {
|
|
281
|
+
showHelp();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const [command, ...rest] = args.positional;
|
|
286
|
+
const format = detectFormat(args);
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
switch (command) {
|
|
290
|
+
case "init":
|
|
291
|
+
return cmdInit(args);
|
|
292
|
+
case "status":
|
|
293
|
+
return cmdStatus(format);
|
|
294
|
+
case "list":
|
|
295
|
+
return cmdList(args, format);
|
|
296
|
+
case "add":
|
|
297
|
+
return cmdAdd(rest, args, format);
|
|
298
|
+
case "show":
|
|
299
|
+
return cmdShow(rest, format);
|
|
300
|
+
case "take":
|
|
301
|
+
return cmdTake(rest, args, format);
|
|
302
|
+
case "done":
|
|
303
|
+
return cmdDone(rest, args, format);
|
|
304
|
+
case "fail":
|
|
305
|
+
return cmdFail(rest, args, format);
|
|
306
|
+
case "block":
|
|
307
|
+
return cmdBlock(rest, args, format);
|
|
308
|
+
case "unblock":
|
|
309
|
+
return cmdUnblock(rest, args, format);
|
|
310
|
+
case "release":
|
|
311
|
+
return cmdRelease(rest, format);
|
|
312
|
+
case "destroy":
|
|
313
|
+
return cmdDestroy(args);
|
|
314
|
+
case "reopen":
|
|
315
|
+
return cmdReopen(rest, format);
|
|
316
|
+
case "edit":
|
|
317
|
+
return cmdTaskEdit(rest, args, format);
|
|
318
|
+
case "spec":
|
|
319
|
+
return cmdSpec(rest, args, format);
|
|
320
|
+
case "plan":
|
|
321
|
+
return cmdPlan(rest, args, format);
|
|
322
|
+
case "server":
|
|
323
|
+
return cmdServer(args);
|
|
324
|
+
case "git":
|
|
325
|
+
return cmdGit(rest);
|
|
326
|
+
case "whoami":
|
|
327
|
+
console.log(getCurrentUser());
|
|
328
|
+
return;
|
|
329
|
+
default:
|
|
330
|
+
console.error(`Unknown command: ${command}`);
|
|
331
|
+
console.error("Run 'feina --help' for usage.");
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
336
|
+
if (format === "json") {
|
|
337
|
+
console.error(JSON.stringify({ error: msg }));
|
|
338
|
+
} else {
|
|
339
|
+
console.error(`Error: ${msg}`);
|
|
340
|
+
}
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function cmdGit(rest: string[]): void {
|
|
346
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
347
|
+
const opengitPath = resolve(__dirname, "opengit.sh");
|
|
348
|
+
|
|
349
|
+
if (rest.length === 0) {
|
|
350
|
+
console.error("Usage: feina git <subcommand> [args...]");
|
|
351
|
+
console.error("Runs opengit — safe git wrapper for parallel worktree workflows.");
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const result = spawnSync(opengitPath, rest, { stdio: "inherit" });
|
|
356
|
+
process.exit(result.status ?? 1);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function cmdDestroy(args: ParsedArgs): Promise<void> {
|
|
360
|
+
const dbPath = findDbPath();
|
|
361
|
+
if (!dbPath) {
|
|
362
|
+
console.error("Error: no .tasca/tasca.db found.");
|
|
363
|
+
process.exit(2);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!args.flags["yes"]) {
|
|
367
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
368
|
+
const answer = await new Promise<string>((resolve) =>
|
|
369
|
+
rl.question(`Destroy ${dbPath}? This cannot be undone. [y/N] `, resolve)
|
|
370
|
+
);
|
|
371
|
+
rl.close();
|
|
372
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
373
|
+
console.log("Aborted.");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const tascaDir = dbPath.replace(/\/tasca\.db$/, "");
|
|
379
|
+
rmSync(tascaDir, { recursive: true, force: true });
|
|
380
|
+
console.log(`Destroyed ${tascaDir}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function cmdInit(args: ParsedArgs): void {
|
|
384
|
+
const existing = findDbPath();
|
|
385
|
+
if (existing && !args.flags.force) {
|
|
386
|
+
console.error(`feina DB already exists at ${existing}`);
|
|
387
|
+
console.error(`Use --force to create a nested DB anyway.`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
const path = initDb();
|
|
391
|
+
console.log(`Initialized feina DB at ${path}`);
|
|
392
|
+
|
|
393
|
+
// Auto-add .tasca/ to .gitignore if inside a git repo
|
|
394
|
+
if (existsSync(".git")) {
|
|
395
|
+
const gitignorePath = ".gitignore";
|
|
396
|
+
const entry = ".tasca/";
|
|
397
|
+
|
|
398
|
+
if (!existsSync(gitignorePath)) {
|
|
399
|
+
writeFileSync(gitignorePath, entry + "\n");
|
|
400
|
+
console.log(".tasca/ added to .gitignore");
|
|
401
|
+
} else {
|
|
402
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
403
|
+
if (!content.includes(entry)) {
|
|
404
|
+
const separator = content.endsWith("\n") ? "" : "\n";
|
|
405
|
+
writeFileSync(gitignorePath, content + separator + entry + "\n");
|
|
406
|
+
console.log(".tasca/ added to .gitignore");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function cmdStatus(format: OutputFormat): void {
|
|
413
|
+
const db = ensureDb();
|
|
414
|
+
const counts = db
|
|
415
|
+
.prepare(
|
|
416
|
+
`SELECT
|
|
417
|
+
SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS pending,
|
|
418
|
+
SUM(CASE WHEN status='in_progress' THEN 1 ELSE 0 END) AS in_progress,
|
|
419
|
+
SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) AS completed
|
|
420
|
+
FROM tasks`,
|
|
421
|
+
)
|
|
422
|
+
.get() as { pending: number; in_progress: number; completed: number };
|
|
423
|
+
const specs = (db.prepare(`SELECT COUNT(*) AS n FROM specs`).get() as { n: number }).n;
|
|
424
|
+
const plans = (db.prepare(`SELECT COUNT(*) AS n FROM plans`).get() as { n: number }).n;
|
|
425
|
+
|
|
426
|
+
// Check if server is running by probing the port
|
|
427
|
+
const port = 8272;
|
|
428
|
+
const lsof = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`]);
|
|
429
|
+
const serverRunning = lsof.exitCode === 0 && new TextDecoder().decode(lsof.stdout).trim().length > 0;
|
|
430
|
+
|
|
431
|
+
console.log(
|
|
432
|
+
formatStatus(
|
|
433
|
+
{
|
|
434
|
+
pending: counts.pending ?? 0,
|
|
435
|
+
in_progress: counts.in_progress ?? 0,
|
|
436
|
+
completed: counts.completed ?? 0,
|
|
437
|
+
specs,
|
|
438
|
+
plans,
|
|
439
|
+
serverRunning,
|
|
440
|
+
serverPort: port,
|
|
441
|
+
},
|
|
442
|
+
format,
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function cmdList(args: ParsedArgs, format: OutputFormat): void {
|
|
448
|
+
const db = ensureDb();
|
|
449
|
+
const tasks = listTasks(db, {
|
|
450
|
+
status: args.options.status as TaskStatus | undefined,
|
|
451
|
+
pending: args.flags.pending,
|
|
452
|
+
spec_id: args.options.spec,
|
|
453
|
+
owner: args.options.owner,
|
|
454
|
+
});
|
|
455
|
+
console.log(formatTaskList(tasks, format));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function cmdAdd(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
459
|
+
const [id, ...subjectParts] = rest;
|
|
460
|
+
if (!id || subjectParts.length === 0) {
|
|
461
|
+
console.error(
|
|
462
|
+
"Usage: feina add <TASK-ID> <subject> [--spec ID] [--desc text] [--package X] [--gate X]",
|
|
463
|
+
);
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
const db = ensureDb();
|
|
467
|
+
const task = addTask(db, {
|
|
468
|
+
id,
|
|
469
|
+
subject: subjectParts.join(" "),
|
|
470
|
+
description: args.options.desc,
|
|
471
|
+
spec_id: args.options.spec,
|
|
472
|
+
packages: args.multi.package ?? [],
|
|
473
|
+
quality_gates: args.multi.gate ?? [],
|
|
474
|
+
blocked_by: args.multi["blocked-by"] ?? [],
|
|
475
|
+
});
|
|
476
|
+
console.log(formatTask(task, format));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function cmdShow(rest: string[], format: OutputFormat): void {
|
|
480
|
+
const [id] = rest;
|
|
481
|
+
if (!id) {
|
|
482
|
+
console.error("Usage: feina show <TASK-ID>");
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
const db = ensureDb();
|
|
486
|
+
const task = getTask(db, id);
|
|
487
|
+
if (!task) {
|
|
488
|
+
console.error(`Task ${id} not found`);
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (task.spec_id) {
|
|
493
|
+
const spec = getSpec(db, task.spec_id);
|
|
494
|
+
const plan = spec ? (getLatestPlan(db, task.spec_id) ?? undefined) : undefined;
|
|
495
|
+
if (format === "json") {
|
|
496
|
+
console.log(JSON.stringify({ ...task, spec_context: spec ? { ...spec, plan_content: plan?.content ?? null } : null }, null, 2));
|
|
497
|
+
} else {
|
|
498
|
+
console.log(formatTask(task, format));
|
|
499
|
+
if (spec) {
|
|
500
|
+
console.log();
|
|
501
|
+
console.log(formatSpec(spec, format, { includeContent: true, plan }));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
console.log(formatTask(task, format));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function cmdTake(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
511
|
+
const [id] = rest;
|
|
512
|
+
if (!id) {
|
|
513
|
+
console.error("Usage: feina take <TASK-ID> [--owner name]");
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
const db = ensureDb();
|
|
517
|
+
const owner = args.options.owner ?? getCurrentUser();
|
|
518
|
+
const task = takeTask(db, id, owner);
|
|
519
|
+
|
|
520
|
+
if (task.spec_id) {
|
|
521
|
+
const spec = getSpec(db, task.spec_id);
|
|
522
|
+
const plan = spec ? (getLatestPlan(db, task.spec_id) ?? undefined) : undefined;
|
|
523
|
+
if (format === "json") {
|
|
524
|
+
console.log(JSON.stringify({ ...task, spec_context: spec ? { ...spec, plan_content: plan?.content ?? null } : null }, null, 2));
|
|
525
|
+
} else {
|
|
526
|
+
console.log(formatTask(task, format));
|
|
527
|
+
if (spec) {
|
|
528
|
+
console.log();
|
|
529
|
+
console.log(formatSpec(spec, format, { includeContent: true, plan }));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
console.log(formatTask(task, format));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function cmdDone(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
539
|
+
const [id] = rest;
|
|
540
|
+
const result = args.options.result;
|
|
541
|
+
if (!id || !result) {
|
|
542
|
+
console.error("Usage: feina done <TASK-ID> --result <text>");
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}
|
|
545
|
+
const db = ensureDb();
|
|
546
|
+
const task = doneTask(db, id, result, getCurrentUser());
|
|
547
|
+
console.log(formatTask(task, format));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function cmdFail(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
551
|
+
const [id] = rest;
|
|
552
|
+
const error = args.options.error;
|
|
553
|
+
if (!id || !error) {
|
|
554
|
+
console.error("Usage: feina fail <TASK-ID> --error <text>");
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
const db = ensureDb();
|
|
558
|
+
const task = failTask(db, id, error, getCurrentUser());
|
|
559
|
+
console.log(formatTask(task, format));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function cmdRelease(rest: string[], format: OutputFormat): void {
|
|
563
|
+
const [id] = rest;
|
|
564
|
+
if (!id) { console.error("Usage: feina release <TASK-ID>"); process.exit(1); }
|
|
565
|
+
const db = ensureDb();
|
|
566
|
+
const task = releaseTask(db, id, getCurrentUser());
|
|
567
|
+
console.log(formatTask(task, format));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function cmdReopen(rest: string[], format: OutputFormat): void {
|
|
571
|
+
const [id] = rest;
|
|
572
|
+
if (!id) { console.error("Usage: feina reopen <TASK-ID>"); process.exit(1); }
|
|
573
|
+
const db = ensureDb();
|
|
574
|
+
const task = reopenTask(db, id, getCurrentUser());
|
|
575
|
+
console.log(formatTask(task, format));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function cmdBlock(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
579
|
+
const [id] = rest;
|
|
580
|
+
const by = args.options.by;
|
|
581
|
+
if (!id || !by) {
|
|
582
|
+
console.error("Usage: feina block <TASK-ID> --by <BLOCKER-ID>");
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
const db = ensureDb();
|
|
586
|
+
const task = blockTask(db, id, by);
|
|
587
|
+
console.log(formatTask(task, format));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function cmdUnblock(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
591
|
+
const [id] = rest;
|
|
592
|
+
const dep = args.options.dep;
|
|
593
|
+
if (!id || !dep) {
|
|
594
|
+
console.error("Usage: feina unblock <TASK-ID> --dep <BLOCKER-ID>");
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
const db = ensureDb();
|
|
598
|
+
const task = unblockTask(db, id, dep);
|
|
599
|
+
console.log(formatTask(task, format));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function cmdTaskEdit(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
603
|
+
const [id] = rest;
|
|
604
|
+
if (!id) {
|
|
605
|
+
console.error('Usage: feina task edit <TASK-ID> [--subject text] [--desc text] [--package name] [--gate cmd]');
|
|
606
|
+
console.error(' Supports --stdin and --file for --desc');
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let description: string | undefined;
|
|
611
|
+
if (args.flags.stdin) description = readStdin();
|
|
612
|
+
else if (args.options.file) description = readFileSync(args.options.file, 'utf-8');
|
|
613
|
+
else if (args.options.desc !== undefined) description = args.options.desc;
|
|
614
|
+
|
|
615
|
+
const input: EditTaskInput = {};
|
|
616
|
+
if (args.options.subject !== undefined) input.subject = args.options.subject;
|
|
617
|
+
if (description !== undefined) input.description = description;
|
|
618
|
+
if (args.multi.package?.length) input.packages = args.multi.package;
|
|
619
|
+
if (args.multi.gate?.length) input.quality_gates = args.multi.gate;
|
|
620
|
+
if (args.options.worktree !== undefined) input.worktree = args.options.worktree || null;
|
|
621
|
+
if (args.flags["clear-blocked-by"]) input.blocked_by = [];
|
|
622
|
+
|
|
623
|
+
const db = ensureDb();
|
|
624
|
+
const task = editTask(db, id, input, getCurrentUser());
|
|
625
|
+
console.log(formatTask(task, format));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function cmdSpec(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
629
|
+
const [subcommand, ...subRest] = rest;
|
|
630
|
+
const db = ensureDb();
|
|
631
|
+
const actor = getCurrentUser();
|
|
632
|
+
|
|
633
|
+
switch (subcommand) {
|
|
634
|
+
case "list": {
|
|
635
|
+
const status = args.options.status as SpecStatus | undefined;
|
|
636
|
+
console.log(formatSpecList(listSpecs(db, status), format));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
case "show": {
|
|
640
|
+
const [id] = subRest;
|
|
641
|
+
if (!id) {
|
|
642
|
+
console.error("Usage: feina spec show <SPEC-ID> [--full]");
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
const spec = getSpec(db, id);
|
|
646
|
+
if (!spec) {
|
|
647
|
+
console.error(`Spec ${id} not found`);
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
const plan = getLatestPlan(db, id) ?? undefined;
|
|
651
|
+
console.log(formatSpec(spec, format, { includeContent: args.flags.full, plan }));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
case "content": {
|
|
655
|
+
const [id] = subRest;
|
|
656
|
+
if (!id) {
|
|
657
|
+
console.error("Usage: feina spec content <SPEC-ID>");
|
|
658
|
+
process.exit(1);
|
|
659
|
+
}
|
|
660
|
+
const spec = getSpec(db, id);
|
|
661
|
+
if (!spec) {
|
|
662
|
+
console.error(`Spec ${id} not found`);
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
if (!spec.content) {
|
|
666
|
+
console.error(`Spec ${id} has no content`);
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
process.stdout.write(spec.content);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
case "add": {
|
|
673
|
+
const [id, ...titleParts] = subRest;
|
|
674
|
+
if (!id || titleParts.length === 0) {
|
|
675
|
+
console.error(
|
|
676
|
+
"Usage: feina spec add <SPEC-ID> <title> [--content text | --file path | --stdin]",
|
|
677
|
+
);
|
|
678
|
+
process.exit(1);
|
|
679
|
+
}
|
|
680
|
+
const spec = addSpec(
|
|
681
|
+
db,
|
|
682
|
+
{ id, title: titleParts.join(" "), content: readContentFromArgs(args) },
|
|
683
|
+
actor,
|
|
684
|
+
);
|
|
685
|
+
console.log(formatSpec(spec, format));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
case "set-content": {
|
|
689
|
+
const [id] = subRest;
|
|
690
|
+
if (!id) {
|
|
691
|
+
console.error(
|
|
692
|
+
"Usage: feina spec set-content <SPEC-ID> --content text | --file path | --stdin",
|
|
693
|
+
);
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
const content = readContentFromArgs(args);
|
|
697
|
+
if (content === undefined) {
|
|
698
|
+
console.error("Provide content via --content, --file, or --stdin");
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
const spec = setSpecContent(db, id, content, actor);
|
|
702
|
+
console.log(formatSpec(spec, format));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
case "start": {
|
|
706
|
+
const [id] = subRest;
|
|
707
|
+
if (!id) {
|
|
708
|
+
console.error("Usage: feina spec start <SPEC-ID>");
|
|
709
|
+
process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
console.log(formatSpec(startSpec(db, id, actor), format));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
case "done": {
|
|
715
|
+
const [id] = subRest;
|
|
716
|
+
if (!id) {
|
|
717
|
+
console.error("Usage: feina spec done <SPEC-ID> [--pr N] [--merged YYYY-MM-DD]");
|
|
718
|
+
process.exit(1);
|
|
719
|
+
}
|
|
720
|
+
const spec = doneSpec(
|
|
721
|
+
db,
|
|
722
|
+
id,
|
|
723
|
+
{ pr: args.options.pr, merged_date: args.options.merged },
|
|
724
|
+
actor,
|
|
725
|
+
);
|
|
726
|
+
console.log(formatSpec(spec, format));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
case "edit": {
|
|
730
|
+
const [id] = subRest;
|
|
731
|
+
if (!id) {
|
|
732
|
+
console.error("Usage: feina spec edit <SPEC-ID> [--title text]");
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
const spec = editSpec(db, id, { title: args.options.title }, actor);
|
|
736
|
+
console.log(formatSpec(spec, format));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
case "archive": {
|
|
740
|
+
const [id] = subRest;
|
|
741
|
+
if (!id) { console.error("Usage: feina spec archive <SPEC-ID>"); process.exit(1); }
|
|
742
|
+
console.log(formatSpec(archiveSpec(db, id, actor), format));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
case "unarchive": {
|
|
746
|
+
const [id] = subRest;
|
|
747
|
+
if (!id) { console.error("Usage: feina spec unarchive <SPEC-ID>"); process.exit(1); }
|
|
748
|
+
console.log(formatSpec(unarchiveSpec(db, id, actor), format));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
case "delete": {
|
|
752
|
+
const [id] = subRest;
|
|
753
|
+
if (!id) { console.error("Usage: feina spec delete <SPEC-ID>"); process.exit(1); }
|
|
754
|
+
console.log(JSON.stringify(deleteSpec(db, id, actor)));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
default:
|
|
758
|
+
console.error(`Unknown spec subcommand: ${subcommand ?? "(none)"}`);
|
|
759
|
+
console.error("Available: list, show, content, add, set-content, start, done, edit, archive, unarchive, delete");
|
|
760
|
+
process.exit(1);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function cmdServer(args: ParsedArgs): Promise<void> {
|
|
765
|
+
const port = Number(args.options.port ?? "8272");
|
|
766
|
+
|
|
767
|
+
// --down: kill whatever is listening on the tasca port
|
|
768
|
+
if (args.flags["down"]) {
|
|
769
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
770
|
+
console.error("Error: --port must be a valid port number");
|
|
771
|
+
process.exit(1);
|
|
772
|
+
}
|
|
773
|
+
const result = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`]);
|
|
774
|
+
const pids = new TextDecoder().decode(result.stdout).trim().split("\n").filter(Boolean);
|
|
775
|
+
if (pids.length === 0) {
|
|
776
|
+
console.log(`No process found listening on port ${port}.`);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
for (const pid of pids) {
|
|
780
|
+
try {
|
|
781
|
+
process.kill(Number(pid), "SIGTERM");
|
|
782
|
+
console.log(`Stopped tasca server (PID ${pid}).`);
|
|
783
|
+
} catch {
|
|
784
|
+
console.error(`Failed to kill PID ${pid}.`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Verify DB exists before starting server (fast fail)
|
|
791
|
+
if (!findDbPath()) {
|
|
792
|
+
console.error("Error: no .tasca/tasca.db found. Run 'feina init' first.");
|
|
793
|
+
process.exit(2);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const host = args.options.host ?? "127.0.0.1";
|
|
797
|
+
|
|
798
|
+
if (args.flags["daemon"]) {
|
|
799
|
+
// Spawn a detached child WITHOUT starting the server in the parent first.
|
|
800
|
+
const noDaemon = (a: string) => a !== "--daemon" && a !== "-d";
|
|
801
|
+
// In compiled binary argv[1] is a virtual /$bunfs/ path — skip it; user args start at argv[2].
|
|
802
|
+
// In dev mode (bun src/cli.ts) argv[1] is the script path — keep it.
|
|
803
|
+
const isCompiled = process.argv[1]?.startsWith("/$bunfs/");
|
|
804
|
+
const childArgs = isCompiled
|
|
805
|
+
? [process.execPath, ...process.argv.slice(2).filter(noDaemon)]
|
|
806
|
+
: [process.execPath, ...process.argv.slice(1).filter(noDaemon)];
|
|
807
|
+
const child = Bun.spawn(childArgs, { detached: true, stdio: ["ignore", "ignore", "ignore"] });
|
|
808
|
+
child.unref();
|
|
809
|
+
console.log(`tasca dashboard → http://${host}:${port}`);
|
|
810
|
+
console.log(`Stop with: tasca server --down`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Lazy import so server.ts and dashboard.ts aren't loaded in non-server CLI invocations.
|
|
815
|
+
const { startServer } = await import("./server");
|
|
816
|
+
const server = startServer({ port, host });
|
|
817
|
+
|
|
818
|
+
console.log(`tasca dashboard listening at ${server.url}`);
|
|
819
|
+
console.log(`Stop with: tasca server --down`);
|
|
820
|
+
|
|
821
|
+
// Keep process alive until SIGINT
|
|
822
|
+
process.on("SIGINT", () => {
|
|
823
|
+
console.log("\nStopping server...");
|
|
824
|
+
server.stop();
|
|
825
|
+
process.exit(0);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function cmdPlan(rest: string[], args: ParsedArgs, format: OutputFormat): void {
|
|
830
|
+
const [subcommand, ...subRest] = rest;
|
|
831
|
+
const db = ensureDb();
|
|
832
|
+
const actor = getCurrentUser();
|
|
833
|
+
|
|
834
|
+
switch (subcommand) {
|
|
835
|
+
case "add": {
|
|
836
|
+
const [specId] = subRest;
|
|
837
|
+
if (!specId) {
|
|
838
|
+
console.error(
|
|
839
|
+
"Usage: feina plan add <SPEC-ID> --content text | --file path | --stdin",
|
|
840
|
+
);
|
|
841
|
+
process.exit(1);
|
|
842
|
+
}
|
|
843
|
+
const content = readContentFromArgs(args);
|
|
844
|
+
if (content === undefined) {
|
|
845
|
+
console.error("Provide content via --content, --file, or --stdin");
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
const plan = addPlan(db, specId, content, actor);
|
|
849
|
+
console.log(formatPlan(plan, format));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
case "show": {
|
|
853
|
+
const [specId] = subRest;
|
|
854
|
+
if (!specId) {
|
|
855
|
+
console.error("Usage: feina plan show <SPEC-ID>");
|
|
856
|
+
process.exit(1);
|
|
857
|
+
}
|
|
858
|
+
const plan = getLatestPlan(db, specId);
|
|
859
|
+
if (!plan) {
|
|
860
|
+
console.error(`No plan found for spec ${specId}`);
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
process.stdout.write(plan.content);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
case "list": {
|
|
867
|
+
const [specId] = subRest;
|
|
868
|
+
if (!specId) {
|
|
869
|
+
console.error("Usage: feina plan list <SPEC-ID>");
|
|
870
|
+
process.exit(1);
|
|
871
|
+
}
|
|
872
|
+
console.log(formatPlanList(listPlans(db, specId), format));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
default:
|
|
876
|
+
console.error(`Unknown plan subcommand: ${subcommand ?? "(none)"}`);
|
|
877
|
+
console.error("Available: add, show, list");
|
|
878
|
+
process.exit(1);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
main().catch((err) => {
|
|
883
|
+
console.error(err);
|
|
884
|
+
process.exit(1);
|
|
885
|
+
});
|