lockstep-mcp 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/LICENSE +21 -0
- package/README.md +669 -0
- package/dist/cli.js +367 -0
- package/dist/config.js +48 -0
- package/dist/dashboard.js +1982 -0
- package/dist/install.js +252 -0
- package/dist/macos.js +55 -0
- package/dist/prompts.js +173 -0
- package/dist/server.js +1942 -0
- package/dist/storage.js +1235 -0
- package/dist/tmux.js +87 -0
- package/dist/utils.js +35 -0
- package/dist/worktree.js +356 -0
- package/package.json +66 -0
package/dist/storage.js
ADDED
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { ensureDir, sleep } from "./utils.js";
|
|
6
|
+
function nowIso() {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
async function appendLog(logDir, event, payload) {
|
|
10
|
+
const logPath = path.join(logDir, "events.jsonl");
|
|
11
|
+
const line = JSON.stringify({ ts: nowIso(), event, ...payload });
|
|
12
|
+
await fs.appendFile(logPath, `${line}\n`, "utf8");
|
|
13
|
+
}
|
|
14
|
+
const DEFAULT_STATE = {
|
|
15
|
+
tasks: [],
|
|
16
|
+
locks: [],
|
|
17
|
+
notes: [],
|
|
18
|
+
};
|
|
19
|
+
export class JsonStore {
|
|
20
|
+
dataDir;
|
|
21
|
+
logDir;
|
|
22
|
+
statePath;
|
|
23
|
+
lockPath;
|
|
24
|
+
contextPath;
|
|
25
|
+
constructor(dataDir, logDir) {
|
|
26
|
+
this.dataDir = dataDir;
|
|
27
|
+
this.logDir = logDir;
|
|
28
|
+
this.statePath = path.join(this.dataDir, "state.json");
|
|
29
|
+
this.lockPath = path.join(this.dataDir, "state.lock");
|
|
30
|
+
this.contextPath = path.join(this.dataDir, "project_contexts.json");
|
|
31
|
+
}
|
|
32
|
+
async init() {
|
|
33
|
+
await ensureDir(this.dataDir);
|
|
34
|
+
await ensureDir(this.logDir);
|
|
35
|
+
}
|
|
36
|
+
async loadState() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await fs.readFile(this.statePath, "utf8");
|
|
39
|
+
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const err = error;
|
|
43
|
+
if (err.code === "ENOENT")
|
|
44
|
+
return { ...DEFAULT_STATE };
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async saveState(state) {
|
|
49
|
+
await fs.writeFile(this.statePath, JSON.stringify(state, null, 2), "utf8");
|
|
50
|
+
}
|
|
51
|
+
async withStateLock(fn) {
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
const timeoutMs = 5000;
|
|
54
|
+
while (true) {
|
|
55
|
+
try {
|
|
56
|
+
const handle = await fs.open(this.lockPath, "wx");
|
|
57
|
+
await handle.close();
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const err = error;
|
|
62
|
+
if (err.code !== "EEXIST")
|
|
63
|
+
throw error;
|
|
64
|
+
if (Date.now() - start > timeoutMs) {
|
|
65
|
+
throw new Error("Timed out waiting for state lock");
|
|
66
|
+
}
|
|
67
|
+
await sleep(50);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return await fn();
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await fs.unlink(this.lockPath).catch(() => undefined);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async status() {
|
|
78
|
+
return this.loadState();
|
|
79
|
+
}
|
|
80
|
+
async createTask(input) {
|
|
81
|
+
return this.withStateLock(async () => {
|
|
82
|
+
const state = await this.loadState();
|
|
83
|
+
const task = {
|
|
84
|
+
id: crypto.randomUUID(),
|
|
85
|
+
title: input.title,
|
|
86
|
+
description: input.description,
|
|
87
|
+
status: input.status ?? "todo",
|
|
88
|
+
complexity: input.complexity ?? "medium",
|
|
89
|
+
isolation: input.isolation ?? "shared",
|
|
90
|
+
owner: input.owner,
|
|
91
|
+
tags: input.tags,
|
|
92
|
+
metadata: input.metadata,
|
|
93
|
+
createdAt: nowIso(),
|
|
94
|
+
updatedAt: nowIso(),
|
|
95
|
+
};
|
|
96
|
+
state.tasks.push(task);
|
|
97
|
+
await this.saveState(state);
|
|
98
|
+
await appendLog(this.logDir, "task_create", { task });
|
|
99
|
+
return task;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async updateTask(input) {
|
|
103
|
+
return this.withStateLock(async () => {
|
|
104
|
+
const state = await this.loadState();
|
|
105
|
+
const task = state.tasks.find((item) => item.id === input.id);
|
|
106
|
+
if (!task)
|
|
107
|
+
throw new Error(`Task not found: ${input.id}`);
|
|
108
|
+
if (input.title !== undefined)
|
|
109
|
+
task.title = input.title;
|
|
110
|
+
if (input.description !== undefined)
|
|
111
|
+
task.description = input.description;
|
|
112
|
+
if (input.status !== undefined)
|
|
113
|
+
task.status = input.status;
|
|
114
|
+
if (input.owner !== undefined)
|
|
115
|
+
task.owner = input.owner;
|
|
116
|
+
if (input.tags !== undefined)
|
|
117
|
+
task.tags = input.tags;
|
|
118
|
+
if (input.metadata !== undefined)
|
|
119
|
+
task.metadata = input.metadata;
|
|
120
|
+
task.updatedAt = nowIso();
|
|
121
|
+
await this.saveState(state);
|
|
122
|
+
await appendLog(this.logDir, "task_update", { task });
|
|
123
|
+
return task;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async claimTask(input) {
|
|
127
|
+
return this.updateTask({ id: input.id, owner: input.owner, status: "in_progress" });
|
|
128
|
+
}
|
|
129
|
+
// Review workflow methods - not fully implemented for JSON storage
|
|
130
|
+
async submitTaskForReview() {
|
|
131
|
+
throw new Error("Review workflow requires SQLite storage. Set storage: 'sqlite' in config.");
|
|
132
|
+
}
|
|
133
|
+
async approveTask() {
|
|
134
|
+
throw new Error("Review workflow requires SQLite storage.");
|
|
135
|
+
}
|
|
136
|
+
async requestTaskChanges() {
|
|
137
|
+
throw new Error("Review workflow requires SQLite storage.");
|
|
138
|
+
}
|
|
139
|
+
async listTasks(filters) {
|
|
140
|
+
const state = await this.loadState();
|
|
141
|
+
let tasks = [...state.tasks];
|
|
142
|
+
if (filters?.status)
|
|
143
|
+
tasks = tasks.filter((task) => task.status === filters.status);
|
|
144
|
+
if (filters?.owner)
|
|
145
|
+
tasks = tasks.filter((task) => task.owner === filters.owner);
|
|
146
|
+
if (filters?.tag)
|
|
147
|
+
tasks = tasks.filter((task) => task.tags?.includes(filters.tag ?? ""));
|
|
148
|
+
if (filters?.limit && filters.limit > 0)
|
|
149
|
+
tasks = tasks.slice(0, filters.limit);
|
|
150
|
+
return tasks;
|
|
151
|
+
}
|
|
152
|
+
async acquireLock(input) {
|
|
153
|
+
return this.withStateLock(async () => {
|
|
154
|
+
const state = await this.loadState();
|
|
155
|
+
const existing = state.locks.find((lock) => lock.path === input.path && lock.status === "active");
|
|
156
|
+
if (existing)
|
|
157
|
+
throw new Error(`Lock already active for ${input.path}`);
|
|
158
|
+
const lock = {
|
|
159
|
+
path: input.path,
|
|
160
|
+
owner: input.owner,
|
|
161
|
+
note: input.note,
|
|
162
|
+
status: "active",
|
|
163
|
+
createdAt: nowIso(),
|
|
164
|
+
updatedAt: nowIso(),
|
|
165
|
+
};
|
|
166
|
+
state.locks.push(lock);
|
|
167
|
+
await this.saveState(state);
|
|
168
|
+
await appendLog(this.logDir, "lock_acquire", { lock });
|
|
169
|
+
return lock;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async releaseLock(input) {
|
|
173
|
+
return this.withStateLock(async () => {
|
|
174
|
+
const state = await this.loadState();
|
|
175
|
+
const lock = state.locks.find((item) => item.path === input.path && item.status === "active");
|
|
176
|
+
if (!lock)
|
|
177
|
+
throw new Error(`Active lock not found for ${input.path}`);
|
|
178
|
+
if (input.owner && lock.owner && input.owner !== lock.owner) {
|
|
179
|
+
throw new Error(`Lock owned by ${lock.owner}, not ${input.owner}`);
|
|
180
|
+
}
|
|
181
|
+
lock.status = "resolved";
|
|
182
|
+
lock.updatedAt = nowIso();
|
|
183
|
+
await this.saveState(state);
|
|
184
|
+
await appendLog(this.logDir, "lock_release", { lock });
|
|
185
|
+
return lock;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async listLocks(filters) {
|
|
189
|
+
const state = await this.loadState();
|
|
190
|
+
let locks = [...state.locks];
|
|
191
|
+
if (filters?.status)
|
|
192
|
+
locks = locks.filter((lock) => lock.status === filters.status);
|
|
193
|
+
if (filters?.owner)
|
|
194
|
+
locks = locks.filter((lock) => lock.owner === filters.owner);
|
|
195
|
+
return locks;
|
|
196
|
+
}
|
|
197
|
+
async appendNote(input) {
|
|
198
|
+
return this.withStateLock(async () => {
|
|
199
|
+
const state = await this.loadState();
|
|
200
|
+
const note = {
|
|
201
|
+
id: crypto.randomUUID(),
|
|
202
|
+
text: input.text,
|
|
203
|
+
author: input.author,
|
|
204
|
+
createdAt: nowIso(),
|
|
205
|
+
};
|
|
206
|
+
state.notes.push(note);
|
|
207
|
+
await this.saveState(state);
|
|
208
|
+
await appendLog(this.logDir, "note_append", { note });
|
|
209
|
+
return note;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async listNotes(limit) {
|
|
213
|
+
const state = await this.loadState();
|
|
214
|
+
if (!limit || limit <= 0)
|
|
215
|
+
return [...state.notes];
|
|
216
|
+
return state.notes.slice(Math.max(state.notes.length - limit, 0));
|
|
217
|
+
}
|
|
218
|
+
async appendLogEntry(event, payload) {
|
|
219
|
+
await appendLog(this.logDir, event, payload ?? {});
|
|
220
|
+
}
|
|
221
|
+
async loadContexts() {
|
|
222
|
+
try {
|
|
223
|
+
const raw = await fs.readFile(this.contextPath, "utf8");
|
|
224
|
+
return JSON.parse(raw);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
const err = error;
|
|
228
|
+
if (err.code === "ENOENT")
|
|
229
|
+
return {};
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async saveContexts(contexts) {
|
|
234
|
+
await fs.writeFile(this.contextPath, JSON.stringify(contexts, null, 2), "utf8");
|
|
235
|
+
}
|
|
236
|
+
async setProjectContext(input) {
|
|
237
|
+
return this.withStateLock(async () => {
|
|
238
|
+
const contexts = await this.loadContexts();
|
|
239
|
+
const existing = contexts[input.projectRoot];
|
|
240
|
+
const context = {
|
|
241
|
+
projectRoot: input.projectRoot,
|
|
242
|
+
description: input.description,
|
|
243
|
+
endState: input.endState,
|
|
244
|
+
techStack: input.techStack,
|
|
245
|
+
constraints: input.constraints,
|
|
246
|
+
acceptanceCriteria: input.acceptanceCriteria,
|
|
247
|
+
tests: input.tests,
|
|
248
|
+
implementationPlan: input.implementationPlan,
|
|
249
|
+
preferredImplementer: input.preferredImplementer ?? existing?.preferredImplementer,
|
|
250
|
+
status: input.status ?? existing?.status ?? "planning",
|
|
251
|
+
createdAt: existing?.createdAt ?? nowIso(),
|
|
252
|
+
updatedAt: nowIso(),
|
|
253
|
+
};
|
|
254
|
+
contexts[input.projectRoot] = context;
|
|
255
|
+
await this.saveContexts(contexts);
|
|
256
|
+
await appendLog(this.logDir, "project_context_set", { context });
|
|
257
|
+
return context;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
async getProjectContext(projectRoot) {
|
|
261
|
+
const contexts = await this.loadContexts();
|
|
262
|
+
return contexts[projectRoot] ?? null;
|
|
263
|
+
}
|
|
264
|
+
async listAllProjectContexts() {
|
|
265
|
+
const contexts = await this.loadContexts();
|
|
266
|
+
return Object.values(contexts);
|
|
267
|
+
}
|
|
268
|
+
async updateProjectStatus(projectRoot, status) {
|
|
269
|
+
return this.withStateLock(async () => {
|
|
270
|
+
const contexts = await this.loadContexts();
|
|
271
|
+
const existing = contexts[projectRoot];
|
|
272
|
+
if (!existing)
|
|
273
|
+
throw new Error(`Project context not found: ${projectRoot}`);
|
|
274
|
+
existing.status = status;
|
|
275
|
+
existing.updatedAt = nowIso();
|
|
276
|
+
await this.saveContexts(contexts);
|
|
277
|
+
await appendLog(this.logDir, "project_status_update", { projectRoot, status });
|
|
278
|
+
return existing;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
get implementersPath() {
|
|
282
|
+
return path.join(this.dataDir, "implementers.json");
|
|
283
|
+
}
|
|
284
|
+
async loadImplementers() {
|
|
285
|
+
try {
|
|
286
|
+
const raw = await fs.readFile(this.implementersPath, "utf8");
|
|
287
|
+
return JSON.parse(raw);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const err = error;
|
|
291
|
+
if (err.code === "ENOENT")
|
|
292
|
+
return {};
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async saveImplementers(implementers) {
|
|
297
|
+
await fs.writeFile(this.implementersPath, JSON.stringify(implementers, null, 2), "utf8");
|
|
298
|
+
}
|
|
299
|
+
async registerImplementer(input) {
|
|
300
|
+
return this.withStateLock(async () => {
|
|
301
|
+
const implementers = await this.loadImplementers();
|
|
302
|
+
const implementer = {
|
|
303
|
+
id: crypto.randomUUID(),
|
|
304
|
+
name: input.name,
|
|
305
|
+
type: input.type,
|
|
306
|
+
projectRoot: input.projectRoot,
|
|
307
|
+
status: "active",
|
|
308
|
+
pid: input.pid,
|
|
309
|
+
isolation: input.isolation ?? "shared",
|
|
310
|
+
worktreePath: input.worktreePath,
|
|
311
|
+
branchName: input.branchName,
|
|
312
|
+
createdAt: nowIso(),
|
|
313
|
+
updatedAt: nowIso(),
|
|
314
|
+
};
|
|
315
|
+
implementers[implementer.id] = implementer;
|
|
316
|
+
await this.saveImplementers(implementers);
|
|
317
|
+
await appendLog(this.logDir, "implementer_register", { implementer });
|
|
318
|
+
return implementer;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async updateImplementer(id, status) {
|
|
322
|
+
return this.withStateLock(async () => {
|
|
323
|
+
const implementers = await this.loadImplementers();
|
|
324
|
+
const implementer = implementers[id];
|
|
325
|
+
if (!implementer)
|
|
326
|
+
throw new Error(`Implementer not found: ${id}`);
|
|
327
|
+
implementer.status = status;
|
|
328
|
+
implementer.updatedAt = nowIso();
|
|
329
|
+
await this.saveImplementers(implementers);
|
|
330
|
+
await appendLog(this.logDir, "implementer_update", { implementer });
|
|
331
|
+
return implementer;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async listImplementers(projectRoot) {
|
|
335
|
+
const implementers = await this.loadImplementers();
|
|
336
|
+
let list = Object.values(implementers);
|
|
337
|
+
if (projectRoot) {
|
|
338
|
+
list = list.filter((impl) => impl.projectRoot === projectRoot);
|
|
339
|
+
}
|
|
340
|
+
return list;
|
|
341
|
+
}
|
|
342
|
+
async resetImplementers(projectRoot) {
|
|
343
|
+
return this.withStateLock(async () => {
|
|
344
|
+
const implementers = await this.loadImplementers();
|
|
345
|
+
let count = 0;
|
|
346
|
+
for (const id of Object.keys(implementers)) {
|
|
347
|
+
const impl = implementers[id];
|
|
348
|
+
if (impl.projectRoot === projectRoot && impl.status === "active") {
|
|
349
|
+
impl.status = "stopped";
|
|
350
|
+
impl.updatedAt = nowIso();
|
|
351
|
+
count++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
await this.saveImplementers(implementers);
|
|
355
|
+
await appendLog(this.logDir, "implementers_reset", { projectRoot, count });
|
|
356
|
+
return count;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async resetSession(projectRoot, options) {
|
|
360
|
+
return this.withStateLock(async () => {
|
|
361
|
+
const state = await this.loadState();
|
|
362
|
+
// Count and clear tasks
|
|
363
|
+
const tasksCleared = state.tasks.length;
|
|
364
|
+
state.tasks = [];
|
|
365
|
+
// Count and clear locks
|
|
366
|
+
const locksCleared = state.locks.length;
|
|
367
|
+
state.locks = [];
|
|
368
|
+
// Count and clear notes
|
|
369
|
+
const notesCleared = state.notes.length;
|
|
370
|
+
state.notes = [];
|
|
371
|
+
await this.saveState(state);
|
|
372
|
+
// Reset implementers
|
|
373
|
+
const implementersReset = await this.resetImplementers(projectRoot);
|
|
374
|
+
// Clear project context unless keepProjectContext is true
|
|
375
|
+
if (!options?.keepProjectContext) {
|
|
376
|
+
const contexts = await this.loadContexts();
|
|
377
|
+
delete contexts[projectRoot];
|
|
378
|
+
await this.saveContexts(contexts);
|
|
379
|
+
}
|
|
380
|
+
await appendLog(this.logDir, "session_reset", {
|
|
381
|
+
projectRoot,
|
|
382
|
+
tasksCleared,
|
|
383
|
+
locksCleared,
|
|
384
|
+
notesCleared,
|
|
385
|
+
implementersReset,
|
|
386
|
+
discussionsArchived: 0
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
tasksCleared,
|
|
390
|
+
locksCleared,
|
|
391
|
+
notesCleared,
|
|
392
|
+
implementersReset,
|
|
393
|
+
discussionsArchived: 0 // JSON store doesn't support discussions
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Discussion methods - not implemented for JSON storage (use SQLite)
|
|
398
|
+
async createDiscussion() {
|
|
399
|
+
throw new Error("Discussion features require SQLite storage. Set storage: 'sqlite' in config.");
|
|
400
|
+
}
|
|
401
|
+
async replyToDiscussion() {
|
|
402
|
+
throw new Error("Discussion features require SQLite storage.");
|
|
403
|
+
}
|
|
404
|
+
async resolveDiscussion() {
|
|
405
|
+
throw new Error("Discussion features require SQLite storage.");
|
|
406
|
+
}
|
|
407
|
+
async getDiscussion() {
|
|
408
|
+
throw new Error("Discussion features require SQLite storage.");
|
|
409
|
+
}
|
|
410
|
+
async listDiscussions() {
|
|
411
|
+
throw new Error("Discussion features require SQLite storage.");
|
|
412
|
+
}
|
|
413
|
+
async archiveDiscussion() {
|
|
414
|
+
throw new Error("Discussion features require SQLite storage.");
|
|
415
|
+
}
|
|
416
|
+
async archiveOldDiscussions() {
|
|
417
|
+
throw new Error("Discussion features require SQLite storage.");
|
|
418
|
+
}
|
|
419
|
+
async deleteArchivedDiscussions() {
|
|
420
|
+
throw new Error("Discussion features require SQLite storage.");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
export class SqliteStore {
|
|
424
|
+
dbPath;
|
|
425
|
+
logDir;
|
|
426
|
+
db;
|
|
427
|
+
constructor(dbPath, logDir) {
|
|
428
|
+
this.dbPath = dbPath;
|
|
429
|
+
this.logDir = logDir;
|
|
430
|
+
}
|
|
431
|
+
async init() {
|
|
432
|
+
await ensureDir(path.dirname(this.dbPath));
|
|
433
|
+
await ensureDir(this.logDir);
|
|
434
|
+
this.db = new Database(this.dbPath);
|
|
435
|
+
this.db.pragma("journal_mode = WAL");
|
|
436
|
+
this.db.exec(`
|
|
437
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
438
|
+
id TEXT PRIMARY KEY,
|
|
439
|
+
title TEXT NOT NULL,
|
|
440
|
+
description TEXT,
|
|
441
|
+
status TEXT NOT NULL,
|
|
442
|
+
complexity TEXT NOT NULL DEFAULT 'medium',
|
|
443
|
+
isolation TEXT NOT NULL DEFAULT 'shared',
|
|
444
|
+
owner TEXT,
|
|
445
|
+
tags TEXT,
|
|
446
|
+
metadata TEXT,
|
|
447
|
+
review_notes TEXT,
|
|
448
|
+
review_feedback TEXT,
|
|
449
|
+
review_requested_at TEXT,
|
|
450
|
+
created_at TEXT NOT NULL,
|
|
451
|
+
updated_at TEXT NOT NULL
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
CREATE TABLE IF NOT EXISTS locks (
|
|
455
|
+
path TEXT PRIMARY KEY,
|
|
456
|
+
owner TEXT,
|
|
457
|
+
note TEXT,
|
|
458
|
+
status TEXT NOT NULL,
|
|
459
|
+
created_at TEXT NOT NULL,
|
|
460
|
+
updated_at TEXT NOT NULL
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
464
|
+
id TEXT PRIMARY KEY,
|
|
465
|
+
text TEXT NOT NULL,
|
|
466
|
+
author TEXT,
|
|
467
|
+
created_at TEXT NOT NULL
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
CREATE TABLE IF NOT EXISTS project_contexts (
|
|
471
|
+
project_root TEXT PRIMARY KEY,
|
|
472
|
+
description TEXT NOT NULL,
|
|
473
|
+
end_state TEXT NOT NULL,
|
|
474
|
+
tech_stack TEXT,
|
|
475
|
+
constraints TEXT,
|
|
476
|
+
acceptance_criteria TEXT,
|
|
477
|
+
tests TEXT,
|
|
478
|
+
implementation_plan TEXT,
|
|
479
|
+
status TEXT NOT NULL DEFAULT 'planning',
|
|
480
|
+
created_at TEXT NOT NULL,
|
|
481
|
+
updated_at TEXT NOT NULL
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
CREATE TABLE IF NOT EXISTS implementers (
|
|
485
|
+
id TEXT PRIMARY KEY,
|
|
486
|
+
name TEXT NOT NULL,
|
|
487
|
+
type TEXT NOT NULL,
|
|
488
|
+
project_root TEXT NOT NULL,
|
|
489
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
490
|
+
pid INTEGER,
|
|
491
|
+
isolation TEXT NOT NULL DEFAULT 'shared',
|
|
492
|
+
worktree_path TEXT,
|
|
493
|
+
branch_name TEXT,
|
|
494
|
+
created_at TEXT NOT NULL,
|
|
495
|
+
updated_at TEXT NOT NULL
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
CREATE TABLE IF NOT EXISTS discussions (
|
|
499
|
+
id TEXT PRIMARY KEY,
|
|
500
|
+
topic TEXT NOT NULL,
|
|
501
|
+
category TEXT NOT NULL DEFAULT 'other',
|
|
502
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
503
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
504
|
+
project_root TEXT NOT NULL,
|
|
505
|
+
created_by TEXT NOT NULL,
|
|
506
|
+
waiting_on TEXT,
|
|
507
|
+
decision TEXT,
|
|
508
|
+
decision_reasoning TEXT,
|
|
509
|
+
decided_by TEXT,
|
|
510
|
+
linked_task_id TEXT,
|
|
511
|
+
created_at TEXT NOT NULL,
|
|
512
|
+
updated_at TEXT NOT NULL,
|
|
513
|
+
resolved_at TEXT,
|
|
514
|
+
archived_at TEXT
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
CREATE TABLE IF NOT EXISTS discussion_messages (
|
|
518
|
+
id TEXT PRIMARY KEY,
|
|
519
|
+
discussion_id TEXT NOT NULL,
|
|
520
|
+
author TEXT NOT NULL,
|
|
521
|
+
message TEXT NOT NULL,
|
|
522
|
+
recommendation TEXT,
|
|
523
|
+
created_at TEXT NOT NULL,
|
|
524
|
+
FOREIGN KEY (discussion_id) REFERENCES discussions(id)
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
CREATE INDEX IF NOT EXISTS idx_discussions_status ON discussions(status);
|
|
528
|
+
CREATE INDEX IF NOT EXISTS idx_discussions_project ON discussions(project_root);
|
|
529
|
+
CREATE INDEX IF NOT EXISTS idx_discussion_messages_discussion ON discussion_messages(discussion_id);
|
|
530
|
+
`);
|
|
531
|
+
// Migration: add new columns if they don't exist
|
|
532
|
+
try {
|
|
533
|
+
this.db.exec("ALTER TABLE project_contexts ADD COLUMN acceptance_criteria TEXT");
|
|
534
|
+
}
|
|
535
|
+
catch { /* column exists */ }
|
|
536
|
+
try {
|
|
537
|
+
this.db.exec("ALTER TABLE project_contexts ADD COLUMN tests TEXT");
|
|
538
|
+
}
|
|
539
|
+
catch { /* column exists */ }
|
|
540
|
+
try {
|
|
541
|
+
this.db.exec("ALTER TABLE project_contexts ADD COLUMN implementation_plan TEXT");
|
|
542
|
+
}
|
|
543
|
+
catch { /* column exists */ }
|
|
544
|
+
try {
|
|
545
|
+
this.db.exec("ALTER TABLE project_contexts ADD COLUMN preferred_implementer TEXT");
|
|
546
|
+
}
|
|
547
|
+
catch { /* column exists */ }
|
|
548
|
+
try {
|
|
549
|
+
this.db.exec("ALTER TABLE project_contexts ADD COLUMN status TEXT NOT NULL DEFAULT 'planning'");
|
|
550
|
+
}
|
|
551
|
+
catch { /* column exists */ }
|
|
552
|
+
// Task review workflow columns
|
|
553
|
+
try {
|
|
554
|
+
this.db.exec("ALTER TABLE tasks ADD COLUMN complexity TEXT NOT NULL DEFAULT 'medium'");
|
|
555
|
+
}
|
|
556
|
+
catch { /* column exists */ }
|
|
557
|
+
try {
|
|
558
|
+
this.db.exec("ALTER TABLE tasks ADD COLUMN review_notes TEXT");
|
|
559
|
+
}
|
|
560
|
+
catch { /* column exists */ }
|
|
561
|
+
try {
|
|
562
|
+
this.db.exec("ALTER TABLE tasks ADD COLUMN review_feedback TEXT");
|
|
563
|
+
}
|
|
564
|
+
catch { /* column exists */ }
|
|
565
|
+
try {
|
|
566
|
+
this.db.exec("ALTER TABLE tasks ADD COLUMN review_requested_at TEXT");
|
|
567
|
+
}
|
|
568
|
+
catch { /* column exists */ }
|
|
569
|
+
// Worktree isolation columns
|
|
570
|
+
try {
|
|
571
|
+
this.db.exec("ALTER TABLE tasks ADD COLUMN isolation TEXT NOT NULL DEFAULT 'shared'");
|
|
572
|
+
}
|
|
573
|
+
catch { /* column exists */ }
|
|
574
|
+
try {
|
|
575
|
+
this.db.exec("ALTER TABLE implementers ADD COLUMN isolation TEXT NOT NULL DEFAULT 'shared'");
|
|
576
|
+
}
|
|
577
|
+
catch { /* column exists */ }
|
|
578
|
+
try {
|
|
579
|
+
this.db.exec("ALTER TABLE implementers ADD COLUMN worktree_path TEXT");
|
|
580
|
+
}
|
|
581
|
+
catch { /* column exists */ }
|
|
582
|
+
try {
|
|
583
|
+
this.db.exec("ALTER TABLE implementers ADD COLUMN branch_name TEXT");
|
|
584
|
+
}
|
|
585
|
+
catch { /* column exists */ }
|
|
586
|
+
}
|
|
587
|
+
getDb() {
|
|
588
|
+
if (!this.db)
|
|
589
|
+
throw new Error("Database not initialized");
|
|
590
|
+
return this.db;
|
|
591
|
+
}
|
|
592
|
+
parseTask(row) {
|
|
593
|
+
return {
|
|
594
|
+
id: row.id,
|
|
595
|
+
title: row.title,
|
|
596
|
+
description: row.description ?? undefined,
|
|
597
|
+
status: row.status,
|
|
598
|
+
complexity: row.complexity ?? "medium",
|
|
599
|
+
isolation: row.isolation ?? "shared",
|
|
600
|
+
owner: row.owner ?? undefined,
|
|
601
|
+
tags: row.tags ? JSON.parse(row.tags) : undefined,
|
|
602
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
603
|
+
reviewNotes: row.review_notes ?? undefined,
|
|
604
|
+
reviewFeedback: row.review_feedback ?? undefined,
|
|
605
|
+
reviewRequestedAt: row.review_requested_at ?? undefined,
|
|
606
|
+
createdAt: row.created_at,
|
|
607
|
+
updatedAt: row.updated_at,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
parseLock(row) {
|
|
611
|
+
return {
|
|
612
|
+
path: row.path,
|
|
613
|
+
owner: row.owner ?? undefined,
|
|
614
|
+
note: row.note ?? undefined,
|
|
615
|
+
status: row.status,
|
|
616
|
+
createdAt: row.created_at,
|
|
617
|
+
updatedAt: row.updated_at,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
parseNote(row) {
|
|
621
|
+
return {
|
|
622
|
+
id: row.id,
|
|
623
|
+
text: row.text,
|
|
624
|
+
author: row.author ?? undefined,
|
|
625
|
+
createdAt: row.created_at,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
async status() {
|
|
629
|
+
const db = this.getDb();
|
|
630
|
+
const tasks = db.prepare("SELECT * FROM tasks ORDER BY created_at ASC").all();
|
|
631
|
+
const locks = db.prepare("SELECT * FROM locks ORDER BY created_at ASC").all();
|
|
632
|
+
const notes = db.prepare("SELECT * FROM notes ORDER BY created_at ASC").all();
|
|
633
|
+
return {
|
|
634
|
+
tasks: tasks.map((row) => this.parseTask(row)),
|
|
635
|
+
locks: locks.map((row) => this.parseLock(row)),
|
|
636
|
+
notes: notes.map((row) => this.parseNote(row)),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
async createTask(input) {
|
|
640
|
+
const db = this.getDb();
|
|
641
|
+
const task = {
|
|
642
|
+
id: crypto.randomUUID(),
|
|
643
|
+
title: input.title,
|
|
644
|
+
description: input.description,
|
|
645
|
+
status: input.status ?? "todo",
|
|
646
|
+
complexity: input.complexity ?? "medium",
|
|
647
|
+
isolation: input.isolation ?? "shared",
|
|
648
|
+
owner: input.owner,
|
|
649
|
+
tags: input.tags,
|
|
650
|
+
metadata: input.metadata,
|
|
651
|
+
createdAt: nowIso(),
|
|
652
|
+
updatedAt: nowIso(),
|
|
653
|
+
};
|
|
654
|
+
db.prepare(`INSERT INTO tasks (id, title, description, status, complexity, isolation, owner, tags, metadata, created_at, updated_at)
|
|
655
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(task.id, task.title, task.description ?? null, task.status, task.complexity, task.isolation, task.owner ?? null, task.tags ? JSON.stringify(task.tags) : null, task.metadata ? JSON.stringify(task.metadata) : null, task.createdAt, task.updatedAt);
|
|
656
|
+
await appendLog(this.logDir, "task_create", { task });
|
|
657
|
+
return task;
|
|
658
|
+
}
|
|
659
|
+
async updateTask(input) {
|
|
660
|
+
const db = this.getDb();
|
|
661
|
+
const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(input.id);
|
|
662
|
+
if (!row)
|
|
663
|
+
throw new Error(`Task not found: ${input.id}`);
|
|
664
|
+
const task = this.parseTask(row);
|
|
665
|
+
if (input.title !== undefined)
|
|
666
|
+
task.title = input.title;
|
|
667
|
+
if (input.description !== undefined)
|
|
668
|
+
task.description = input.description;
|
|
669
|
+
if (input.status !== undefined)
|
|
670
|
+
task.status = input.status;
|
|
671
|
+
if (input.complexity !== undefined)
|
|
672
|
+
task.complexity = input.complexity;
|
|
673
|
+
if (input.isolation !== undefined)
|
|
674
|
+
task.isolation = input.isolation;
|
|
675
|
+
if (input.owner !== undefined)
|
|
676
|
+
task.owner = input.owner;
|
|
677
|
+
if (input.tags !== undefined)
|
|
678
|
+
task.tags = input.tags;
|
|
679
|
+
if (input.metadata !== undefined)
|
|
680
|
+
task.metadata = input.metadata;
|
|
681
|
+
if (input.reviewNotes !== undefined)
|
|
682
|
+
task.reviewNotes = input.reviewNotes;
|
|
683
|
+
if (input.reviewFeedback !== undefined)
|
|
684
|
+
task.reviewFeedback = input.reviewFeedback;
|
|
685
|
+
if (input.reviewRequestedAt !== undefined)
|
|
686
|
+
task.reviewRequestedAt = input.reviewRequestedAt;
|
|
687
|
+
task.updatedAt = nowIso();
|
|
688
|
+
db.prepare(`UPDATE tasks
|
|
689
|
+
SET title = ?, description = ?, status = ?, complexity = ?, isolation = ?, owner = ?, tags = ?, metadata = ?,
|
|
690
|
+
review_notes = ?, review_feedback = ?, review_requested_at = ?, updated_at = ?
|
|
691
|
+
WHERE id = ?`).run(task.title, task.description ?? null, task.status, task.complexity, task.isolation, task.owner ?? null, task.tags ? JSON.stringify(task.tags) : null, task.metadata ? JSON.stringify(task.metadata) : null, task.reviewNotes ?? null, task.reviewFeedback ?? null, task.reviewRequestedAt ?? null, task.updatedAt, task.id);
|
|
692
|
+
await appendLog(this.logDir, "task_update", { task });
|
|
693
|
+
return task;
|
|
694
|
+
}
|
|
695
|
+
async claimTask(input) {
|
|
696
|
+
return this.updateTask({ id: input.id, owner: input.owner, status: "in_progress" });
|
|
697
|
+
}
|
|
698
|
+
async submitTaskForReview(input) {
|
|
699
|
+
const db = this.getDb();
|
|
700
|
+
const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(input.id);
|
|
701
|
+
if (!row)
|
|
702
|
+
throw new Error(`Task not found: ${input.id}`);
|
|
703
|
+
if (row.owner !== input.owner) {
|
|
704
|
+
throw new Error(`Task owned by ${row.owner}, not ${input.owner}`);
|
|
705
|
+
}
|
|
706
|
+
return this.updateTask({
|
|
707
|
+
id: input.id,
|
|
708
|
+
status: "review",
|
|
709
|
+
reviewNotes: input.reviewNotes,
|
|
710
|
+
reviewRequestedAt: nowIso(),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
async approveTask(input) {
|
|
714
|
+
const db = this.getDb();
|
|
715
|
+
const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(input.id);
|
|
716
|
+
if (!row)
|
|
717
|
+
throw new Error(`Task not found: ${input.id}`);
|
|
718
|
+
if (row.status !== "review") {
|
|
719
|
+
throw new Error(`Task is not in review status (current: ${row.status})`);
|
|
720
|
+
}
|
|
721
|
+
return this.updateTask({
|
|
722
|
+
id: input.id,
|
|
723
|
+
status: "done",
|
|
724
|
+
reviewFeedback: input.feedback ?? "Approved",
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
async requestTaskChanges(input) {
|
|
728
|
+
const db = this.getDb();
|
|
729
|
+
const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(input.id);
|
|
730
|
+
if (!row)
|
|
731
|
+
throw new Error(`Task not found: ${input.id}`);
|
|
732
|
+
if (row.status !== "review") {
|
|
733
|
+
throw new Error(`Task is not in review status (current: ${row.status})`);
|
|
734
|
+
}
|
|
735
|
+
return this.updateTask({
|
|
736
|
+
id: input.id,
|
|
737
|
+
status: "in_progress", // Send back to in_progress for rework
|
|
738
|
+
reviewFeedback: input.feedback,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
async listTasks(filters) {
|
|
742
|
+
const db = this.getDb();
|
|
743
|
+
const where = [];
|
|
744
|
+
const params = [];
|
|
745
|
+
if (filters?.status) {
|
|
746
|
+
where.push("status = ?");
|
|
747
|
+
params.push(filters.status);
|
|
748
|
+
}
|
|
749
|
+
if (filters?.owner) {
|
|
750
|
+
where.push("owner = ?");
|
|
751
|
+
params.push(filters.owner);
|
|
752
|
+
}
|
|
753
|
+
const sql = `SELECT * FROM tasks${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at ASC`;
|
|
754
|
+
let tasks = db.prepare(sql).all(...params).map((row) => this.parseTask(row));
|
|
755
|
+
if (filters?.tag) {
|
|
756
|
+
tasks = tasks.filter((task) => task.tags?.includes(filters.tag ?? ""));
|
|
757
|
+
}
|
|
758
|
+
if (filters?.limit && filters.limit > 0)
|
|
759
|
+
tasks = tasks.slice(0, filters.limit);
|
|
760
|
+
return tasks;
|
|
761
|
+
}
|
|
762
|
+
async acquireLock(input) {
|
|
763
|
+
const db = this.getDb();
|
|
764
|
+
const lock = {
|
|
765
|
+
path: input.path,
|
|
766
|
+
owner: input.owner,
|
|
767
|
+
note: input.note,
|
|
768
|
+
status: "active",
|
|
769
|
+
createdAt: nowIso(),
|
|
770
|
+
updatedAt: nowIso(),
|
|
771
|
+
};
|
|
772
|
+
const transaction = db.transaction(() => {
|
|
773
|
+
const existing = db
|
|
774
|
+
.prepare("SELECT * FROM locks WHERE path = ? AND status = 'active'")
|
|
775
|
+
.get(input.path);
|
|
776
|
+
if (existing)
|
|
777
|
+
throw new Error(`Lock already active for ${input.path}`);
|
|
778
|
+
db.prepare(`INSERT INTO locks (path, owner, note, status, created_at, updated_at)
|
|
779
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(lock.path, lock.owner ?? null, lock.note ?? null, lock.status, lock.createdAt, lock.updatedAt);
|
|
780
|
+
});
|
|
781
|
+
transaction();
|
|
782
|
+
await appendLog(this.logDir, "lock_acquire", { lock });
|
|
783
|
+
return lock;
|
|
784
|
+
}
|
|
785
|
+
async releaseLock(input) {
|
|
786
|
+
const db = this.getDb();
|
|
787
|
+
const row = db
|
|
788
|
+
.prepare("SELECT * FROM locks WHERE path = ? AND status = 'active'")
|
|
789
|
+
.get(input.path);
|
|
790
|
+
if (!row)
|
|
791
|
+
throw new Error(`Active lock not found for ${input.path}`);
|
|
792
|
+
const lock = this.parseLock(row);
|
|
793
|
+
if (input.owner && lock.owner && input.owner !== lock.owner) {
|
|
794
|
+
throw new Error(`Lock owned by ${lock.owner}, not ${input.owner}`);
|
|
795
|
+
}
|
|
796
|
+
lock.status = "resolved";
|
|
797
|
+
lock.updatedAt = nowIso();
|
|
798
|
+
db.prepare("UPDATE locks SET status = ?, updated_at = ? WHERE path = ?").run(lock.status, lock.updatedAt, lock.path);
|
|
799
|
+
await appendLog(this.logDir, "lock_release", { lock });
|
|
800
|
+
return lock;
|
|
801
|
+
}
|
|
802
|
+
async listLocks(filters) {
|
|
803
|
+
const db = this.getDb();
|
|
804
|
+
const where = [];
|
|
805
|
+
const params = [];
|
|
806
|
+
if (filters?.status) {
|
|
807
|
+
where.push("status = ?");
|
|
808
|
+
params.push(filters.status);
|
|
809
|
+
}
|
|
810
|
+
if (filters?.owner) {
|
|
811
|
+
where.push("owner = ?");
|
|
812
|
+
params.push(filters.owner);
|
|
813
|
+
}
|
|
814
|
+
const sql = `SELECT * FROM locks${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at ASC`;
|
|
815
|
+
const locks = db.prepare(sql).all(...params);
|
|
816
|
+
return locks.map((row) => this.parseLock(row));
|
|
817
|
+
}
|
|
818
|
+
async appendNote(input) {
|
|
819
|
+
const db = this.getDb();
|
|
820
|
+
const note = {
|
|
821
|
+
id: crypto.randomUUID(),
|
|
822
|
+
text: input.text,
|
|
823
|
+
author: input.author,
|
|
824
|
+
createdAt: nowIso(),
|
|
825
|
+
};
|
|
826
|
+
db.prepare(`INSERT INTO notes (id, text, author, created_at)
|
|
827
|
+
VALUES (?, ?, ?, ?)`).run(note.id, note.text, note.author ?? null, note.createdAt);
|
|
828
|
+
await appendLog(this.logDir, "note_append", { note });
|
|
829
|
+
return note;
|
|
830
|
+
}
|
|
831
|
+
async listNotes(limit) {
|
|
832
|
+
const db = this.getDb();
|
|
833
|
+
const sql = "SELECT * FROM notes ORDER BY created_at ASC";
|
|
834
|
+
let notes = db.prepare(sql).all().map((row) => this.parseNote(row));
|
|
835
|
+
if (limit && limit > 0)
|
|
836
|
+
notes = notes.slice(Math.max(notes.length - limit, 0));
|
|
837
|
+
return notes;
|
|
838
|
+
}
|
|
839
|
+
async appendLogEntry(event, payload) {
|
|
840
|
+
await appendLog(this.logDir, event, payload ?? {});
|
|
841
|
+
}
|
|
842
|
+
parseProjectContext(row) {
|
|
843
|
+
return {
|
|
844
|
+
projectRoot: row.project_root,
|
|
845
|
+
description: row.description,
|
|
846
|
+
endState: row.end_state,
|
|
847
|
+
techStack: row.tech_stack ? JSON.parse(row.tech_stack) : undefined,
|
|
848
|
+
constraints: row.constraints ? JSON.parse(row.constraints) : undefined,
|
|
849
|
+
acceptanceCriteria: row.acceptance_criteria ? JSON.parse(row.acceptance_criteria) : undefined,
|
|
850
|
+
tests: row.tests ? JSON.parse(row.tests) : undefined,
|
|
851
|
+
implementationPlan: row.implementation_plan ? JSON.parse(row.implementation_plan) : undefined,
|
|
852
|
+
preferredImplementer: row.preferred_implementer,
|
|
853
|
+
status: row.status ?? "planning",
|
|
854
|
+
createdAt: row.created_at,
|
|
855
|
+
updatedAt: row.updated_at,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
parseImplementer(row) {
|
|
859
|
+
return {
|
|
860
|
+
id: row.id,
|
|
861
|
+
name: row.name,
|
|
862
|
+
type: row.type,
|
|
863
|
+
projectRoot: row.project_root,
|
|
864
|
+
status: row.status,
|
|
865
|
+
pid: row.pid ?? undefined,
|
|
866
|
+
isolation: row.isolation ?? "shared",
|
|
867
|
+
worktreePath: row.worktree_path ?? undefined,
|
|
868
|
+
branchName: row.branch_name ?? undefined,
|
|
869
|
+
createdAt: row.created_at,
|
|
870
|
+
updatedAt: row.updated_at,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
async setProjectContext(input) {
|
|
874
|
+
const db = this.getDb();
|
|
875
|
+
const existing = db
|
|
876
|
+
.prepare("SELECT * FROM project_contexts WHERE project_root = ?")
|
|
877
|
+
.get(input.projectRoot);
|
|
878
|
+
const context = {
|
|
879
|
+
projectRoot: input.projectRoot,
|
|
880
|
+
description: input.description,
|
|
881
|
+
endState: input.endState,
|
|
882
|
+
techStack: input.techStack,
|
|
883
|
+
constraints: input.constraints,
|
|
884
|
+
acceptanceCriteria: input.acceptanceCriteria,
|
|
885
|
+
tests: input.tests,
|
|
886
|
+
implementationPlan: input.implementationPlan,
|
|
887
|
+
preferredImplementer: input.preferredImplementer ?? existing?.preferred_implementer,
|
|
888
|
+
status: input.status ?? existing?.status ?? "planning",
|
|
889
|
+
createdAt: existing?.created_at ?? nowIso(),
|
|
890
|
+
updatedAt: nowIso(),
|
|
891
|
+
};
|
|
892
|
+
if (existing) {
|
|
893
|
+
db.prepare(`UPDATE project_contexts
|
|
894
|
+
SET description = ?, end_state = ?, tech_stack = ?, constraints = ?,
|
|
895
|
+
acceptance_criteria = ?, tests = ?, implementation_plan = ?, preferred_implementer = ?, status = ?, updated_at = ?
|
|
896
|
+
WHERE project_root = ?`).run(context.description, context.endState, context.techStack ? JSON.stringify(context.techStack) : null, context.constraints ? JSON.stringify(context.constraints) : null, context.acceptanceCriteria ? JSON.stringify(context.acceptanceCriteria) : null, context.tests ? JSON.stringify(context.tests) : null, context.implementationPlan ? JSON.stringify(context.implementationPlan) : null, context.preferredImplementer ?? null, context.status, context.updatedAt, context.projectRoot);
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
db.prepare(`INSERT INTO project_contexts (project_root, description, end_state, tech_stack, constraints,
|
|
900
|
+
acceptance_criteria, tests, implementation_plan, preferred_implementer, status, created_at, updated_at)
|
|
901
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(context.projectRoot, context.description, context.endState, context.techStack ? JSON.stringify(context.techStack) : null, context.constraints ? JSON.stringify(context.constraints) : null, context.acceptanceCriteria ? JSON.stringify(context.acceptanceCriteria) : null, context.tests ? JSON.stringify(context.tests) : null, context.implementationPlan ? JSON.stringify(context.implementationPlan) : null, context.preferredImplementer ?? null, context.status, context.createdAt, context.updatedAt);
|
|
902
|
+
}
|
|
903
|
+
await appendLog(this.logDir, "project_context_set", { context });
|
|
904
|
+
return context;
|
|
905
|
+
}
|
|
906
|
+
async getProjectContext(projectRoot) {
|
|
907
|
+
const db = this.getDb();
|
|
908
|
+
const row = db
|
|
909
|
+
.prepare("SELECT * FROM project_contexts WHERE project_root = ?")
|
|
910
|
+
.get(projectRoot);
|
|
911
|
+
return row ? this.parseProjectContext(row) : null;
|
|
912
|
+
}
|
|
913
|
+
async listAllProjectContexts() {
|
|
914
|
+
const db = this.getDb();
|
|
915
|
+
const rows = db
|
|
916
|
+
.prepare("SELECT * FROM project_contexts ORDER BY updated_at DESC")
|
|
917
|
+
.all();
|
|
918
|
+
return rows.map((row) => this.parseProjectContext(row));
|
|
919
|
+
}
|
|
920
|
+
async updateProjectStatus(projectRoot, status) {
|
|
921
|
+
const db = this.getDb();
|
|
922
|
+
const row = db
|
|
923
|
+
.prepare("SELECT * FROM project_contexts WHERE project_root = ?")
|
|
924
|
+
.get(projectRoot);
|
|
925
|
+
if (!row)
|
|
926
|
+
throw new Error(`Project context not found: ${projectRoot}`);
|
|
927
|
+
const updatedAt = nowIso();
|
|
928
|
+
db.prepare("UPDATE project_contexts SET status = ?, updated_at = ? WHERE project_root = ?")
|
|
929
|
+
.run(status, updatedAt, projectRoot);
|
|
930
|
+
await appendLog(this.logDir, "project_status_update", { projectRoot, status });
|
|
931
|
+
const updated = db
|
|
932
|
+
.prepare("SELECT * FROM project_contexts WHERE project_root = ?")
|
|
933
|
+
.get(projectRoot);
|
|
934
|
+
return this.parseProjectContext(updated);
|
|
935
|
+
}
|
|
936
|
+
async registerImplementer(input) {
|
|
937
|
+
const db = this.getDb();
|
|
938
|
+
const implementer = {
|
|
939
|
+
id: crypto.randomUUID(),
|
|
940
|
+
name: input.name,
|
|
941
|
+
type: input.type,
|
|
942
|
+
projectRoot: input.projectRoot,
|
|
943
|
+
status: "active",
|
|
944
|
+
pid: input.pid,
|
|
945
|
+
isolation: input.isolation ?? "shared",
|
|
946
|
+
worktreePath: input.worktreePath,
|
|
947
|
+
branchName: input.branchName,
|
|
948
|
+
createdAt: nowIso(),
|
|
949
|
+
updatedAt: nowIso(),
|
|
950
|
+
};
|
|
951
|
+
db.prepare(`INSERT INTO implementers (id, name, type, project_root, status, pid, isolation, worktree_path, branch_name, created_at, updated_at)
|
|
952
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(implementer.id, implementer.name, implementer.type, implementer.projectRoot, implementer.status, implementer.pid ?? null, implementer.isolation, implementer.worktreePath ?? null, implementer.branchName ?? null, implementer.createdAt, implementer.updatedAt);
|
|
953
|
+
await appendLog(this.logDir, "implementer_register", { implementer });
|
|
954
|
+
return implementer;
|
|
955
|
+
}
|
|
956
|
+
async updateImplementer(id, status) {
|
|
957
|
+
const db = this.getDb();
|
|
958
|
+
const row = db.prepare("SELECT * FROM implementers WHERE id = ?").get(id);
|
|
959
|
+
if (!row)
|
|
960
|
+
throw new Error(`Implementer not found: ${id}`);
|
|
961
|
+
const updatedAt = nowIso();
|
|
962
|
+
db.prepare("UPDATE implementers SET status = ?, updated_at = ? WHERE id = ?")
|
|
963
|
+
.run(status, updatedAt, id);
|
|
964
|
+
await appendLog(this.logDir, "implementer_update", { id, status });
|
|
965
|
+
const updated = db.prepare("SELECT * FROM implementers WHERE id = ?").get(id);
|
|
966
|
+
return this.parseImplementer(updated);
|
|
967
|
+
}
|
|
968
|
+
async listImplementers(projectRoot) {
|
|
969
|
+
const db = this.getDb();
|
|
970
|
+
let rows;
|
|
971
|
+
if (projectRoot) {
|
|
972
|
+
rows = db.prepare("SELECT * FROM implementers WHERE project_root = ? ORDER BY created_at ASC")
|
|
973
|
+
.all(projectRoot);
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
rows = db.prepare("SELECT * FROM implementers ORDER BY created_at ASC").all();
|
|
977
|
+
}
|
|
978
|
+
return rows.map((row) => this.parseImplementer(row));
|
|
979
|
+
}
|
|
980
|
+
async resetImplementers(projectRoot) {
|
|
981
|
+
const db = this.getDb();
|
|
982
|
+
const updatedAt = nowIso();
|
|
983
|
+
const result = db.prepare("UPDATE implementers SET status = 'stopped', updated_at = ? WHERE project_root = ? AND status = 'active'").run(updatedAt, projectRoot);
|
|
984
|
+
await appendLog(this.logDir, "implementers_reset", { projectRoot, count: result.changes });
|
|
985
|
+
return result.changes;
|
|
986
|
+
}
|
|
987
|
+
async resetSession(projectRoot, options) {
|
|
988
|
+
const db = this.getDb();
|
|
989
|
+
// Start transaction for atomic reset
|
|
990
|
+
const resetTransaction = db.transaction(() => {
|
|
991
|
+
// Count and clear tasks
|
|
992
|
+
const taskCount = db.prepare("SELECT COUNT(*) as count FROM tasks").get();
|
|
993
|
+
const tasksCleared = taskCount.count;
|
|
994
|
+
db.prepare("DELETE FROM tasks").run();
|
|
995
|
+
// Count and clear active locks (keep resolved for history if needed)
|
|
996
|
+
const lockCount = db.prepare("SELECT COUNT(*) as count FROM locks WHERE status = 'active'").get();
|
|
997
|
+
const locksCleared = lockCount.count;
|
|
998
|
+
db.prepare("UPDATE locks SET status = 'resolved', updated_at = ? WHERE status = 'active'").run(nowIso());
|
|
999
|
+
// Also delete all locks if we want a clean slate
|
|
1000
|
+
db.prepare("DELETE FROM locks").run();
|
|
1001
|
+
// Count and clear notes
|
|
1002
|
+
const noteCount = db.prepare("SELECT COUNT(*) as count FROM notes").get();
|
|
1003
|
+
const notesCleared = noteCount.count;
|
|
1004
|
+
db.prepare("DELETE FROM notes").run();
|
|
1005
|
+
// Reset implementers
|
|
1006
|
+
const updatedAt = nowIso();
|
|
1007
|
+
const implResult = db.prepare("UPDATE implementers SET status = 'stopped', updated_at = ? WHERE project_root = ? AND status = 'active'").run(updatedAt, projectRoot);
|
|
1008
|
+
const implementersReset = implResult.changes;
|
|
1009
|
+
// Archive open discussions
|
|
1010
|
+
const discussionResult = db.prepare("UPDATE discussions SET status = 'archived', archived_at = ?, updated_at = ? WHERE project_root = ? AND status IN ('open', 'waiting')").run(nowIso(), nowIso(), projectRoot);
|
|
1011
|
+
const discussionsArchived = discussionResult.changes;
|
|
1012
|
+
// Clear project context unless keepProjectContext is true
|
|
1013
|
+
if (!options?.keepProjectContext) {
|
|
1014
|
+
db.prepare("DELETE FROM project_contexts WHERE project_root = ?").run(projectRoot);
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
// Reset status to planning if keeping context
|
|
1018
|
+
db.prepare("UPDATE project_contexts SET status = 'planning', updated_at = ? WHERE project_root = ?")
|
|
1019
|
+
.run(nowIso(), projectRoot);
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
tasksCleared,
|
|
1023
|
+
locksCleared,
|
|
1024
|
+
notesCleared,
|
|
1025
|
+
implementersReset,
|
|
1026
|
+
discussionsArchived
|
|
1027
|
+
};
|
|
1028
|
+
});
|
|
1029
|
+
const result = resetTransaction();
|
|
1030
|
+
await appendLog(this.logDir, "session_reset", {
|
|
1031
|
+
projectRoot,
|
|
1032
|
+
...result
|
|
1033
|
+
});
|
|
1034
|
+
return result;
|
|
1035
|
+
}
|
|
1036
|
+
// Discussion methods
|
|
1037
|
+
parseDiscussion(row) {
|
|
1038
|
+
return {
|
|
1039
|
+
id: row.id,
|
|
1040
|
+
topic: row.topic,
|
|
1041
|
+
category: row.category,
|
|
1042
|
+
priority: row.priority,
|
|
1043
|
+
status: row.status,
|
|
1044
|
+
projectRoot: row.project_root,
|
|
1045
|
+
createdBy: row.created_by,
|
|
1046
|
+
waitingOn: row.waiting_on ?? undefined,
|
|
1047
|
+
decision: row.decision ?? undefined,
|
|
1048
|
+
decisionReasoning: row.decision_reasoning ?? undefined,
|
|
1049
|
+
decidedBy: row.decided_by ?? undefined,
|
|
1050
|
+
linkedTaskId: row.linked_task_id ?? undefined,
|
|
1051
|
+
createdAt: row.created_at,
|
|
1052
|
+
updatedAt: row.updated_at,
|
|
1053
|
+
resolvedAt: row.resolved_at ?? undefined,
|
|
1054
|
+
archivedAt: row.archived_at ?? undefined,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
parseDiscussionMessage(row) {
|
|
1058
|
+
return {
|
|
1059
|
+
id: row.id,
|
|
1060
|
+
discussionId: row.discussion_id,
|
|
1061
|
+
author: row.author,
|
|
1062
|
+
message: row.message,
|
|
1063
|
+
recommendation: row.recommendation ?? undefined,
|
|
1064
|
+
createdAt: row.created_at,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
async createDiscussion(input) {
|
|
1068
|
+
const db = this.getDb();
|
|
1069
|
+
const now = nowIso();
|
|
1070
|
+
const discussion = {
|
|
1071
|
+
id: crypto.randomUUID(),
|
|
1072
|
+
topic: input.topic,
|
|
1073
|
+
category: input.category,
|
|
1074
|
+
priority: input.priority,
|
|
1075
|
+
status: input.waitingOn ? "waiting" : "open",
|
|
1076
|
+
projectRoot: input.projectRoot,
|
|
1077
|
+
createdBy: input.createdBy,
|
|
1078
|
+
waitingOn: input.waitingOn,
|
|
1079
|
+
createdAt: now,
|
|
1080
|
+
updatedAt: now,
|
|
1081
|
+
};
|
|
1082
|
+
const msg = {
|
|
1083
|
+
id: crypto.randomUUID(),
|
|
1084
|
+
discussionId: discussion.id,
|
|
1085
|
+
author: input.createdBy,
|
|
1086
|
+
message: input.message,
|
|
1087
|
+
createdAt: now,
|
|
1088
|
+
};
|
|
1089
|
+
db.prepare(`INSERT INTO discussions (id, topic, category, priority, status, project_root, created_by, waiting_on, created_at, updated_at)
|
|
1090
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(discussion.id, discussion.topic, discussion.category, discussion.priority, discussion.status, discussion.projectRoot, discussion.createdBy, discussion.waitingOn ?? null, discussion.createdAt, discussion.updatedAt);
|
|
1091
|
+
db.prepare(`INSERT INTO discussion_messages (id, discussion_id, author, message, recommendation, created_at)
|
|
1092
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(msg.id, msg.discussionId, msg.author, msg.message, null, msg.createdAt);
|
|
1093
|
+
await appendLog(this.logDir, "discussion_create", { discussion, message: msg });
|
|
1094
|
+
return { discussion, message: msg };
|
|
1095
|
+
}
|
|
1096
|
+
async replyToDiscussion(input) {
|
|
1097
|
+
const db = this.getDb();
|
|
1098
|
+
const now = nowIso();
|
|
1099
|
+
const row = db.prepare("SELECT * FROM discussions WHERE id = ?").get(input.discussionId);
|
|
1100
|
+
if (!row)
|
|
1101
|
+
throw new Error(`Discussion not found: ${input.discussionId}`);
|
|
1102
|
+
if (row.status === "resolved" || row.status === "archived") {
|
|
1103
|
+
throw new Error(`Cannot reply to ${row.status} discussion`);
|
|
1104
|
+
}
|
|
1105
|
+
const msg = {
|
|
1106
|
+
id: crypto.randomUUID(),
|
|
1107
|
+
discussionId: input.discussionId,
|
|
1108
|
+
author: input.author,
|
|
1109
|
+
message: input.message,
|
|
1110
|
+
recommendation: input.recommendation,
|
|
1111
|
+
createdAt: now,
|
|
1112
|
+
};
|
|
1113
|
+
db.prepare(`INSERT INTO discussion_messages (id, discussion_id, author, message, recommendation, created_at)
|
|
1114
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(msg.id, msg.discussionId, msg.author, msg.message, msg.recommendation ?? null, msg.createdAt);
|
|
1115
|
+
// Update discussion status and waiting_on
|
|
1116
|
+
const newStatus = input.waitingOn ? "waiting" : "open";
|
|
1117
|
+
db.prepare(`UPDATE discussions SET status = ?, waiting_on = ?, updated_at = ? WHERE id = ?`).run(newStatus, input.waitingOn ?? null, now, input.discussionId);
|
|
1118
|
+
const updated = db.prepare("SELECT * FROM discussions WHERE id = ?").get(input.discussionId);
|
|
1119
|
+
await appendLog(this.logDir, "discussion_reply", { discussion: this.parseDiscussion(updated), message: msg });
|
|
1120
|
+
return { discussion: this.parseDiscussion(updated), message: msg };
|
|
1121
|
+
}
|
|
1122
|
+
async resolveDiscussion(input) {
|
|
1123
|
+
const db = this.getDb();
|
|
1124
|
+
const now = nowIso();
|
|
1125
|
+
const row = db.prepare("SELECT * FROM discussions WHERE id = ?").get(input.discussionId);
|
|
1126
|
+
if (!row)
|
|
1127
|
+
throw new Error(`Discussion not found: ${input.discussionId}`);
|
|
1128
|
+
db.prepare(`UPDATE discussions
|
|
1129
|
+
SET status = 'resolved', decision = ?, decision_reasoning = ?, decided_by = ?,
|
|
1130
|
+
linked_task_id = ?, waiting_on = NULL, resolved_at = ?, updated_at = ?
|
|
1131
|
+
WHERE id = ?`).run(input.decision, input.reasoning, input.decidedBy, input.linkedTaskId ?? null, now, now, input.discussionId);
|
|
1132
|
+
const updated = db.prepare("SELECT * FROM discussions WHERE id = ?").get(input.discussionId);
|
|
1133
|
+
const discussion = this.parseDiscussion(updated);
|
|
1134
|
+
await appendLog(this.logDir, "discussion_resolve", { discussion });
|
|
1135
|
+
return discussion;
|
|
1136
|
+
}
|
|
1137
|
+
async getDiscussion(id) {
|
|
1138
|
+
const db = this.getDb();
|
|
1139
|
+
const row = db.prepare("SELECT * FROM discussions WHERE id = ?").get(id);
|
|
1140
|
+
if (!row)
|
|
1141
|
+
return null;
|
|
1142
|
+
const msgRows = db.prepare("SELECT * FROM discussion_messages WHERE discussion_id = ? ORDER BY created_at ASC")
|
|
1143
|
+
.all(id);
|
|
1144
|
+
return {
|
|
1145
|
+
discussion: this.parseDiscussion(row),
|
|
1146
|
+
messages: msgRows.map((r) => this.parseDiscussionMessage(r)),
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
async listDiscussions(filters) {
|
|
1150
|
+
const db = this.getDb();
|
|
1151
|
+
const where = [];
|
|
1152
|
+
const params = [];
|
|
1153
|
+
if (filters?.status) {
|
|
1154
|
+
where.push("status = ?");
|
|
1155
|
+
params.push(filters.status);
|
|
1156
|
+
}
|
|
1157
|
+
if (filters?.category) {
|
|
1158
|
+
where.push("category = ?");
|
|
1159
|
+
params.push(filters.category);
|
|
1160
|
+
}
|
|
1161
|
+
if (filters?.projectRoot) {
|
|
1162
|
+
where.push("project_root = ?");
|
|
1163
|
+
params.push(filters.projectRoot);
|
|
1164
|
+
}
|
|
1165
|
+
if (filters?.waitingOn) {
|
|
1166
|
+
where.push("waiting_on = ?");
|
|
1167
|
+
params.push(filters.waitingOn);
|
|
1168
|
+
}
|
|
1169
|
+
let sql = `SELECT * FROM discussions${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY
|
|
1170
|
+
CASE priority WHEN 'blocking' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
|
|
1171
|
+
created_at DESC`;
|
|
1172
|
+
if (filters?.limit && filters.limit > 0) {
|
|
1173
|
+
sql += ` LIMIT ${filters.limit}`;
|
|
1174
|
+
}
|
|
1175
|
+
const rows = db.prepare(sql).all(...params);
|
|
1176
|
+
return rows.map((row) => this.parseDiscussion(row));
|
|
1177
|
+
}
|
|
1178
|
+
async archiveDiscussion(id) {
|
|
1179
|
+
const db = this.getDb();
|
|
1180
|
+
const now = nowIso();
|
|
1181
|
+
const row = db.prepare("SELECT * FROM discussions WHERE id = ?").get(id);
|
|
1182
|
+
if (!row)
|
|
1183
|
+
throw new Error(`Discussion not found: ${id}`);
|
|
1184
|
+
db.prepare(`UPDATE discussions SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?`).run(now, now, id);
|
|
1185
|
+
const updated = db.prepare("SELECT * FROM discussions WHERE id = ?").get(id);
|
|
1186
|
+
const discussion = this.parseDiscussion(updated);
|
|
1187
|
+
await appendLog(this.logDir, "discussion_archive", { discussion });
|
|
1188
|
+
return discussion;
|
|
1189
|
+
}
|
|
1190
|
+
async archiveOldDiscussions(options) {
|
|
1191
|
+
const db = this.getDb();
|
|
1192
|
+
const days = options.olderThanDays ?? 7;
|
|
1193
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
1194
|
+
const now = nowIso();
|
|
1195
|
+
let sql = `UPDATE discussions SET status = 'archived', archived_at = ?, updated_at = ?
|
|
1196
|
+
WHERE status = 'resolved' AND resolved_at < ?`;
|
|
1197
|
+
const params = [now, now, cutoff];
|
|
1198
|
+
if (options.projectRoot) {
|
|
1199
|
+
sql += " AND project_root = ?";
|
|
1200
|
+
params.push(options.projectRoot);
|
|
1201
|
+
}
|
|
1202
|
+
const result = db.prepare(sql).run(...params);
|
|
1203
|
+
await appendLog(this.logDir, "discussions_bulk_archive", { count: result.changes, olderThanDays: days });
|
|
1204
|
+
return result.changes;
|
|
1205
|
+
}
|
|
1206
|
+
async deleteArchivedDiscussions(options) {
|
|
1207
|
+
const db = this.getDb();
|
|
1208
|
+
const days = options.olderThanDays ?? 30;
|
|
1209
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
1210
|
+
// First get IDs to delete
|
|
1211
|
+
let selectSql = `SELECT id FROM discussions WHERE status = 'archived' AND archived_at < ?`;
|
|
1212
|
+
const selectParams = [cutoff];
|
|
1213
|
+
if (options.projectRoot) {
|
|
1214
|
+
selectSql += " AND project_root = ?";
|
|
1215
|
+
selectParams.push(options.projectRoot);
|
|
1216
|
+
}
|
|
1217
|
+
const ids = db.prepare(selectSql).all(...selectParams);
|
|
1218
|
+
if (ids.length === 0)
|
|
1219
|
+
return 0;
|
|
1220
|
+
const idList = ids.map((r) => r.id);
|
|
1221
|
+
// Delete messages first (foreign key)
|
|
1222
|
+
const placeholders = idList.map(() => "?").join(",");
|
|
1223
|
+
db.prepare(`DELETE FROM discussion_messages WHERE discussion_id IN (${placeholders})`).run(...idList);
|
|
1224
|
+
// Delete discussions
|
|
1225
|
+
const result = db.prepare(`DELETE FROM discussions WHERE id IN (${placeholders})`).run(...idList);
|
|
1226
|
+
await appendLog(this.logDir, "discussions_bulk_delete", { count: result.changes, olderThanDays: days });
|
|
1227
|
+
return result.changes;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
export function createStore(config) {
|
|
1231
|
+
if (config.storage === "json") {
|
|
1232
|
+
return new JsonStore(config.dataDir, config.logDir);
|
|
1233
|
+
}
|
|
1234
|
+
return new SqliteStore(config.dbPath, config.logDir);
|
|
1235
|
+
}
|