opencode-planpilot 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/src/lib/app.ts ADDED
@@ -0,0 +1,1072 @@
1
+ import type { DatabaseConnection } from "./db"
2
+ import {
3
+ createEmptyStatusChanges,
4
+ mergeStatusChanges,
5
+ type ActivePlanRow,
6
+ type GoalChanges,
7
+ type GoalDetail,
8
+ type GoalQuery,
9
+ type GoalRow,
10
+ type GoalStatus,
11
+ type PlanChanges,
12
+ type PlanDetail,
13
+ type PlanOrder,
14
+ type PlanRow,
15
+ type PlanStatus,
16
+ type StatusChanges,
17
+ type StepChanges,
18
+ type StepDetail,
19
+ type StepExecutor,
20
+ type StepInput,
21
+ type StepQuery,
22
+ type StepRow,
23
+ type StepStatus,
24
+ } from "./models"
25
+ import { ensureNonEmpty, joinIds, normalizeCommentEntries, uniqueIds } from "./util"
26
+ import { invalidInput, notFound } from "./errors"
27
+ import { formatStepDetail } from "./format"
28
+
29
+ export class PlanpilotApp {
30
+ private db: DatabaseConnection
31
+ private sessionId: string
32
+ private cwd?: string
33
+
34
+ constructor(db: DatabaseConnection, sessionId: string, cwd?: string) {
35
+ this.db = db
36
+ this.sessionId = sessionId
37
+ this.cwd = cwd
38
+ }
39
+
40
+ addPlan(input: { title: string; content: string }): PlanRow {
41
+ ensureNonEmpty("plan title", input.title)
42
+ ensureNonEmpty("plan content", input.content)
43
+ const now = Date.now()
44
+ const result = this.db
45
+ .prepare(
46
+ `INSERT INTO plans (title, content, status, comment, last_session_id, last_cwd, created_at, updated_at)
47
+ VALUES (?, ?, ?, NULL, ?, ?, ?, ?)`
48
+ )
49
+ .run(input.title, input.content, "todo", this.sessionId, this.cwd ?? null, now, now)
50
+ const plan = this.getPlan(result.lastInsertRowid as number)
51
+ return plan
52
+ }
53
+
54
+ addPlanTree(input: { title: string; content: string }, steps: StepInput[]): { plan: PlanRow; stepCount: number; goalCount: number } {
55
+ ensureNonEmpty("plan title", input.title)
56
+ ensureNonEmpty("plan content", input.content)
57
+ steps.forEach((step) => {
58
+ ensureNonEmpty("step content", step.content)
59
+ step.goals.forEach((goal) => ensureNonEmpty("goal content", goal))
60
+ })
61
+
62
+ const tx = this.db.transaction(() => {
63
+ const now = Date.now()
64
+ const planResult = this.db
65
+ .prepare(
66
+ `INSERT INTO plans (title, content, status, comment, last_session_id, last_cwd, created_at, updated_at)
67
+ VALUES (?, ?, ?, NULL, ?, ?, ?, ?)`
68
+ )
69
+ .run(input.title, input.content, "todo", this.sessionId, this.cwd ?? null, now, now)
70
+ const plan = this.getPlan(planResult.lastInsertRowid as number)
71
+
72
+ let stepCount = 0
73
+ let goalCount = 0
74
+ steps.forEach((step, idx) => {
75
+ const stepResult = this.db
76
+ .prepare(
77
+ `INSERT INTO steps (plan_id, content, status, executor, sort_order, comment, created_at, updated_at)
78
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`
79
+ )
80
+ .run(plan.id, step.content, "todo", step.executor, idx + 1, now, now)
81
+ const stepId = stepResult.lastInsertRowid as number
82
+ stepCount += 1
83
+ step.goals.forEach((goal) => {
84
+ this.db
85
+ .prepare(
86
+ `INSERT INTO goals (step_id, content, status, comment, created_at, updated_at)
87
+ VALUES (?, ?, ?, NULL, ?, ?)`
88
+ )
89
+ .run(stepId, goal, "todo", now, now)
90
+ goalCount += 1
91
+ })
92
+ })
93
+
94
+ return { plan, stepCount, goalCount }
95
+ })
96
+
97
+ return tx()
98
+ }
99
+
100
+ listPlans(order?: PlanOrder | null, desc?: boolean): PlanRow[] {
101
+ const orderBy = order ?? "updated"
102
+ const direction = desc ? "DESC" : "ASC"
103
+ const orderColumn =
104
+ orderBy === "id" ? "id" : orderBy === "title" ? "title" : orderBy === "created" ? "created_at" : "updated_at"
105
+
106
+ return this.db
107
+ .prepare(`SELECT * FROM plans ORDER BY ${orderColumn} ${direction}, id ASC`)
108
+ .all() as PlanRow[]
109
+ }
110
+
111
+ getPlan(id: number): PlanRow {
112
+ const row = this.db.prepare("SELECT * FROM plans WHERE id = ?").get(id) as PlanRow | undefined
113
+ if (!row) throw notFound(`plan id ${id}`)
114
+ return row
115
+ }
116
+
117
+ getStep(id: number): StepRow {
118
+ const row = this.db.prepare("SELECT * FROM steps WHERE id = ?").get(id) as StepRow | undefined
119
+ if (!row) throw notFound(`step id ${id}`)
120
+ return row
121
+ }
122
+
123
+ getGoal(id: number): GoalRow {
124
+ const row = this.db.prepare("SELECT * FROM goals WHERE id = ?").get(id) as GoalRow | undefined
125
+ if (!row) throw notFound(`goal id ${id}`)
126
+ return row
127
+ }
128
+
129
+ planWithSteps(id: number): { plan: PlanRow; steps: StepRow[] } {
130
+ const plan = this.getPlan(id)
131
+ const steps = this.db
132
+ .prepare("SELECT * FROM steps WHERE plan_id = ? ORDER BY sort_order ASC, id ASC")
133
+ .all(id) as StepRow[]
134
+ return { plan, steps }
135
+ }
136
+
137
+ getPlanDetail(id: number): PlanDetail {
138
+ const plan = this.getPlan(id)
139
+ const steps = this.db
140
+ .prepare("SELECT * FROM steps WHERE plan_id = ? ORDER BY sort_order ASC, id ASC")
141
+ .all(id) as StepRow[]
142
+ const stepIds = steps.map((step) => step.id)
143
+ const goals = this.goalsForSteps(stepIds)
144
+ const goalsMap = new Map<number, GoalRow[]>()
145
+ for (const step of steps) {
146
+ goalsMap.set(step.id, goals.get(step.id) ?? [])
147
+ }
148
+ return { plan, steps, goals: goalsMap }
149
+ }
150
+
151
+ getStepDetail(id: number): StepDetail {
152
+ const step = this.getStep(id)
153
+ const goals = this.goalsForStep(step.id)
154
+ return { step, goals }
155
+ }
156
+
157
+ getGoalDetail(id: number): GoalDetail {
158
+ const goal = this.getGoal(id)
159
+ const step = this.getStep(goal.step_id)
160
+ return { goal, step }
161
+ }
162
+
163
+ getPlanDetails(plans: PlanRow[]): PlanDetail[] {
164
+ if (!plans.length) return []
165
+ const planIds = plans.map((plan) => plan.id)
166
+ const steps = this.db
167
+ .prepare(`SELECT * FROM steps WHERE plan_id IN (${planIds.map(() => "?").join(",")}) ORDER BY plan_id ASC, sort_order ASC, id ASC`)
168
+ .all(...planIds) as StepRow[]
169
+ const stepIds = steps.map((step) => step.id)
170
+ const goalsByStep = this.goalsForSteps(stepIds)
171
+
172
+ const stepsByPlan = new Map<number, StepRow[]>()
173
+ for (const step of steps) {
174
+ const list = stepsByPlan.get(step.plan_id)
175
+ if (list) list.push(step)
176
+ else stepsByPlan.set(step.plan_id, [step])
177
+ }
178
+
179
+ return plans.map((plan) => {
180
+ const planSteps = stepsByPlan.get(plan.id) ?? []
181
+ const goalsMap = new Map<number, GoalRow[]>()
182
+ for (const step of planSteps) {
183
+ goalsMap.set(step.id, goalsByStep.get(step.id) ?? [])
184
+ }
185
+ return { plan, steps: planSteps, goals: goalsMap }
186
+ })
187
+ }
188
+
189
+ getStepsDetail(steps: StepRow[]): StepDetail[] {
190
+ if (!steps.length) return []
191
+ const stepIds = steps.map((step) => step.id)
192
+ const goalsMap = this.goalsForSteps(stepIds)
193
+ return steps.map((step) => ({
194
+ step,
195
+ goals: goalsMap.get(step.id) ?? [],
196
+ }))
197
+ }
198
+
199
+ getActivePlan(): ActivePlanRow | null {
200
+ const row = this.db
201
+ .prepare("SELECT * FROM active_plan WHERE session_id = ?")
202
+ .get(this.sessionId) as ActivePlanRow | undefined
203
+ return row ?? null
204
+ }
205
+
206
+ setActivePlan(planId: number, takeover: boolean): ActivePlanRow {
207
+ this.getPlan(planId)
208
+ const tx = this.db.transaction(() => {
209
+ const existing = this.db
210
+ .prepare("SELECT * FROM active_plan WHERE plan_id = ?")
211
+ .get(planId) as ActivePlanRow | undefined
212
+ if (existing && existing.session_id !== this.sessionId && !takeover) {
213
+ throw invalidInput(
214
+ `plan id ${planId} is already active in session ${existing.session_id} (use --force to take over)`
215
+ )
216
+ }
217
+
218
+ this.db.prepare("DELETE FROM active_plan WHERE session_id = ?").run(this.sessionId)
219
+ this.db.prepare("DELETE FROM active_plan WHERE plan_id = ?").run(planId)
220
+
221
+ const now = Date.now()
222
+ this.db
223
+ .prepare("INSERT INTO active_plan (session_id, plan_id, updated_at) VALUES (?, ?, ?)")
224
+ .run(this.sessionId, planId, now)
225
+
226
+ this.touchPlan(planId)
227
+
228
+ const created = this.db
229
+ .prepare("SELECT * FROM active_plan WHERE session_id = ?")
230
+ .get(this.sessionId) as ActivePlanRow | undefined
231
+ if (!created) throw notFound("active plan not found after insert")
232
+ return created
233
+ })
234
+
235
+ return tx()
236
+ }
237
+
238
+ clearActivePlan() {
239
+ this.db.prepare("DELETE FROM active_plan WHERE session_id = ?").run(this.sessionId)
240
+ }
241
+
242
+ updatePlanWithActiveClear(id: number, changes: PlanChanges): { plan: PlanRow; cleared: boolean } {
243
+ const tx = this.db.transaction(() => {
244
+ const plan = this.updatePlanWithConn(id, changes)
245
+ let cleared = false
246
+ if (plan.status === "done") {
247
+ cleared = this.clearActivePlansForPlanWithConn(plan.id)
248
+ }
249
+ return { plan, cleared }
250
+ })
251
+
252
+ return tx()
253
+ }
254
+
255
+ deletePlan(id: number) {
256
+ const tx = this.db.transaction(() => {
257
+ this.db.prepare("DELETE FROM active_plan WHERE plan_id = ?").run(id)
258
+ const stepIds = this.db
259
+ .prepare("SELECT id FROM steps WHERE plan_id = ?")
260
+ .all(id)
261
+ .map((row: any) => row.id as number)
262
+ if (stepIds.length) {
263
+ this.db
264
+ .prepare(`DELETE FROM goals WHERE step_id IN (${stepIds.map(() => "?").join(",")})`)
265
+ .run(...stepIds)
266
+ this.db.prepare("DELETE FROM steps WHERE plan_id = ?").run(id)
267
+ }
268
+ const result = this.db.prepare("DELETE FROM plans WHERE id = ?").run(id)
269
+ if (result.changes === 0) {
270
+ throw notFound(`plan id ${id}`)
271
+ }
272
+ })
273
+
274
+ tx()
275
+ }
276
+
277
+ addStepsBatch(
278
+ planId: number,
279
+ contents: string[],
280
+ status: StepStatus,
281
+ executor: StepExecutor,
282
+ at?: number | null,
283
+ ): { steps: StepRow[]; changes: StatusChanges } {
284
+ if (!this.db.prepare("SELECT 1 FROM plans WHERE id = ?").get(planId)) {
285
+ throw notFound(`plan id ${planId}`)
286
+ }
287
+ if (!contents.length) {
288
+ return { steps: [], changes: createEmptyStatusChanges() }
289
+ }
290
+ contents.forEach((content) => ensureNonEmpty("step content", content))
291
+
292
+ const tx = this.db.transaction(() => {
293
+ const existing = this.db
294
+ .prepare("SELECT * FROM steps WHERE plan_id = ? ORDER BY sort_order ASC, id ASC")
295
+ .all(planId) as StepRow[]
296
+ this.normalizeStepsInPlace(existing)
297
+
298
+ const total = existing.length
299
+ const insertPos = at !== undefined && at !== null ? (at > 0 ? Math.min(at, total + 1) : 1) : total + 1
300
+ const now = Date.now()
301
+ const shiftBy = contents.length
302
+ if (shiftBy > 0) {
303
+ for (let idx = existing.length - 1; idx >= 0; idx -= 1) {
304
+ const step = existing[idx]
305
+ if (step.sort_order >= insertPos) {
306
+ const newOrder = step.sort_order + shiftBy
307
+ this.db
308
+ .prepare("UPDATE steps SET sort_order = ?, updated_at = ? WHERE id = ?")
309
+ .run(newOrder, now, step.id)
310
+ step.sort_order = newOrder
311
+ step.updated_at = now
312
+ }
313
+ }
314
+ }
315
+
316
+ const created: StepRow[] = []
317
+ contents.forEach((content, idx) => {
318
+ const sortOrder = insertPos + idx
319
+ const result = this.db
320
+ .prepare(
321
+ `INSERT INTO steps (plan_id, content, status, executor, sort_order, comment, created_at, updated_at)
322
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`
323
+ )
324
+ .run(planId, content, status, executor, sortOrder, now, now)
325
+ const step = this.getStep(result.lastInsertRowid as number)
326
+ created.push(step)
327
+ })
328
+
329
+ const changes = this.refreshPlanStatus(planId)
330
+ this.touchPlan(planId)
331
+ return { steps: created, changes }
332
+ })
333
+
334
+ return tx()
335
+ }
336
+
337
+ addStepTree(planId: number, content: string, executor: StepExecutor, goals: string[]): { step: StepRow; goals: GoalRow[]; changes: StatusChanges } {
338
+ ensureNonEmpty("step content", content)
339
+ goals.forEach((goal) => ensureNonEmpty("goal content", goal))
340
+
341
+ const tx = this.db.transaction(() => {
342
+ if (!this.db.prepare("SELECT 1 FROM plans WHERE id = ?").get(planId)) {
343
+ throw notFound(`plan id ${planId}`)
344
+ }
345
+ const existing = this.db
346
+ .prepare("SELECT * FROM steps WHERE plan_id = ? ORDER BY sort_order ASC, id ASC")
347
+ .all(planId) as StepRow[]
348
+ this.normalizeStepsInPlace(existing)
349
+
350
+ const sortOrder = existing.length + 1
351
+ const now = Date.now()
352
+ const stepResult = this.db
353
+ .prepare(
354
+ `INSERT INTO steps (plan_id, content, status, executor, sort_order, comment, created_at, updated_at)
355
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`
356
+ )
357
+ .run(planId, content, "todo", executor, sortOrder, now, now)
358
+ const step = this.getStep(stepResult.lastInsertRowid as number)
359
+
360
+ const createdGoals: GoalRow[] = []
361
+ for (const goalContent of goals) {
362
+ const goalResult = this.db
363
+ .prepare(
364
+ `INSERT INTO goals (step_id, content, status, comment, created_at, updated_at)
365
+ VALUES (?, ?, ?, NULL, ?, ?)`
366
+ )
367
+ .run(step.id, goalContent, "todo", now, now)
368
+ createdGoals.push(this.getGoal(goalResult.lastInsertRowid as number))
369
+ }
370
+
371
+ const changes = this.refreshPlanStatus(planId)
372
+ this.touchPlan(planId)
373
+ return { step, goals: createdGoals, changes }
374
+ })
375
+
376
+ return tx()
377
+ }
378
+
379
+ listStepsFiltered(planId: number, query: StepQuery): StepRow[] {
380
+ this.getPlan(planId)
381
+ const conditions: string[] = ["plan_id = ?"]
382
+ const params: any[] = [planId]
383
+ if (query.status) {
384
+ conditions.push("status = ?")
385
+ params.push(query.status)
386
+ }
387
+ if (query.executor) {
388
+ conditions.push("executor = ?")
389
+ params.push(query.executor)
390
+ }
391
+ const order = query.order ?? "order"
392
+ const direction = query.desc ? "DESC" : "ASC"
393
+ const orderColumn =
394
+ order === "id"
395
+ ? "id"
396
+ : order === "created"
397
+ ? "created_at"
398
+ : order === "updated"
399
+ ? "updated_at"
400
+ : "sort_order"
401
+
402
+ let sql = `SELECT * FROM steps WHERE ${conditions.join(" AND ")} ORDER BY ${orderColumn} ${direction}, id ASC`
403
+ if (query.limit !== undefined) {
404
+ sql += " LIMIT ?"
405
+ params.push(query.limit)
406
+ }
407
+ if (query.offset !== undefined) {
408
+ sql += " OFFSET ?"
409
+ params.push(query.offset)
410
+ }
411
+ return this.db.prepare(sql).all(...params) as StepRow[]
412
+ }
413
+
414
+ countSteps(planId: number, query: StepQuery): number {
415
+ this.getPlan(planId)
416
+ const conditions: string[] = ["plan_id = ?"]
417
+ const params: any[] = [planId]
418
+ if (query.status) {
419
+ conditions.push("status = ?")
420
+ params.push(query.status)
421
+ }
422
+ if (query.executor) {
423
+ conditions.push("executor = ?")
424
+ params.push(query.executor)
425
+ }
426
+ const row = this.db
427
+ .prepare(`SELECT COUNT(*) as count FROM steps WHERE ${conditions.join(" AND ")}`)
428
+ .get(...params) as { count: number }
429
+ return row.count
430
+ }
431
+
432
+ nextStep(planId: number): StepRow | null {
433
+ const row = this.db
434
+ .prepare("SELECT * FROM steps WHERE plan_id = ? AND status = ? ORDER BY sort_order ASC, id ASC LIMIT 1")
435
+ .get(planId, "todo") as StepRow | undefined
436
+ return row ?? null
437
+ }
438
+
439
+ updateStep(id: number, changes: StepChanges): { step: StepRow; changes: StatusChanges } {
440
+ const tx = this.db.transaction(() => {
441
+ if (changes.content !== undefined) {
442
+ ensureNonEmpty("step content", changes.content)
443
+ }
444
+ if (changes.status === "done") {
445
+ const pending = this.nextGoalForStep(id)
446
+ if (pending) {
447
+ throw invalidInput(`cannot mark step done; next pending goal: ${pending.content} (id ${pending.id})`)
448
+ }
449
+ }
450
+ const existing = this.getStep(id)
451
+ const now = Date.now()
452
+
453
+ const updated = {
454
+ content: changes.content ?? existing.content,
455
+ status: changes.status ?? existing.status,
456
+ executor: changes.executor ?? existing.executor,
457
+ comment: changes.comment !== undefined ? changes.comment : existing.comment,
458
+ }
459
+ this.db
460
+ .prepare(
461
+ `UPDATE steps SET content = ?, status = ?, executor = ?, comment = ?, updated_at = ? WHERE id = ?`
462
+ )
463
+ .run(updated.content, updated.status, updated.executor, updated.comment, now, id)
464
+
465
+ const step = this.getStep(id)
466
+ const statusChanges = createEmptyStatusChanges()
467
+ if (changes.status !== undefined) {
468
+ mergeStatusChanges(statusChanges, this.refreshPlanStatus(step.plan_id))
469
+ }
470
+ this.touchPlan(step.plan_id)
471
+ return { step, changes: statusChanges }
472
+ })
473
+
474
+ return tx()
475
+ }
476
+
477
+ setStepDoneWithGoals(id: number, allGoals: boolean): { step: StepRow; changes: StatusChanges } {
478
+ const tx = this.db.transaction(() => {
479
+ let changes = createEmptyStatusChanges()
480
+ if (allGoals) {
481
+ const goalChanges = this.setAllGoalsDoneForStep(id)
482
+ mergeStatusChanges(changes, goalChanges)
483
+ } else {
484
+ const pending = this.nextGoalForStep(id)
485
+ if (pending) {
486
+ throw invalidInput(`cannot mark step done; next pending goal: ${pending.content} (id ${pending.id})`)
487
+ }
488
+ }
489
+
490
+ const existing = this.getStep(id)
491
+ if (existing.status !== "done") {
492
+ const now = Date.now()
493
+ this.db.prepare("UPDATE steps SET status = ?, updated_at = ? WHERE id = ?").run("done", now, id)
494
+ }
495
+ const step = this.getStep(id)
496
+ mergeStatusChanges(changes, this.refreshPlanStatus(step.plan_id))
497
+ this.touchPlan(step.plan_id)
498
+ return { step, changes }
499
+ })
500
+
501
+ return tx()
502
+ }
503
+
504
+ moveStep(id: number, to: number): StepRow[] {
505
+ const tx = this.db.transaction(() => {
506
+ const target = this.getStep(id)
507
+ const planId = target.plan_id
508
+ const steps = this.db
509
+ .prepare("SELECT * FROM steps WHERE plan_id = ? ORDER BY sort_order ASC, id ASC")
510
+ .all(planId) as StepRow[]
511
+ const currentIndex = steps.findIndex((step) => step.id === id)
512
+ if (currentIndex === -1) throw notFound(`step id ${id}`)
513
+ let desiredIndex = Math.max(to - 1, 0)
514
+ if (desiredIndex >= steps.length) desiredIndex = steps.length - 1
515
+
516
+ const [moving] = steps.splice(currentIndex, 1)
517
+ if (desiredIndex >= steps.length) steps.push(moving)
518
+ else steps.splice(desiredIndex, 0, moving)
519
+
520
+ const now = Date.now()
521
+ steps.forEach((step, idx) => {
522
+ const desiredOrder = idx + 1
523
+ if (step.sort_order !== desiredOrder) {
524
+ this.db
525
+ .prepare("UPDATE steps SET sort_order = ?, updated_at = ? WHERE id = ?")
526
+ .run(desiredOrder, now, step.id)
527
+ step.sort_order = desiredOrder
528
+ step.updated_at = now
529
+ }
530
+ })
531
+ return steps
532
+ })
533
+
534
+ return tx()
535
+ }
536
+
537
+ deleteSteps(ids: number[]): { deleted: number; changes: StatusChanges } {
538
+ const tx = this.db.transaction(() => {
539
+ if (!ids.length) return { deleted: 0, changes: createEmptyStatusChanges() }
540
+ const unique = uniqueIds(ids)
541
+ const steps = this.db
542
+ .prepare(`SELECT * FROM steps WHERE id IN (${unique.map(() => "?").join(",")})`)
543
+ .all(...unique) as StepRow[]
544
+ const existing = new Set(steps.map((step) => step.id))
545
+ const missing = unique.filter((id) => !existing.has(id))
546
+ if (missing.length) {
547
+ throw notFound(`step id(s) not found: ${joinIds(missing)}`)
548
+ }
549
+
550
+ const planIds = Array.from(new Set(steps.map((step) => step.plan_id)))
551
+ if (unique.length) {
552
+ this.db
553
+ .prepare(`DELETE FROM goals WHERE step_id IN (${unique.map(() => "?").join(",")})`)
554
+ .run(...unique)
555
+ }
556
+ const result = this.db
557
+ .prepare(`DELETE FROM steps WHERE id IN (${unique.map(() => "?").join(",")})`)
558
+ .run(...unique)
559
+
560
+ planIds.forEach((planId) => this.normalizeStepsForPlan(planId))
561
+
562
+ const changes = createEmptyStatusChanges()
563
+ planIds.forEach((planId) => mergeStatusChanges(changes, this.refreshPlanStatus(planId)))
564
+ if (planIds.length) {
565
+ this.touchPlans(planIds)
566
+ }
567
+ return { deleted: result.changes, changes }
568
+ })
569
+
570
+ return tx()
571
+ }
572
+
573
+ addGoalsBatch(stepId: number, contents: string[], status: GoalStatus): { goals: GoalRow[]; changes: StatusChanges } {
574
+ if (!contents.length) return { goals: [], changes: createEmptyStatusChanges() }
575
+ contents.forEach((content) => ensureNonEmpty("goal content", content))
576
+
577
+ const tx = this.db.transaction(() => {
578
+ const step = this.getStep(stepId)
579
+ const now = Date.now()
580
+ const created: GoalRow[] = []
581
+ contents.forEach((content) => {
582
+ const result = this.db
583
+ .prepare(
584
+ `INSERT INTO goals (step_id, content, status, comment, created_at, updated_at)
585
+ VALUES (?, ?, ?, NULL, ?, ?)`
586
+ )
587
+ .run(stepId, content, status, now, now)
588
+ created.push(this.getGoal(result.lastInsertRowid as number))
589
+ })
590
+ const changes = this.refreshStepStatus(stepId)
591
+ this.touchPlan(step.plan_id)
592
+ return { goals: created, changes }
593
+ })
594
+
595
+ return tx()
596
+ }
597
+
598
+ listGoalsFiltered(stepId: number, query: GoalQuery): GoalRow[] {
599
+ this.getStep(stepId)
600
+ const conditions: string[] = ["step_id = ?"]
601
+ const params: any[] = [stepId]
602
+ if (query.status) {
603
+ conditions.push("status = ?")
604
+ params.push(query.status)
605
+ }
606
+ let sql = `SELECT * FROM goals WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC, id DESC`
607
+ if (query.limit !== undefined) {
608
+ sql += " LIMIT ?"
609
+ params.push(query.limit)
610
+ }
611
+ if (query.offset !== undefined) {
612
+ sql += " OFFSET ?"
613
+ params.push(query.offset)
614
+ }
615
+ return this.db.prepare(sql).all(...params) as GoalRow[]
616
+ }
617
+
618
+ countGoals(stepId: number, query: GoalQuery): number {
619
+ this.getStep(stepId)
620
+ const conditions: string[] = ["step_id = ?"]
621
+ const params: any[] = [stepId]
622
+ if (query.status) {
623
+ conditions.push("status = ?")
624
+ params.push(query.status)
625
+ }
626
+ const row = this.db
627
+ .prepare(`SELECT COUNT(*) as count FROM goals WHERE ${conditions.join(" AND ")}`)
628
+ .get(...params) as { count: number }
629
+ return row.count
630
+ }
631
+
632
+ updateGoal(id: number, changes: GoalChanges): { goal: GoalRow; changes: StatusChanges } {
633
+ const tx = this.db.transaction(() => {
634
+ if (changes.content !== undefined) {
635
+ ensureNonEmpty("goal content", changes.content)
636
+ }
637
+ const existing = this.getGoal(id)
638
+ const now = Date.now()
639
+ const updated = {
640
+ content: changes.content ?? existing.content,
641
+ status: changes.status ?? existing.status,
642
+ comment: changes.comment !== undefined ? changes.comment : existing.comment,
643
+ }
644
+ this.db
645
+ .prepare("UPDATE goals SET content = ?, status = ?, comment = ?, updated_at = ? WHERE id = ?")
646
+ .run(updated.content, updated.status, updated.comment, now, id)
647
+
648
+ const goal = this.getGoal(id)
649
+ const statusChanges = createEmptyStatusChanges()
650
+ if (changes.status !== undefined) {
651
+ mergeStatusChanges(statusChanges, this.refreshStepStatus(goal.step_id))
652
+ }
653
+ const step = this.getStep(goal.step_id)
654
+ this.touchPlan(step.plan_id)
655
+ return { goal, changes: statusChanges }
656
+ })
657
+
658
+ return tx()
659
+ }
660
+
661
+ setGoalStatus(id: number, status: GoalStatus): { goal: GoalRow; changes: StatusChanges } {
662
+ const tx = this.db.transaction(() => {
663
+ this.getGoal(id)
664
+ const now = Date.now()
665
+ this.db.prepare("UPDATE goals SET status = ?, updated_at = ? WHERE id = ?").run(status, now, id)
666
+ const goal = this.getGoal(id)
667
+ const changes = this.refreshStepStatus(goal.step_id)
668
+ const step = this.getStep(goal.step_id)
669
+ this.touchPlan(step.plan_id)
670
+ return { goal, changes }
671
+ })
672
+
673
+ return tx()
674
+ }
675
+
676
+ setGoalsStatus(ids: number[], status: GoalStatus): { updated: number; changes: StatusChanges } {
677
+ if (!ids.length) return { updated: 0, changes: createEmptyStatusChanges() }
678
+ const tx = this.db.transaction(() => {
679
+ const unique = uniqueIds(ids)
680
+ const goals = this.db
681
+ .prepare(`SELECT * FROM goals WHERE id IN (${unique.map(() => "?").join(",")})`)
682
+ .all(...unique) as GoalRow[]
683
+ const existing = new Set(goals.map((goal) => goal.id))
684
+ const missing = unique.filter((id) => !existing.has(id))
685
+ if (missing.length) {
686
+ throw notFound(`goal id(s) not found: ${joinIds(missing)}`)
687
+ }
688
+ const now = Date.now()
689
+ const stepIds: number[] = []
690
+ const stepSeen = new Set<number>()
691
+ goals.forEach((goal) => {
692
+ if (!stepSeen.has(goal.step_id)) {
693
+ stepSeen.add(goal.step_id)
694
+ stepIds.push(goal.step_id)
695
+ }
696
+ this.db.prepare("UPDATE goals SET status = ?, updated_at = ? WHERE id = ?").run(status, now, goal.id)
697
+ })
698
+
699
+ const changes = createEmptyStatusChanges()
700
+ stepIds.forEach((stepId) => mergeStatusChanges(changes, this.refreshStepStatus(stepId)))
701
+
702
+ const planIds: number[] = []
703
+ if (stepIds.length) {
704
+ const steps = this.db
705
+ .prepare(`SELECT plan_id FROM steps WHERE id IN (${stepIds.map(() => "?").join(",")})`)
706
+ .all(...stepIds) as Array<{ plan_id: number }>
707
+ const seen = new Set<number>()
708
+ steps.forEach((row) => {
709
+ if (!seen.has(row.plan_id)) {
710
+ seen.add(row.plan_id)
711
+ planIds.push(row.plan_id)
712
+ }
713
+ })
714
+ }
715
+ if (planIds.length) this.touchPlans(planIds)
716
+
717
+ return { updated: unique.length, changes }
718
+ })
719
+
720
+ return tx()
721
+ }
722
+
723
+ deleteGoals(ids: number[]): { deleted: number; changes: StatusChanges } {
724
+ const tx = this.db.transaction(() => {
725
+ if (!ids.length) return { deleted: 0, changes: createEmptyStatusChanges() }
726
+ const unique = uniqueIds(ids)
727
+ const goals = this.db
728
+ .prepare(`SELECT * FROM goals WHERE id IN (${unique.map(() => "?").join(",")})`)
729
+ .all(...unique) as GoalRow[]
730
+ const existing = new Set(goals.map((goal) => goal.id))
731
+ const missing = unique.filter((id) => !existing.has(id))
732
+ if (missing.length) {
733
+ throw notFound(`goal id(s) not found: ${joinIds(missing)}`)
734
+ }
735
+
736
+ const stepIds = Array.from(new Set(goals.map((goal) => goal.step_id)))
737
+ const result = this.db
738
+ .prepare(`DELETE FROM goals WHERE id IN (${unique.map(() => "?").join(",")})`)
739
+ .run(...unique)
740
+
741
+ const changes = createEmptyStatusChanges()
742
+ stepIds.forEach((stepId) => mergeStatusChanges(changes, this.refreshStepStatus(stepId)))
743
+
744
+ if (stepIds.length) {
745
+ const planIds: number[] = []
746
+ const steps = this.db
747
+ .prepare(`SELECT plan_id FROM steps WHERE id IN (${stepIds.map(() => "?").join(",")})`)
748
+ .all(...stepIds) as Array<{ plan_id: number }>
749
+ const seen = new Set<number>()
750
+ steps.forEach((row) => {
751
+ if (!seen.has(row.plan_id)) {
752
+ seen.add(row.plan_id)
753
+ planIds.push(row.plan_id)
754
+ }
755
+ })
756
+ if (planIds.length) this.touchPlans(planIds)
757
+ }
758
+
759
+ return { deleted: result.changes, changes }
760
+ })
761
+
762
+ return tx()
763
+ }
764
+
765
+ commentPlans(entries: Array<[number, string]>): number[] {
766
+ const normalized = normalizeCommentEntries(entries)
767
+ if (!normalized.length) return []
768
+ const ids = normalized.map(([id]) => id)
769
+ const tx = this.db.transaction(() => {
770
+ const existing = this.db
771
+ .prepare(`SELECT id FROM plans WHERE id IN (${ids.map(() => "?").join(",")})`)
772
+ .all(...ids)
773
+ .map((row: any) => row.id as number)
774
+ const existingSet = new Set(existing)
775
+ const missing = ids.filter((id) => !existingSet.has(id))
776
+ if (missing.length) {
777
+ throw notFound(`plan id(s) not found: ${joinIds(missing)}`)
778
+ }
779
+ const now = Date.now()
780
+ normalized.forEach(([planId, comment]) => {
781
+ if (this.cwd) {
782
+ this.db
783
+ .prepare("UPDATE plans SET comment = ?, last_session_id = ?, last_cwd = ?, updated_at = ? WHERE id = ?")
784
+ .run(comment, this.sessionId, this.cwd, now, planId)
785
+ } else {
786
+ this.db
787
+ .prepare("UPDATE plans SET comment = ?, last_session_id = ?, updated_at = ? WHERE id = ?")
788
+ .run(comment, this.sessionId, now, planId)
789
+ }
790
+ })
791
+ return ids
792
+ })
793
+
794
+ return tx()
795
+ }
796
+
797
+ commentSteps(entries: Array<[number, string]>): number[] {
798
+ const normalized = normalizeCommentEntries(entries)
799
+ if (!normalized.length) return []
800
+ const ids = normalized.map(([id]) => id)
801
+ const tx = this.db.transaction(() => {
802
+ const steps = this.db
803
+ .prepare(`SELECT * FROM steps WHERE id IN (${ids.map(() => "?").join(",")})`)
804
+ .all(...ids) as StepRow[]
805
+ const existing = new Set(steps.map((step) => step.id))
806
+ const missing = ids.filter((id) => !existing.has(id))
807
+ if (missing.length) {
808
+ throw notFound(`step id(s) not found: ${joinIds(missing)}`)
809
+ }
810
+
811
+ const planIds = Array.from(new Set(steps.map((step) => step.plan_id)))
812
+ const now = Date.now()
813
+ normalized.forEach(([stepId, comment]) => {
814
+ this.db.prepare("UPDATE steps SET comment = ?, updated_at = ? WHERE id = ?").run(comment, now, stepId)
815
+ })
816
+ if (planIds.length) this.touchPlans(planIds)
817
+ return planIds
818
+ })
819
+
820
+ return tx()
821
+ }
822
+
823
+ commentGoals(entries: Array<[number, string]>): number[] {
824
+ const normalized = normalizeCommentEntries(entries)
825
+ if (!normalized.length) return []
826
+ const ids = normalized.map(([id]) => id)
827
+ const tx = this.db.transaction(() => {
828
+ const goals = this.db
829
+ .prepare(`SELECT * FROM goals WHERE id IN (${ids.map(() => "?").join(",")})`)
830
+ .all(...ids) as GoalRow[]
831
+ const existing = new Set(goals.map((goal) => goal.id))
832
+ const missing = ids.filter((id) => !existing.has(id))
833
+ if (missing.length) {
834
+ throw notFound(`goal id(s) not found: ${joinIds(missing)}`)
835
+ }
836
+
837
+ const stepIds = Array.from(new Set(goals.map((goal) => goal.step_id)))
838
+ const now = Date.now()
839
+ normalized.forEach(([goalId, comment]) => {
840
+ this.db.prepare("UPDATE goals SET comment = ?, updated_at = ? WHERE id = ?").run(comment, now, goalId)
841
+ })
842
+
843
+ if (stepIds.length) {
844
+ const planIds: number[] = []
845
+ const steps = this.db
846
+ .prepare(`SELECT plan_id FROM steps WHERE id IN (${stepIds.map(() => "?").join(",")})`)
847
+ .all(...stepIds) as Array<{ plan_id: number }>
848
+ const seen = new Set<number>()
849
+ steps.forEach((row) => {
850
+ if (!seen.has(row.plan_id)) {
851
+ seen.add(row.plan_id)
852
+ planIds.push(row.plan_id)
853
+ }
854
+ })
855
+ if (planIds.length) this.touchPlans(planIds)
856
+ return planIds
857
+ }
858
+
859
+ return []
860
+ })
861
+
862
+ return tx()
863
+ }
864
+
865
+ goalsForStep(stepId: number): GoalRow[] {
866
+ return this.db.prepare("SELECT * FROM goals WHERE step_id = ? ORDER BY id ASC").all(stepId) as GoalRow[]
867
+ }
868
+
869
+ goalsForSteps(stepIds: number[]): Map<number, GoalRow[]> {
870
+ const grouped = new Map<number, GoalRow[]>()
871
+ if (!stepIds.length) return grouped
872
+ const rows = this.db
873
+ .prepare(`SELECT * FROM goals WHERE step_id IN (${stepIds.map(() => "?").join(",")}) ORDER BY step_id ASC, id ASC`)
874
+ .all(...stepIds) as GoalRow[]
875
+ rows.forEach((goal) => {
876
+ const list = grouped.get(goal.step_id)
877
+ if (list) list.push(goal)
878
+ else grouped.set(goal.step_id, [goal])
879
+ })
880
+ return grouped
881
+ }
882
+
883
+ planIdsForSteps(ids: number[]): number[] {
884
+ if (!ids.length) return []
885
+ const unique = uniqueIds(ids)
886
+ const rows = this.db
887
+ .prepare(`SELECT plan_id FROM steps WHERE id IN (${unique.map(() => "?").join(",")})`)
888
+ .all(...unique) as Array<{ plan_id: number }>
889
+ const seen = new Set<number>()
890
+ const planIds: number[] = []
891
+ rows.forEach((row) => {
892
+ if (!seen.has(row.plan_id)) {
893
+ seen.add(row.plan_id)
894
+ planIds.push(row.plan_id)
895
+ }
896
+ })
897
+ return planIds
898
+ }
899
+
900
+ planIdsForGoals(ids: number[]): number[] {
901
+ if (!ids.length) return []
902
+ const unique = uniqueIds(ids)
903
+ const goals = this.db
904
+ .prepare(`SELECT step_id FROM goals WHERE id IN (${unique.map(() => "?").join(",")})`)
905
+ .all(...unique) as Array<{ step_id: number }>
906
+ const stepIds = Array.from(new Set(goals.map((row) => row.step_id)))
907
+ if (!stepIds.length) return []
908
+ const steps = this.db
909
+ .prepare(`SELECT plan_id FROM steps WHERE id IN (${stepIds.map(() => "?").join(",")})`)
910
+ .all(...stepIds) as Array<{ plan_id: number }>
911
+ const seen = new Set<number>()
912
+ const planIds: number[] = []
913
+ steps.forEach((row) => {
914
+ if (!seen.has(row.plan_id)) {
915
+ seen.add(row.plan_id)
916
+ planIds.push(row.plan_id)
917
+ }
918
+ })
919
+ return planIds
920
+ }
921
+
922
+ private updatePlanWithConn(id: number, changes: PlanChanges): PlanRow {
923
+ if (changes.title !== undefined) ensureNonEmpty("plan title", changes.title)
924
+ if (changes.content !== undefined) ensureNonEmpty("plan content", changes.content)
925
+ if (changes.status === "done") {
926
+ const total = this.db.prepare("SELECT COUNT(*) as count FROM steps WHERE plan_id = ?").get(id) as { count: number }
927
+ if (total.count > 0) {
928
+ const next = this.nextStep(id)
929
+ if (next) {
930
+ const goals = this.goalsForStep(next.id)
931
+ const detail = formatStepDetail(next, goals)
932
+ throw invalidInput(`cannot mark plan done; next pending step:\n${detail}`)
933
+ }
934
+ }
935
+ }
936
+
937
+ const existing = this.getPlan(id)
938
+ const now = Date.now()
939
+ const updated = {
940
+ title: changes.title ?? existing.title,
941
+ content: changes.content ?? existing.content,
942
+ status: changes.status ?? existing.status,
943
+ comment: changes.comment !== undefined ? changes.comment : existing.comment,
944
+ }
945
+
946
+ if (this.cwd) {
947
+ this.db
948
+ .prepare(
949
+ `UPDATE plans SET title = ?, content = ?, status = ?, comment = ?, last_session_id = ?, last_cwd = ?, updated_at = ? WHERE id = ?`
950
+ )
951
+ .run(updated.title, updated.content, updated.status, updated.comment, this.sessionId, this.cwd, now, id)
952
+ } else {
953
+ this.db
954
+ .prepare(
955
+ `UPDATE plans SET title = ?, content = ?, status = ?, comment = ?, last_session_id = ?, updated_at = ? WHERE id = ?`
956
+ )
957
+ .run(updated.title, updated.content, updated.status, updated.comment, this.sessionId, now, id)
958
+ }
959
+
960
+ return this.getPlan(id)
961
+ }
962
+
963
+ private refreshPlanStatus(planId: number): StatusChanges {
964
+ const total = this.db.prepare("SELECT COUNT(*) as count FROM steps WHERE plan_id = ?").get(planId) as { count: number }
965
+ if (total.count === 0) return createEmptyStatusChanges()
966
+ const done = this.db
967
+ .prepare("SELECT COUNT(*) as count FROM steps WHERE plan_id = ? AND status = ?")
968
+ .get(planId, "done") as { count: number }
969
+ const status: PlanStatus = done.count === total.count ? "done" : "todo"
970
+
971
+ const plan = this.getPlan(planId)
972
+ const changes = createEmptyStatusChanges()
973
+ if (plan.status !== status) {
974
+ const now = Date.now()
975
+ const reason = done.count === total.count ? `all steps are done (${done.count}/${total.count})` : `steps done ${done.count}/${total.count}`
976
+ this.db.prepare("UPDATE plans SET status = ?, updated_at = ? WHERE id = ?").run(status, now, planId)
977
+ changes.plans.push({ plan_id: planId, from: plan.status, to: status, reason })
978
+ if (status === "done") {
979
+ const clearedCurrent = this.clearActivePlansForPlanWithConn(planId)
980
+ if (clearedCurrent) {
981
+ changes.active_plans_cleared.push({ plan_id: planId, reason: "plan marked done" })
982
+ }
983
+ }
984
+ }
985
+ return changes
986
+ }
987
+
988
+ private refreshStepStatus(stepId: number): StatusChanges {
989
+ const goals = this.db.prepare("SELECT * FROM goals WHERE step_id = ? ORDER BY id ASC").all(stepId) as GoalRow[]
990
+ if (!goals.length) return createEmptyStatusChanges()
991
+ const doneCount = goals.filter((goal) => goal.status === "done").length
992
+ const total = goals.length
993
+ const status: StepStatus = doneCount === total ? "done" : "todo"
994
+
995
+ const step = this.getStep(stepId)
996
+ const changes = createEmptyStatusChanges()
997
+ if (step.status !== status) {
998
+ const now = Date.now()
999
+ const reason = doneCount === total ? `all goals are done (${doneCount}/${total})` : `goals done ${doneCount}/${total}`
1000
+ this.db.prepare("UPDATE steps SET status = ?, updated_at = ? WHERE id = ?").run(status, now, stepId)
1001
+ changes.steps.push({ step_id: stepId, from: step.status, to: status, reason })
1002
+ }
1003
+ mergeStatusChanges(changes, this.refreshPlanStatus(step.plan_id))
1004
+ return changes
1005
+ }
1006
+
1007
+ private nextGoalForStep(stepId: number): GoalRow | null {
1008
+ const row = this.db
1009
+ .prepare("SELECT * FROM goals WHERE step_id = ? AND status = ? ORDER BY id ASC LIMIT 1")
1010
+ .get(stepId, "todo") as GoalRow | undefined
1011
+ return row ?? null
1012
+ }
1013
+
1014
+ private setAllGoalsDoneForStep(stepId: number): StatusChanges {
1015
+ this.getStep(stepId)
1016
+ const goals = this.goalsForStep(stepId)
1017
+ if (!goals.length) return createEmptyStatusChanges()
1018
+ const ids = goals.map((goal) => goal.id)
1019
+ return this.setGoalsStatus(ids, "done").changes
1020
+ }
1021
+
1022
+ private touchPlan(planId: number) {
1023
+ const now = Date.now()
1024
+ if (this.cwd) {
1025
+ const result = this.db
1026
+ .prepare("UPDATE plans SET last_session_id = ?, last_cwd = ?, updated_at = ? WHERE id = ?")
1027
+ .run(this.sessionId, this.cwd, now, planId)
1028
+ if (result.changes === 0) {
1029
+ throw notFound(`plan id ${planId}`)
1030
+ }
1031
+ } else {
1032
+ const result = this.db
1033
+ .prepare("UPDATE plans SET last_session_id = ?, updated_at = ? WHERE id = ?")
1034
+ .run(this.sessionId, now, planId)
1035
+ if (result.changes === 0) {
1036
+ throw notFound(`plan id ${planId}`)
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ private touchPlans(planIds: number[]) {
1042
+ planIds.forEach((planId) => this.touchPlan(planId))
1043
+ }
1044
+
1045
+ private normalizeStepsForPlan(planId: number) {
1046
+ const steps = this.db
1047
+ .prepare("SELECT * FROM steps WHERE plan_id = ? ORDER BY sort_order ASC, id ASC")
1048
+ .all(planId) as StepRow[]
1049
+ this.normalizeStepsInPlace(steps)
1050
+ }
1051
+
1052
+ private normalizeStepsInPlace(steps: StepRow[]) {
1053
+ const now = Date.now()
1054
+ steps.forEach((step, idx) => {
1055
+ const desired = idx + 1
1056
+ if (step.sort_order !== desired) {
1057
+ this.db.prepare("UPDATE steps SET sort_order = ?, updated_at = ? WHERE id = ?").run(desired, now, step.id)
1058
+ step.sort_order = desired
1059
+ step.updated_at = now
1060
+ }
1061
+ })
1062
+ }
1063
+
1064
+ private clearActivePlansForPlanWithConn(planId: number): boolean {
1065
+ const existing = this.db
1066
+ .prepare("SELECT * FROM active_plan WHERE plan_id = ?")
1067
+ .all(planId) as ActivePlanRow[]
1068
+ const clearedCurrent = existing.some((row) => row.session_id === this.sessionId)
1069
+ this.db.prepare("DELETE FROM active_plan WHERE plan_id = ?").run(planId)
1070
+ return clearedCurrent
1071
+ }
1072
+ }