prodboard 0.0.0 → 0.1.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 +16 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/bin/prodboard.ts +4 -0
- package/config.schema.json +100 -0
- package/package.json +47 -6
- package/src/commands/comments.ts +86 -0
- package/src/commands/daemon.ts +112 -0
- package/src/commands/init.ts +83 -0
- package/src/commands/issues.ts +268 -0
- package/src/commands/schedules.ts +276 -0
- package/src/config.ts +155 -0
- package/src/confirm.ts +14 -0
- package/src/cron.ts +121 -0
- package/src/db.ts +157 -0
- package/src/format.ts +99 -0
- package/src/ids.ts +5 -0
- package/src/index.ts +214 -0
- package/src/invocation.ts +102 -0
- package/src/logger.ts +84 -0
- package/src/mcp.ts +543 -0
- package/src/queries/comments.ts +31 -0
- package/src/queries/issues.ts +155 -0
- package/src/queries/runs.ts +159 -0
- package/src/queries/schedules.ts +115 -0
- package/src/scheduler.ts +411 -0
- package/src/templates.ts +43 -0
- package/src/types.ts +82 -0
- package/templates/CLAUDE.md +12 -0
- package/templates/config.jsonc +38 -0
- package/templates/mcp.json +8 -0
- package/templates/system-prompt-nogit.md +33 -0
- package/templates/system-prompt.md +31 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { PRODBOARD_DIR } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
export class NotInitializedError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super(`prodboard is not initialized. Run 'prodboard init' first.`);
|
|
7
|
+
this.name = "NotInitializedError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DatabaseError extends Error {
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "DatabaseError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ensureInitialized(): void {
|
|
19
|
+
if (!existsSync(PRODBOARD_DIR)) {
|
|
20
|
+
throw new NotInitializedError();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function main(): Promise<void> {
|
|
25
|
+
const args = Bun.argv.slice(2);
|
|
26
|
+
const command = args[0];
|
|
27
|
+
|
|
28
|
+
if (command === "--version" || command === "version") {
|
|
29
|
+
const pkg = await import("../package.json");
|
|
30
|
+
console.log(pkg.version);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (command === "--help" || command === "help" || !command) {
|
|
35
|
+
printHelp();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
switch (command) {
|
|
41
|
+
case "init": {
|
|
42
|
+
const { init } = await import("./commands/init.ts");
|
|
43
|
+
await init(args.slice(1));
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "add": {
|
|
47
|
+
ensureInitialized();
|
|
48
|
+
const { add } = await import("./commands/issues.ts");
|
|
49
|
+
await add(args.slice(1));
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case "ls": {
|
|
53
|
+
ensureInitialized();
|
|
54
|
+
const { ls } = await import("./commands/issues.ts");
|
|
55
|
+
await ls(args.slice(1));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "show": {
|
|
59
|
+
ensureInitialized();
|
|
60
|
+
const { show } = await import("./commands/issues.ts");
|
|
61
|
+
await show(args.slice(1));
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "edit": {
|
|
65
|
+
ensureInitialized();
|
|
66
|
+
const { edit } = await import("./commands/issues.ts");
|
|
67
|
+
await edit(args.slice(1));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "mv": {
|
|
71
|
+
ensureInitialized();
|
|
72
|
+
const { mv } = await import("./commands/issues.ts");
|
|
73
|
+
await mv(args.slice(1));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "rm": {
|
|
77
|
+
ensureInitialized();
|
|
78
|
+
const { rm } = await import("./commands/issues.ts");
|
|
79
|
+
await rm(args.slice(1));
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "comment": {
|
|
83
|
+
ensureInitialized();
|
|
84
|
+
const { comment } = await import("./commands/comments.ts");
|
|
85
|
+
await comment(args.slice(1));
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "comments": {
|
|
89
|
+
ensureInitialized();
|
|
90
|
+
const { comments } = await import("./commands/comments.ts");
|
|
91
|
+
await comments(args.slice(1));
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "schedule": {
|
|
95
|
+
ensureInitialized();
|
|
96
|
+
const sub = args[1];
|
|
97
|
+
const subArgs = args.slice(2);
|
|
98
|
+
const schedMod = await import("./commands/schedules.ts");
|
|
99
|
+
switch (sub) {
|
|
100
|
+
case "add":
|
|
101
|
+
await schedMod.scheduleAdd(subArgs);
|
|
102
|
+
break;
|
|
103
|
+
case "ls":
|
|
104
|
+
await schedMod.scheduleLs(subArgs);
|
|
105
|
+
break;
|
|
106
|
+
case "edit":
|
|
107
|
+
await schedMod.scheduleEdit(subArgs);
|
|
108
|
+
break;
|
|
109
|
+
case "enable":
|
|
110
|
+
await schedMod.scheduleEnable(subArgs);
|
|
111
|
+
break;
|
|
112
|
+
case "disable":
|
|
113
|
+
await schedMod.scheduleDisable(subArgs);
|
|
114
|
+
break;
|
|
115
|
+
case "rm":
|
|
116
|
+
await schedMod.scheduleRm(subArgs);
|
|
117
|
+
break;
|
|
118
|
+
case "logs":
|
|
119
|
+
await schedMod.scheduleLogs(subArgs);
|
|
120
|
+
break;
|
|
121
|
+
case "run":
|
|
122
|
+
await schedMod.scheduleRun(subArgs);
|
|
123
|
+
break;
|
|
124
|
+
case "stats":
|
|
125
|
+
await schedMod.scheduleStats(subArgs);
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
console.error(`Unknown schedule subcommand: ${sub}`);
|
|
129
|
+
console.error("Available: add, ls, edit, enable, disable, rm, logs, run, stats");
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "daemon": {
|
|
135
|
+
ensureInitialized();
|
|
136
|
+
const sub = args[1];
|
|
137
|
+
const daemonMod = await import("./commands/daemon.ts");
|
|
138
|
+
if (sub === "status") {
|
|
139
|
+
await daemonMod.daemonStatus(args.slice(2));
|
|
140
|
+
} else {
|
|
141
|
+
await daemonMod.daemonStart(args.slice(1));
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "config": {
|
|
146
|
+
ensureInitialized();
|
|
147
|
+
const { loadConfig } = await import("./config.ts");
|
|
148
|
+
const config = loadConfig();
|
|
149
|
+
console.log(JSON.stringify(config, null, 2));
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "mcp": {
|
|
153
|
+
ensureInitialized();
|
|
154
|
+
const { startMcpServer } = await import("./mcp.ts");
|
|
155
|
+
await startMcpServer();
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
default:
|
|
159
|
+
console.error(`Unknown command: ${command}`);
|
|
160
|
+
printHelp();
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
if (err instanceof NotInitializedError) {
|
|
165
|
+
console.error(err.message);
|
|
166
|
+
process.exit(3);
|
|
167
|
+
}
|
|
168
|
+
if (err instanceof DatabaseError) {
|
|
169
|
+
console.error(`Database error: ${err.message}`);
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|
|
172
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function printHelp(): void {
|
|
178
|
+
console.log(`prodboard — CLI-first issue tracker for AI coding agents
|
|
179
|
+
|
|
180
|
+
Usage: prodboard <command> [options]
|
|
181
|
+
|
|
182
|
+
Commands:
|
|
183
|
+
init Initialize prodboard (~/.prodboard/)
|
|
184
|
+
add <title> Create a new issue
|
|
185
|
+
ls List issues
|
|
186
|
+
show <id> Show issue details
|
|
187
|
+
edit <id> Edit an issue
|
|
188
|
+
mv <id> <status> Change issue status
|
|
189
|
+
rm <id> Delete an issue
|
|
190
|
+
comment <id> Add a comment to an issue
|
|
191
|
+
comments <id> List comments for an issue
|
|
192
|
+
schedule <sub> Manage scheduled tasks
|
|
193
|
+
daemon Start the scheduler daemon
|
|
194
|
+
config Show configuration
|
|
195
|
+
mcp Start MCP server (stdio)
|
|
196
|
+
version Show version
|
|
197
|
+
help Show this help
|
|
198
|
+
|
|
199
|
+
Schedule subcommands:
|
|
200
|
+
schedule add Create a schedule
|
|
201
|
+
schedule ls List schedules
|
|
202
|
+
schedule edit Edit a schedule
|
|
203
|
+
schedule enable Enable a schedule
|
|
204
|
+
schedule disable Disable a schedule
|
|
205
|
+
schedule rm Delete a schedule
|
|
206
|
+
schedule logs Show run history
|
|
207
|
+
schedule run Run a schedule immediately
|
|
208
|
+
schedule stats Show schedule statistics
|
|
209
|
+
|
|
210
|
+
Options:
|
|
211
|
+
--json Output in JSON format
|
|
212
|
+
--help Show help for a command
|
|
213
|
+
--version Show version`);
|
|
214
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import type { Config, Schedule, Run, EnvironmentInfo } from "./types.ts";
|
|
3
|
+
import { PRODBOARD_DIR } from "./config.ts";
|
|
4
|
+
import { getLastSessionId } from "./queries/runs.ts";
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
|
|
7
|
+
export function detectEnvironment(workdir: string, config: Config): EnvironmentInfo {
|
|
8
|
+
let hasGit = false;
|
|
9
|
+
try {
|
|
10
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--git-dir"], {
|
|
11
|
+
cwd: workdir,
|
|
12
|
+
stdout: "pipe",
|
|
13
|
+
stderr: "pipe",
|
|
14
|
+
});
|
|
15
|
+
hasGit = result.exitCode === 0;
|
|
16
|
+
} catch {}
|
|
17
|
+
|
|
18
|
+
let hasClaude = false;
|
|
19
|
+
try {
|
|
20
|
+
const result = Bun.spawnSync(["claude", "--version"], {
|
|
21
|
+
stdout: "pipe",
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
});
|
|
24
|
+
hasClaude = result.exitCode === 0;
|
|
25
|
+
} catch {}
|
|
26
|
+
|
|
27
|
+
const worktreeSupported = hasGit && config.daemon.useWorktrees !== "never";
|
|
28
|
+
|
|
29
|
+
return { hasGit, hasClaude, worktreeSupported };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildInvocation(
|
|
33
|
+
schedule: Schedule,
|
|
34
|
+
run: Run,
|
|
35
|
+
config: Config,
|
|
36
|
+
env: EnvironmentInfo,
|
|
37
|
+
resolvedPrompt: string,
|
|
38
|
+
db?: Database
|
|
39
|
+
): string[] {
|
|
40
|
+
const args: string[] = ["claude"];
|
|
41
|
+
|
|
42
|
+
// Prompt
|
|
43
|
+
args.push("-p", resolvedPrompt);
|
|
44
|
+
|
|
45
|
+
// Permissions
|
|
46
|
+
args.push("--dangerously-skip-permissions");
|
|
47
|
+
|
|
48
|
+
// Output format
|
|
49
|
+
args.push("--output-format", "stream-json");
|
|
50
|
+
|
|
51
|
+
// MCP config
|
|
52
|
+
const mcpConfigPath = path.join(PRODBOARD_DIR, "mcp.json");
|
|
53
|
+
args.push("--mcp-config", mcpConfigPath);
|
|
54
|
+
|
|
55
|
+
// System prompt
|
|
56
|
+
const systemPromptFile = env.hasGit
|
|
57
|
+
? path.join(PRODBOARD_DIR, "system-prompt.md")
|
|
58
|
+
: path.join(PRODBOARD_DIR, "system-prompt-nogit.md");
|
|
59
|
+
args.push("--append-system-prompt-file", systemPromptFile);
|
|
60
|
+
|
|
61
|
+
// Max turns: min of schedule override, config default, and hard max
|
|
62
|
+
const scheduleTurns = schedule.max_turns ?? config.daemon.maxTurns;
|
|
63
|
+
const maxTurns = Math.min(scheduleTurns, config.daemon.hardMaxTurns);
|
|
64
|
+
args.push("--max-turns", String(maxTurns));
|
|
65
|
+
|
|
66
|
+
// Allowed tools
|
|
67
|
+
let tools: string[];
|
|
68
|
+
if (schedule.allowed_tools) {
|
|
69
|
+
try {
|
|
70
|
+
tools = JSON.parse(schedule.allowed_tools);
|
|
71
|
+
} catch {
|
|
72
|
+
tools = env.hasGit ? config.daemon.defaultAllowedTools : config.daemon.nonGitDefaultAllowedTools;
|
|
73
|
+
}
|
|
74
|
+
} else if (!env.hasGit) {
|
|
75
|
+
tools = config.daemon.nonGitDefaultAllowedTools;
|
|
76
|
+
} else {
|
|
77
|
+
tools = config.daemon.defaultAllowedTools;
|
|
78
|
+
}
|
|
79
|
+
for (const tool of tools) {
|
|
80
|
+
args.push("--allowedTools", tool);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Worktree
|
|
84
|
+
if (env.worktreeSupported && schedule.use_worktree !== 0) {
|
|
85
|
+
args.push("--worktree");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Session resume
|
|
89
|
+
if (schedule.persist_session && db) {
|
|
90
|
+
const lastSessionId = getLastSessionId(db, schedule.id);
|
|
91
|
+
if (lastSessionId) {
|
|
92
|
+
args.push("--resume", lastSessionId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Agents JSON
|
|
97
|
+
if (schedule.agents_json) {
|
|
98
|
+
args.push("--agents", schedule.agents_json);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return args;
|
|
102
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
7
|
+
debug: 0,
|
|
8
|
+
info: 1,
|
|
9
|
+
warn: 2,
|
|
10
|
+
error: 3,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class Logger {
|
|
14
|
+
private logDir: string;
|
|
15
|
+
private level: LogLevel;
|
|
16
|
+
private maxSizeBytes: number;
|
|
17
|
+
private maxFiles: number;
|
|
18
|
+
private logFile: string;
|
|
19
|
+
|
|
20
|
+
constructor(options: { logDir: string; level: LogLevel; maxSizeMb: number; maxFiles: number }) {
|
|
21
|
+
this.logDir = options.logDir;
|
|
22
|
+
this.level = options.level;
|
|
23
|
+
this.maxSizeBytes = options.maxSizeMb * 1024 * 1024;
|
|
24
|
+
this.maxFiles = options.maxFiles;
|
|
25
|
+
this.logFile = path.join(this.logDir, "daemon.log");
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(this.logDir)) {
|
|
28
|
+
fs.mkdirSync(this.logDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
debug(msg: string, data?: Record<string, unknown>): void {
|
|
33
|
+
this.log("debug", msg, data);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
info(msg: string, data?: Record<string, unknown>): void {
|
|
37
|
+
this.log("info", msg, data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
warn(msg: string, data?: Record<string, unknown>): void {
|
|
41
|
+
this.log("warn", msg, data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
error(msg: string, data?: Record<string, unknown>): void {
|
|
45
|
+
this.log("error", msg, data);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
|
|
49
|
+
if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) return;
|
|
50
|
+
|
|
51
|
+
const timestamp = new Date().toISOString();
|
|
52
|
+
const dataStr = data ? " " + JSON.stringify(data) : "";
|
|
53
|
+
const line = `[${timestamp}] [${level.toUpperCase()}] ${msg}${dataStr}\n`;
|
|
54
|
+
|
|
55
|
+
this.rotate();
|
|
56
|
+
fs.appendFileSync(this.logFile, line);
|
|
57
|
+
|
|
58
|
+
// Also output to stderr
|
|
59
|
+
process.stderr.write(line);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private rotate(): void {
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.existsSync(this.logFile)) return;
|
|
65
|
+
const stat = fs.statSync(this.logFile);
|
|
66
|
+
if (stat.size < this.maxSizeBytes) return;
|
|
67
|
+
|
|
68
|
+
// Shift existing rotated files
|
|
69
|
+
for (let i = this.maxFiles; i >= 1; i--) {
|
|
70
|
+
const src = path.join(this.logDir, `daemon.${i}.log`);
|
|
71
|
+
if (fs.existsSync(src)) {
|
|
72
|
+
if (i >= this.maxFiles) {
|
|
73
|
+
fs.unlinkSync(src);
|
|
74
|
+
} else {
|
|
75
|
+
fs.renameSync(src, path.join(this.logDir, `daemon.${i + 1}.log`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Move current to .1
|
|
81
|
+
fs.renameSync(this.logFile, path.join(this.logDir, "daemon.1.log"));
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
}
|